refactor(editor): extract ai widgets and tool (#10367)

### TL;DR
Moved AI-related components from BlockSuite core to the frontend presets directory to better organize AI functionality.

### What changed?
- Relocated AI panel, copilot tool, and related components from BlockSuite core to frontend presets
- Moved AI widget definitions and registrations to the presets directory
- Updated imports to reference new component locations
- Removed AI component registrations from core effects.ts
- Added AI component registrations to presets effects.ts

### How to test?
1. Verify AI panel functionality works as expected in the editor
2. Test copilot tool interactions in edgeless mode
3. Confirm AI suggestions and responses still appear correctly
4. Check that AI toolbar buttons and menus function properly
5. Ensure AI error states and loading indicators display correctly

### Why make this change?
This restructuring improves code organization by moving AI-specific functionality out of the core BlockSuite library and into the frontend presets where it more logically belongs. This separation of concerns makes the codebase more maintainable and allows for better modularity of AI features.
This commit is contained in:
Saul-Mirone
2025-02-22 13:58:40 +00:00
parent 963cc2e40e
commit 3ff6176306
42 changed files with 157 additions and 168 deletions

View File

@@ -9,7 +9,6 @@ import type { ExtensionType } from '@blocksuite/store';
import { EdgelessRootBlockSpec } from './edgeless-root-spec.js';
import { BrushTool } from './gfx-tool/brush-tool.js';
import { ConnectorTool } from './gfx-tool/connector-tool.js';
import { CopilotTool } from './gfx-tool/copilot-tool.js';
import { DefaultTool } from './gfx-tool/default-tool.js';
import { MindMapIndicatorOverlay } from './gfx-tool/default-tool-ext/mind-map-ext/indicator-overlay.js';
import { EmptyTool } from './gfx-tool/empty-tool.js';
@@ -33,7 +32,6 @@ export const EdgelessToolExtension: ExtensionType[] = [
NoteTool,
BrushTool,
ConnectorTool,
CopilotTool,
TemplateTool,
EmptyTool,
FrameTool,

View File

@@ -1,178 +0,0 @@
/* oxlint-disable @typescript-eslint/no-non-null-assertion */
import type { PointerEventState } from '@blocksuite/block-std';
import {
BaseTool,
type GfxModel,
MouseButton,
} from '@blocksuite/block-std/gfx';
import { IS_MAC } from '@blocksuite/global/env';
import {
Bound,
getCommonBoundWithRotation,
Slot,
} from '@blocksuite/global/utils';
import {
AFFINE_AI_PANEL_WIDGET,
type AffineAIPanelWidget,
} from '../../widgets/ai-panel/ai-panel.js';
export class CopilotTool extends BaseTool {
static override toolName: string = 'copilot';
private _dragging = false;
draggingAreaUpdated = new Slot<boolean | void>();
dragLastPoint: [number, number] = [0, 0];
dragStartPoint: [number, number] = [0, 0];
override get allowDragWithRightButton() {
return true;
}
get area() {
const start = new DOMPoint(this.dragStartPoint[0], this.dragStartPoint[1]);
const end = new DOMPoint(this.dragLastPoint[0], this.dragLastPoint[1]);
const minX = Math.min(start.x, end.x);
const minY = Math.min(start.y, end.y);
const maxX = Math.max(start.x, end.x);
const maxY = Math.max(start.y, end.y);
return new DOMRect(minX, minY, maxX - minX, maxY - minY);
}
// AI processing
get processing() {
const aiPanel = this.gfx.std.view.getWidget(
AFFINE_AI_PANEL_WIDGET,
this.doc.root!.id
) as AffineAIPanelWidget;
return aiPanel && aiPanel.state !== 'hidden';
}
get selectedElements() {
return this.gfx.selection.selectedElements;
}
private _initDragState(e: PointerEventState) {
this.dragStartPoint = this.gfx.viewport.toModelCoord(e.x, e.y);
this.dragLastPoint = this.dragStartPoint;
}
abort() {
this._dragging = false;
this.dragStartPoint = [0, 0];
this.dragLastPoint = [0, 0];
this.gfx.tool.setTool('default');
}
override activate(): void {
this.gfx.viewport.locked = true;
if (this.gfx.selection.lastSurfaceSelections) {
this.gfx.selection.set(this.gfx.selection.lastSurfaceSelections);
}
}
override deactivate(): void {
this.gfx.viewport.locked = false;
}
override dragEnd(): void {
if (!this._dragging) return;
this._dragging = false;
this.draggingAreaUpdated.emit(true);
}
override dragMove(e: PointerEventState): void {
if (!this._dragging) return;
this.dragLastPoint = this.gfx.viewport.toModelCoord(e.x, e.y);
const area = this.area;
const bound = new Bound(area.x, area.y, area.width, area.height);
if (area.width & area.height) {
const elements = this.gfx.getElementsByBound(bound);
const set = new Set(elements);
this.gfx.selection.set({
elements: Array.from(set).map(element => element.id),
editing: false,
inoperable: true,
});
}
this.draggingAreaUpdated.emit();
}
override dragStart(e: PointerEventState): void {
if (this.processing) return;
this._initDragState(e);
this._dragging = true;
this.draggingAreaUpdated.emit();
}
override mounted(): void {
this.addHook('pointerDown', evt => {
const useCopilot =
evt.raw.button === MouseButton.SECONDARY ||
(evt.raw.button === MouseButton.MAIN && IS_MAC
? evt.raw.metaKey
: evt.raw.ctrlKey);
if (useCopilot) {
this.controller.setTool('copilot');
return false;
}
return;
});
}
override pointerDown(e: PointerEventState): void {
if (this.processing) {
e.raw.stopPropagation();
return;
}
this.gfx.tool.setTool('default');
}
updateDragPointsWith(selectedElements: GfxModel[], padding = 0) {
const bounds = getCommonBoundWithRotation(selectedElements).expand(
padding / this.gfx.viewport.zoom
);
this.dragStartPoint = bounds.tl as [number, number];
this.dragLastPoint = bounds.br as [number, number];
}
updateSelectionWith(selectedElements: GfxModel[], padding = 0) {
const { selection } = this.gfx;
selection.clear();
this.updateDragPointsWith(selectedElements, padding);
selection.set({
elements: selectedElements.map(e => e.id),
editing: false,
inoperable: true,
});
this.draggingAreaUpdated.emit(true);
}
}
declare module '@blocksuite/block-std/gfx' {
interface GfxToolsMap {
copilot: CopilotTool;
}
}

View File

@@ -1,6 +1,5 @@
export { BrushTool } from './brush-tool.js';
export { ConnectorTool, type ConnectorToolOptions } from './connector-tool.js';
export { CopilotTool } from './copilot-tool.js';
export { DefaultTool } from './default-tool.js';
export { EmptyTool } from './empty-tool.js';
export { EraserTool } from './eraser-tool.js';

View File

@@ -6,4 +6,5 @@ export { EdgelessRootPreviewBlockComponent } from './edgeless-root-preview-block
export { EdgelessRootService } from './edgeless-root-service.js';
export * from './gfx-tool';
export * from './utils/clipboard-utils.js';
export { sortEdgelessElements } from './utils/clone-utils.js';
export { isCanvasElement } from './utils/query.js';

View File

@@ -75,17 +75,12 @@ import { EdgelessTemplatePanel } from './edgeless/components/toolbar/template/te
import { EdgelessTemplateButton } from './edgeless/components/toolbar/template/template-tool-button.js';
import { EdgelessTextMenu } from './edgeless/components/toolbar/text/text-menu.js';
import {
AFFINE_AI_PANEL_WIDGET,
AFFINE_EDGELESS_COPILOT_WIDGET,
AFFINE_EMBED_CARD_TOOLBAR_WIDGET,
AFFINE_FORMAT_BAR_WIDGET,
AffineAIPanelWidget,
AffineFormatBarWidget,
AffineImageToolbarWidget,
AffineModalWidget,
EDGELESS_TOOLBAR_WIDGET,
EdgelessCopilotToolbarEntry,
EdgelessCopilotWidget,
EdgelessRootBlockComponent,
EdgelessRootPreviewBlockComponent,
EmbedCardToolbar,
@@ -93,16 +88,6 @@ import {
PageRootBlockComponent,
PreviewRootBlockComponent,
} from './index.js';
import { AIPanelDivider } from './widgets/ai-panel/components/divider.js';
import { AIFinishTip } from './widgets/ai-panel/components/finish-tip.js';
import { GeneratingPlaceholder } from './widgets/ai-panel/components/generating-placeholder.js';
import {
AIPanelAnswer,
AIPanelError,
AIPanelGenerating,
AIPanelInput,
} from './widgets/ai-panel/components/index.js';
import { EdgelessCopilotPanel } from './widgets/edgeless-copilot-panel/index.js';
import {
AFFINE_EDGELESS_ZOOM_TOOLBAR_WIDGET,
AffineEdgelessZoomToolbarWidget,
@@ -154,7 +139,6 @@ export function effects() {
registerEdgelessToolbarComponents();
registerEdgelessPanelComponents();
registerEdgelessEditorComponents();
registerAIComponents();
registerMiscComponents();
}
@@ -169,7 +153,6 @@ function registerRootComponents() {
}
function registerWidgets() {
customElements.define(AFFINE_AI_PANEL_WIDGET, AffineAIPanelWidget);
customElements.define(AFFINE_EMBED_CARD_TOOLBAR_WIDGET, EmbedCardToolbar);
customElements.define(AFFINE_INNER_MODAL_WIDGET, AffineInnerModalWidget);
customElements.define(AFFINE_MODAL_WIDGET, AffineModalWidget);
@@ -177,7 +160,6 @@ function registerWidgets() {
AFFINE_PAGE_DRAGGING_AREA_WIDGET,
AffinePageDraggingAreaWidget
);
customElements.define(AFFINE_EDGELESS_COPILOT_WIDGET, EdgelessCopilotWidget);
customElements.define(AFFINE_IMAGE_TOOLBAR_WIDGET, AffineImageToolbarWidget);
customElements.define(AFFINE_SLASH_MENU_WIDGET, AffineSlashMenuWidget);
customElements.define(
@@ -295,16 +277,6 @@ function registerEdgelessEditorComponents() {
customElements.define('edgeless-text-editor', EdgelessTextEditor);
}
function registerAIComponents() {
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);
}
function registerMiscComponents() {
// Modal and menu components
customElements.define('affine-custom-modal', AffineCustomModal);
@@ -350,13 +322,6 @@ function registerMiscComponents() {
EdgelessSelectedRectWidget
);
// Copilot components
customElements.define('edgeless-copilot-panel', EdgelessCopilotPanel);
customElements.define(
'edgeless-copilot-toolbar-entry',
EdgelessCopilotToolbarEntry
);
// Mindmap components
customElements.define('mindmap-import-placeholder', MindMapPlaceholder);

View File

@@ -1,549 +0,0 @@
import type { AIError } from '@blocksuite/affine-components/ai-item';
import {
DocModeProvider,
NotificationProvider,
ThemeProvider,
} from '@blocksuite/affine-shared/services';
import {
getPageRootByElement,
stopPropagation,
} from '@blocksuite/affine-shared/utils';
import { WidgetComponent } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { assertExists } from '@blocksuite/global/utils';
import type { BaseSelection } from '@blocksuite/store';
import {
autoPlacement,
autoUpdate,
computePosition,
type ComputePositionConfig,
flip,
offset,
type Rect,
shift,
} from '@floating-ui/dom';
import { css, html, nothing, type PropertyValues } from 'lit';
import { property, query } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import { AFFINE_FORMAT_BAR_WIDGET } from '../format-bar/format-bar.js';
import {
AFFINE_VIEWPORT_OVERLAY_WIDGET,
type AffineViewportOverlayWidget,
} from '../viewport-overlay/viewport-overlay.js';
import type { AIPanelGenerating } from './components/index.js';
import type { AffineAIPanelState, AffineAIPanelWidgetConfig } from './type.js';
export const AFFINE_AI_PANEL_WIDGET = 'affine-ai-panel-widget';
export class AffineAIPanelWidget extends WidgetComponent {
static override styles = css`
:host {
display: flex;
outline: none;
border-radius: var(--8, 8px);
border: 1px solid var(--affine-border-color);
background: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-overlay-shadow);
position: absolute;
width: max-content;
height: auto;
top: 0;
left: 0;
overflow-y: auto;
scrollbar-width: none !important;
z-index: var(--affine-z-index-popover);
--affine-font-family: var(--affine-font-sans-family);
}
.ai-panel-container {
display: flex;
flex-direction: column;
box-sizing: border-box;
width: 100%;
height: fit-content;
padding: 10px 0;
}
.ai-panel-container:not(:has(ai-panel-generating)) {
gap: 8px;
}
.ai-panel-container:has(ai-panel-answer),
.ai-panel-container:has(ai-panel-error),
.ai-panel-container:has(ai-panel-generating:has(generating-placeholder)) {
padding: 12px 0;
}
:host([data-state='hidden']) {
display: none;
}
`;
private _abortController = new AbortController();
private _answer: string | null = null;
private readonly _clearDiscardModal = () => {
if (this._discardModalAbort) {
this._discardModalAbort.abort();
this._discardModalAbort = null;
}
};
private readonly _clickOutside = () => {
this._discardWithConfirmation();
};
private _discardModalAbort: AbortController | null = null;
private readonly _inputFinish = (text: string) => {
this._inputText = text;
this.generate();
};
private _inputText: string | null = null;
private readonly _onDocumentClick = (e: MouseEvent) => {
if (
this.state !== 'hidden' &&
e.target !== this &&
!this.contains(e.target as Node)
) {
this._clickOutside();
return true;
}
return false;
};
private readonly _onKeyDown = (event: KeyboardEvent) => {
event.stopPropagation();
const { state } = this;
if (state !== 'generating' && state !== 'input') {
return;
}
const { key } = event;
if (key === 'Escape') {
if (state === 'generating') {
this.stopGenerating();
} else {
this.hide();
}
return;
}
};
private readonly _resetAbortController = () => {
if (this.state === 'generating') {
this._abortController.abort();
}
this._abortController = new AbortController();
};
private _selection?: BaseSelection[];
private _stopAutoUpdate?: undefined | (() => void);
ctx: unknown = null;
private readonly _discardWithConfirmation = () => {
if (this.state === 'hidden') {
return;
}
if (this.state === 'input' || !this.answer) {
this.hide();
return;
}
this.showDiscardModal()
.then(discard => {
discard && this.discard();
})
.catch(console.error);
};
discard = () => {
this.hide();
this.restoreSelection();
this.config?.discardCallback?.();
};
/**
* You can evaluate this method multiple times to regenerate the answer.
*/
generate = () => {
this.restoreSelection();
assertExists(this.config);
const text = this._inputText;
assertExists(text);
assertExists(this.config.generateAnswer);
this._resetAbortController();
// reset answer
this._answer = null;
const update = (answer: string) => {
this._answer = answer;
this.requestUpdate();
};
const finish = (type: 'success' | 'error' | 'aborted', err?: AIError) => {
if (type === 'aborted') return;
assertExists(this.config);
if (type === 'error') {
this.state = 'error';
this.config.errorStateConfig.error = err;
} else {
this.state = 'finished';
this.config.errorStateConfig.error = undefined;
}
this._resetAbortController();
};
this.scrollTop = 0; // reset scroll top
this.state = 'generating';
this.config.generateAnswer({
input: text,
update,
finish,
signal: this._abortController.signal,
});
};
hide = (shouldTriggerCallback: boolean = true) => {
this._resetAbortController();
this.state = 'hidden';
this._stopAutoUpdate?.();
this._inputText = null;
this._answer = null;
this._stopAutoUpdate = undefined;
this.viewportOverlayWidget?.unlock();
if (shouldTriggerCallback) {
this.config?.hideCallback?.();
}
};
onInput = (text: string) => {
this._inputText = text;
this.config?.inputCallback?.(text);
};
restoreSelection = () => {
if (this._selection) {
this.host.selection.set([...this._selection]);
if (this.state === 'hidden') {
this._selection = undefined;
}
}
};
setState = (state: AffineAIPanelState, reference: Element) => {
this.state = state;
this._autoUpdatePosition(reference);
};
showDiscardModal = () => {
const notification = this.host.std.getOptional(NotificationProvider);
if (!notification) {
return Promise.resolve(true);
}
this._clearDiscardModal();
this._discardModalAbort = new AbortController();
return notification
.confirm({
title: 'Discard the AI result',
message: 'Do you want to discard the results the AI just generated?',
cancelText: 'Cancel',
confirmText: 'Discard',
abort: this._abortController.signal,
})
.finally(() => (this._discardModalAbort = null));
};
stopGenerating = () => {
this._abortController.abort();
this.state = 'finished';
if (!this.answer) {
this.hide();
}
};
toggle = (
reference: Element,
input?: string,
shouldTriggerCallback?: boolean
) => {
if (typeof input === 'string') {
this._inputText = input;
this.generate();
} else {
// reset state
this.hide(shouldTriggerCallback);
this.state = 'input';
}
this._autoUpdatePosition(reference);
};
get answer() {
return this._answer;
}
get inputText() {
return this._inputText;
}
get viewportOverlayWidget() {
const rootId = this.host.doc.root?.id;
return rootId
? (this.host.view.getWidget(
AFFINE_VIEWPORT_OVERLAY_WIDGET,
rootId
) as AffineViewportOverlayWidget)
: null;
}
private _autoUpdatePosition(reference: Element) {
// workaround for the case that the reference contains children block elements, like:
// paragraph
// child paragraph
{
const childrenContainer = reference.querySelector(
'.affine-block-children-container'
);
if (childrenContainer && childrenContainer.previousElementSibling) {
reference = childrenContainer.previousElementSibling;
}
}
this._stopAutoUpdate?.();
this._stopAutoUpdate = autoUpdate(reference, this, () => {
computePosition(reference, this, this._calcPositionOptions(reference))
.then(({ x, y }) => {
this.style.left = `${x}px`;
this.style.top = `${y}px`;
setTimeout(() => {
const input = this.shadowRoot?.querySelector('ai-panel-input');
input?.textarea?.focus();
}, 0);
})
.catch(console.error);
});
}
private _calcPositionOptions(
reference: Element
): Partial<ComputePositionConfig> {
let rootBoundary: Rect | undefined;
{
const docModeProvider = this.host.std.get(DocModeProvider);
if (docModeProvider.getEditorMode() === 'page') {
rootBoundary = undefined;
} else {
const gfx = this.host.std.get(GfxControllerIdentifier);
// TODO circular dependency: instanceof EdgelessRootService
const viewport = gfx.viewport;
rootBoundary = {
x: viewport.left,
y: viewport.top,
width: viewport.width,
height: viewport.height - 100, // 100 for edgeless toolbar
};
}
}
const overflowOptions = {
padding: 20,
rootBoundary: rootBoundary,
};
// block element in page editor
if (getPageRootByElement(reference)) {
return {
placement: 'bottom-start',
middleware: [offset(8), shift(overflowOptions)],
};
}
// block element in doc in edgeless editor
else if (reference.closest('edgeless-block-portal-note')) {
return {
middleware: [
offset(8),
shift(overflowOptions),
autoPlacement({
...overflowOptions,
allowedPlacements: ['top-start', 'bottom-start'],
}),
],
};
}
// edgeless element
else {
return {
placement: 'right-start',
middleware: [
offset({ mainAxis: 16 }),
flip({
mainAxis: true,
crossAxis: true,
flipAlignment: true,
...overflowOptions,
}),
shift({
crossAxis: true,
...overflowOptions,
}),
],
};
}
}
override connectedCallback() {
super.connectedCallback();
this.tabIndex = -1;
this.disposables.addFromEvent(
document,
'pointerdown',
this._onDocumentClick
);
this.disposables.add(
this.block.host.event.add('pointerDown', evtState =>
this._onDocumentClick(
evtState.get('pointerState').event as PointerEvent
)
)
);
this.disposables.add(
this.block.host.event.add('click', () => {
return this.state !== 'hidden' ? true : false;
})
);
this.disposables.addFromEvent(this, 'wheel', stopPropagation);
this.disposables.addFromEvent(this, 'pointerdown', stopPropagation);
this.disposables.addFromEvent(this, 'pointerup', stopPropagation);
this.disposables.addFromEvent(this, 'keydown', this._onKeyDown);
}
override disconnectedCallback() {
super.disconnectedCallback();
this._clearDiscardModal();
this._stopAutoUpdate?.();
}
override render() {
if (this.state === 'hidden') {
return nothing;
}
if (!this.config) return nothing;
const config = this.config;
const theme = this.std.get(ThemeProvider).theme;
const mainTemplate = choose(this.state, [
[
'input',
() =>
html`<ai-panel-input
.onBlur=${this._discardWithConfirmation}
.onFinish=${this._inputFinish}
.onInput=${this.onInput}
.networkSearchConfig=${config.networkSearchConfig}
></ai-panel-input>`,
],
[
'generating',
() => html`
${this.answer
? html`
<ai-panel-answer
.finish=${false}
.config=${config.finishStateConfig}
.host=${this.host}
>
${this.answer &&
config.answerRenderer(this.answer, this.state)}
</ai-panel-answer>
`
: nothing}
<ai-panel-generating
.config=${config.generatingStateConfig}
.theme=${theme}
.stopGenerating=${this.stopGenerating}
.withAnswer=${!!this.answer}
></ai-panel-generating>
`,
],
[
'finished',
() => html`
<ai-panel-answer
.config=${config.finishStateConfig}
.copy=${config.copy}
.host=${this.host}
>
${this.answer && config.answerRenderer(this.answer, this.state)}
</ai-panel-answer>
`,
],
[
'error',
() => html`
<ai-panel-error
.config=${config.errorStateConfig}
.copy=${config.copy}
.withAnswer=${!!this.answer}
.host=${this.host}
>
${this.answer && config.answerRenderer(this.answer, this.state)}
</ai-panel-error>
`,
],
]);
return html`<div class="ai-panel-container">${mainTemplate}</div>`;
}
protected override willUpdate(changed: PropertyValues): void {
const prevState = changed.get('state');
if (prevState) {
if (prevState === 'hidden') {
this._selection = this.host.selection.value;
} else {
this.restoreSelection();
}
// tell format bar to show or hide
const rootBlockId = this.host.doc.root?.id;
const formatBar = rootBlockId
? this.host.view.getWidget(AFFINE_FORMAT_BAR_WIDGET, rootBlockId)
: null;
if (formatBar) {
formatBar.requestUpdate();
}
}
if (this.state !== 'hidden') {
this.viewportOverlayWidget?.lock();
} else {
this.viewportOverlayWidget?.unlock();
}
this.dataset.state = this.state;
}
@property({ attribute: false })
accessor config: AffineAIPanelWidgetConfig | null = null;
@query('ai-panel-generating')
accessor generatingElement: AIPanelGenerating | null = null;
@property()
accessor state: AffineAIPanelState = 'hidden';
}

View File

@@ -1,29 +0,0 @@
import { WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement } from 'lit';
export class AIPanelDivider extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
width: 100%;
}
.divider {
height: 0.5px;
background: var(--affine-border-color);
width: 100%;
}
`;
override render() {
return html`<div class="divider"></div>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'ai-panel-divider': AIPanelDivider;
}
}

View File

@@ -1,111 +0,0 @@
import {
AIDoneIcon,
CopyIcon,
WarningIcon,
} from '@blocksuite/affine-components/icons';
import { NotificationProvider } from '@blocksuite/affine-shared/services';
import type { EditorHost } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property, state } from 'lit/decorators.js';
import type { CopyConfig } from '../type.js';
export class AIFinishTip extends WithDisposable(LitElement) {
static override styles = css`
:host {
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
.finish-tip {
display: flex;
box-sizing: border-box;
width: 100%;
height: 22px;
align-items: center;
justify-content: space-between;
padding: 0 12px;
gap: 4px;
color: var(--affine-text-secondary-color);
.text {
display: flex;
align-items: flex-start;
flex: 1 0 0;
/* light/xs */
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
}
.right {
display: flex;
align-items: center;
.copy,
.copied {
display: flex;
width: 20px;
height: 20px;
justify-content: center;
align-items: center;
border-radius: 8px;
user-select: none;
}
.copy:hover {
color: var(--affine-icon-color);
background: var(--affine-hover-color);
cursor: pointer;
}
.copied {
color: var(--affine-brand-color);
}
}
}
`;
override render() {
return html`<div class="finish-tip">
${WarningIcon}
<div class="text">AI outputs can be misleading or wrong</div>
${this.copy?.allowed
? html`<div class="right">
${this.copied
? html`<div class="copied">${AIDoneIcon}</div>`
: html`<div
class="copy"
@click=${async () => {
this.copied = !!(await this.copy?.onCopy());
if (this.copied) {
this.host.std
.getOptional(NotificationProvider)
?.toast('Copied to clipboard');
}
}}
>
${CopyIcon}
<affine-tooltip>Copy</affine-tooltip>
</div>`}
</div>`
: nothing}
</div>`;
}
@state()
accessor copied = false;
@property({ attribute: false })
accessor copy: CopyConfig | undefined = undefined;
@property({ attribute: false })
accessor host!: EditorHost;
}
declare global {
interface HTMLElementTagNameMap {
'ai-finish-tip': AIFinishTip;
}
}

View File

@@ -1,147 +0,0 @@
import {
DarkLoadingIcon,
LightLoadingIcon,
} from '@blocksuite/affine-components/icons';
import { ColorScheme } from '@blocksuite/affine-model';
import { unsafeCSSVar } from '@blocksuite/affine-shared/theme';
import { WithDisposable } from '@blocksuite/global/utils';
import { baseTheme } from '@toeverything/theme';
import {
css,
html,
LitElement,
nothing,
type PropertyValues,
unsafeCSS,
} from 'lit';
import { property } from 'lit/decorators.js';
export class GeneratingPlaceholder extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
margin-bottom: 8px;
}
.generating-header {
width: 100%;
font-size: ${unsafeCSSVar('fontXs')};
font-style: normal;
font-weight: 500;
line-height: 20px;
height: 20px;
}
.generating-header,
.loading-progress {
color: ${unsafeCSSVar('textSecondaryColor')};
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
.generating-body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 100%;
border-radius: 4px;
border: 2px solid ${unsafeCSSVar('primaryColor')};
background: ${unsafeCSSVar('blue50')};
color: ${unsafeCSSVar('brandColor')};
gap: 4px;
}
.generating-icon {
display: flex;
justify-content: center;
align-items: center;
height: 24px;
}
.generating-icon svg {
scale: 1.5;
}
.loading-progress {
display: flex;
flex-direction: column;
font-style: normal;
font-weight: 400;
text-align: center;
gap: 4px;
}
.loading-text {
font-size: ${unsafeCSSVar('fontBase')};
height: 24px;
line-height: 24px;
}
.loading-stage {
font-size: ${unsafeCSSVar('fontXs')};
height: 20px;
line-height: 20px;
}
`;
protected override render() {
const loadingText = this.stages[this.loadingProgress - 1] || '';
return html`<style>
.generating-body {
height: ${this.height}px;
}
</style>
${this.showHeader
? html`<div class="generating-header">Answer</div>`
: nothing}
<div class="generating-body">
<div class="generating-icon">
${this.theme === ColorScheme.Light
? DarkLoadingIcon
: LightLoadingIcon}
</div>
<div class="loading-progress">
<div class="loading-text">${loadingText}</div>
<div class="loading-stage">
${this.loadingProgress} / ${this.stages.length}
</div>
</div>
</div>`;
}
override willUpdate(changed: PropertyValues) {
if (changed.has('loadingProgress')) {
this.loadingProgress = Math.max(
1,
Math.min(this.loadingProgress, this.stages.length)
);
}
}
@property({ attribute: false })
accessor height: number = 300;
@property({ attribute: false })
accessor loadingProgress!: number;
@property({ attribute: false })
accessor showHeader!: boolean;
@property({ attribute: false })
accessor stages!: string[];
@property({ attribute: false })
accessor theme!: ColorScheme;
}
declare global {
interface HTMLElementTagNameMap {
'generating-placeholder': GeneratingPlaceholder;
}
}

View File

@@ -1,2 +0,0 @@
export * from './divider.js';
export * from './state/index.js';

View File

@@ -1,152 +0,0 @@
import type { EditorHost } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import type { AIPanelAnswerConfig, CopyConfig } from '../../type.js';
import { filterAIItemGroup } from '../../utils.js';
export class AIPanelAnswer extends WithDisposable(LitElement) {
static override styles = css`
:host {
width: 100%;
display: flex;
box-sizing: border-box;
flex-direction: column;
gap: 8px;
padding: 0;
}
.answer {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: 4px;
align-self: stretch;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
padding: 0 12px;
}
.answer-head {
align-self: stretch;
color: var(--affine-text-secondary-color);
/* light/xsMedium */
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 500;
line-height: 20px; /* 166.667% */
height: 20px;
}
.answer-body {
align-self: stretch;
color: var(--affine-text-primary-color);
font-feature-settings:
'clig' off,
'liga' off;
/* light/sm */
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 400;
line-height: 22px; /* 157.143% */
}
.response-list-container {
display: flex;
flex-direction: column;
gap: 4px;
}
.response-list-container,
.action-list-container {
padding: 0 8px;
user-select: none;
}
/* set item style outside ai-item */
.response-list-container ai-item-list,
.action-list-container ai-item-list {
--item-padding: 4px;
}
.response-list-container ai-item-list {
--item-icon-color: var(--affine-icon-secondary);
--item-icon-hover-color: var(--affine-icon-color);
}
`;
override render() {
const responseGroup = filterAIItemGroup(this.host, this.config.responses);
return html`
<div class="answer">
<div class="answer-head">Answer</div>
<div class="answer-body">
<slot></slot>
</div>
</div>
${this.finish
? html`
<ai-finish-tip
.copy=${this.copy}
.host=${this.host}
></ai-finish-tip>
${responseGroup.length > 0
? html`
<ai-panel-divider></ai-panel-divider>
${responseGroup.map(
(group, index) => html`
${index !== 0
? html`<ai-panel-divider></ai-panel-divider>`
: nothing}
<div class="response-list-container">
<ai-item-list
.host=${this.host}
.groups=${[group]}
></ai-item-list>
</div>
`
)}
`
: nothing}
${responseGroup.length > 0 && this.config.actions.length > 0
? html`<ai-panel-divider></ai-panel-divider>`
: nothing}
${this.config.actions.length > 0
? html`
<div class="action-list-container">
<ai-item-list
.host=${this.host}
.groups=${this.config.actions}
></ai-item-list>
</div>
`
: nothing}
`
: nothing}
`;
}
@property({ attribute: false })
accessor config!: AIPanelAnswerConfig;
@property({ attribute: false })
accessor copy: CopyConfig | undefined = undefined;
@property({ attribute: false })
accessor finish = true;
@property({ attribute: false })
accessor host!: EditorHost;
}
declare global {
interface HTMLElementTagNameMap {
'ai-panel-answer': AIPanelAnswer;
}
}

View File

@@ -1,263 +0,0 @@
import {
AIErrorType,
type AIItemGroupConfig,
} from '@blocksuite/affine-components/ai-item';
import type { EditorHost } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import { choose } from 'lit/directives/choose.js';
import type { AIPanelErrorConfig, CopyConfig } from '../../type.js';
import { filterAIItemGroup } from '../../utils.js';
export class AIPanelError extends WithDisposable(LitElement) {
static override styles = css`
:host {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
padding: 0;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
.error {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
align-self: stretch;
padding: 0px 12px;
gap: 4px;
.answer-tip {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: 4px;
align-self: stretch;
.answer-label {
align-self: stretch;
color: var(--affine-text-secondary-color);
/* light/xsMedium */
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 500;
line-height: 20px; /* 166.667% */
}
}
.error-info {
align-self: stretch;
color: var(--affine-error-color, #eb4335);
font-feature-settings:
'clig' off,
'liga' off;
/* light/sm */
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
line-height: 22px; /* 157.143% */
a {
color: inherit;
}
}
.action-button-group {
display: flex;
width: 100%;
gap: 16px;
align-items: center;
justify-content: end;
margin-top: 4px;
}
.action-button {
display: flex;
box-sizing: border-box;
padding: 4px 12px;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 8px;
border: 1px solid var(--affine-border-color);
background: var(--affine-white);
color: var(--affine-text-primary-color);
/* light/xsMedium */
font-size: var(--affine-font-xs);
font-style: normal;
font-weight: 500;
line-height: 20px; /* 166.667% */
}
.action-button:hover {
cursor: pointer;
}
.action-button.primary {
border: 1px solid var(--affine-black-10);
background: var(--affine-primary-color);
color: var(--affine-pure-white);
}
.action-button > span {
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
.action-button:not(.primary):hover {
background: var(--affine-hover-color);
}
}
ai-panel-divider {
margin-top: 4px;
}
.response-list-container {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0 8px;
user-select: none;
}
.response-list-container ai-item-list {
--item-padding: 4px;
--item-icon-color: var(--affine-icon-secondary);
--item-icon-hover-color: var(--affine-icon-color);
}
`;
private readonly _getResponseGroup = () => {
let responseGroup: AIItemGroupConfig[] = [];
const errorType = this.config.error?.type;
if (errorType && errorType !== AIErrorType.GeneralNetworkError) {
return responseGroup;
}
responseGroup = filterAIItemGroup(this.host, this.config.responses);
return responseGroup;
};
override render() {
const responseGroup = this._getResponseGroup();
const errorTemplate = choose(
this.config.error?.type,
[
[
AIErrorType.Unauthorized,
() =>
html` <div class="error-info">
You need to login to AFFiNE Cloud to continue using AFFiNE AI.
</div>
<div class="action-button-group">
<div @click=${this.config.cancel} class="action-button">
<span>Cancel</span>
</div>
<div @click=${this.config.login} class="action-button primary">
<span>Login</span>
</div>
</div>`,
],
[
AIErrorType.PaymentRequired,
() =>
html` <div class="error-info">
You've reached the current usage cap for AFFiNE AI. You can
subscribe to AFFiNE AI to continue the AI experience!
</div>
<div class="action-button-group">
<div @click=${this.config.cancel} class="action-button">
<span>Cancel</span>
</div>
<div
@click=${this.config.upgrade}
class="action-button primary"
>
<span>Upgrade</span>
</div>
</div>`,
],
],
// default error handler
() => {
const tip = this.config.error?.message;
const error = tip
? html`<span class="error-tip"
>An error occurred<affine-tooltip
tip-position="bottom-start"
.arrow=${false}
>${tip}</affine-tooltip
></span
>`
: 'An error occurred';
return html`
<style>
.error-tip {
text-decoration: underline;
}
</style>
<div class="error-info">
${error}. Please try again later. If this issue persists, please let
us know at
<a href="mailto:support@toeverything.info">
support@toeverything.info
</a>
</div>
`;
}
);
return html`
<div class="error">
<div class="answer-tip">
<div class="answer-label">Answer</div>
<slot></slot>
</div>
${errorTemplate}
</div>
${this.withAnswer
? html`<ai-finish-tip
.copy=${this.copy}
.host=${this.host}
></ai-finish-tip>`
: nothing}
${responseGroup.length > 0
? html`
<ai-panel-divider></ai-panel-divider>
${responseGroup.map(
(group, index) => html`
${index !== 0
? html`<ai-panel-divider></ai-panel-divider>`
: nothing}
<div class="response-list-container">
<ai-item-list
.host=${this.host}
.groups=${[group]}
></ai-item-list>
</div>
`
)}
`
: nothing}
`;
}
@property({ attribute: false })
accessor config!: AIPanelErrorConfig;
@property({ attribute: false })
accessor copy: CopyConfig | undefined = undefined;
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor withAnswer = false;
}
declare global {
interface HTMLElementTagNameMap {
'ai-panel-error': AIPanelError;
}
}

View File

@@ -1,123 +0,0 @@
import {
AIStarIconWithAnimation,
AIStopIcon,
} from '@blocksuite/affine-components/icons';
import type { ColorScheme } from '@blocksuite/affine-model';
import { WithDisposable } from '@blocksuite/global/utils';
import { baseTheme } from '@toeverything/theme';
import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
import { property } from 'lit/decorators.js';
import type { AIPanelGeneratingConfig } from '../../type.js';
export class AIPanelGenerating extends WithDisposable(LitElement) {
static override styles = css`
:host {
width: 100%;
padding: 0 12px;
box-sizing: border-box;
font-family: ${unsafeCSS(baseTheme.fontSansFamily)};
}
.generating-tip {
display: flex;
width: 100%;
height: 22px;
align-items: center;
gap: 8px;
color: var(--affine-brand-color);
.text {
display: flex;
align-items: flex-start;
gap: 10px;
flex: 1 0 0;
/* light/smMedium */
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 500;
line-height: 22px; /* 157.143% */
}
.left,
.right {
display: flex;
height: 20px;
justify-content: center;
align-items: center;
}
.left {
width: 20px;
}
.right {
gap: 6px;
}
.right:hover {
cursor: pointer;
}
.stop-icon {
height: 20px;
width: 20px;
}
.esc-label {
font-size: var(--affine-font-xs);
font-weight: 500;
line-height: 20px;
}
}
`;
override render() {
const {
generatingIcon = AIStarIconWithAnimation,
stages,
height = 300,
} = this.config;
return html`
${stages && stages.length > 0
? html`<generating-placeholder
.height=${height}
.theme=${this.theme}
.loadingProgress=${this.loadingProgress}
.stages=${stages}
.showHeader=${!this.withAnswer}
></generating-placeholder>`
: nothing}
<div class="generating-tip">
<div class="left">${generatingIcon}</div>
<div class="text">AI is generating...</div>
<div @click=${this.stopGenerating} class="right">
<span class="stop-icon">${AIStopIcon}</span>
<span class="esc-label">ESC</span>
</div>
</div>
`;
}
updateLoadingProgress(progress: number) {
this.loadingProgress = progress;
}
@property({ attribute: false })
accessor config!: AIPanelGeneratingConfig;
@property({ attribute: false })
accessor loadingProgress: number = 1;
@property({ attribute: false })
accessor stopGenerating!: () => void;
@property({ attribute: false })
accessor theme!: ColorScheme;
@property({ attribute: false })
accessor withAnswer!: boolean;
}
declare global {
interface HTMLElementTagNameMap {
'ai-panel-generating': AIPanelGenerating;
}
}

View File

@@ -1,4 +0,0 @@
export * from './answer.js';
export * from './error.js';
export * from './generating.js';
export * from './input.js';

View File

@@ -1,220 +0,0 @@
import { AIStarIcon } from '@blocksuite/affine-components/icons';
import { unsafeCSSVarV2 } from '@blocksuite/affine-shared/theme';
import { stopPropagation } from '@blocksuite/affine-shared/utils';
import { SignalWatcher, WithDisposable } from '@blocksuite/global/utils';
import { PublishIcon, SendIcon } from '@blocksuite/icons/lit';
import { css, html, LitElement, nothing } from 'lit';
import { property, query, state } from 'lit/decorators.js';
import type { AINetworkSearchConfig } from '../../type';
export class AIPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
static override styles = css`
:host {
width: 100%;
padding: 0 12px;
box-sizing: border-box;
}
.root {
display: flex;
align-items: flex-start;
gap: 8px;
background: var(--affine-background-overlay-panel-color);
}
.star {
display: flex;
padding: 2px;
align-items: center;
}
.textarea-container {
display: flex;
align-items: flex-end;
gap: 8px;
flex: 1 0 0;
textarea {
flex: 1 0 0;
border: none;
outline: none;
-webkit-box-shadow: none;
-moz-box-shadow: none;
box-shadow: none;
background-color: transparent;
resize: none;
overflow: hidden;
padding: 0px;
color: var(--affine-text-primary-color);
/* light/sm */
font-family: var(--affine-font-family);
font-size: var(--affine-font-sm);
font-style: normal;
font-weight: 400;
line-height: 22px; /* 157.143% */
}
textarea::placeholder {
color: var(--affine-placeholder-color);
}
textarea::-moz-placeholder {
color: var(--affine-placeholder-color);
}
}
.arrow {
display: flex;
align-items: center;
padding: 2px;
gap: 4px;
border-radius: 4px;
background: ${unsafeCSSVarV2('icon/disable')};
svg {
width: 20px;
height: 20px;
color: ${unsafeCSSVarV2('button/pureWhiteText')};
}
}
.arrow[data-active] {
background: ${unsafeCSSVarV2('icon/activated')};
}
.arrow[data-active]:hover {
cursor: pointer;
}
.network {
display: flex;
align-items: center;
padding: 2px;
gap: 4px;
cursor: pointer;
svg {
width: 20px;
height: 20px;
color: ${unsafeCSSVarV2('icon/primary')};
}
}
.network[data-active='true'] svg {
color: ${unsafeCSSVarV2('icon/activated')};
}
`;
private readonly _onInput = () => {
this.textarea.style.height = 'auto';
this.textarea.style.height = this.textarea.scrollHeight + 'px';
this.onInput?.(this.textarea.value);
const value = this.textarea.value.trim();
if (value.length > 0) {
this._arrow.dataset.active = '';
this._hasContent = true;
} else {
delete this._arrow.dataset.active;
this._hasContent = false;
}
};
private readonly _onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
this._sendToAI(e);
}
};
private readonly _sendToAI = (e: MouseEvent | KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
const value = this.textarea.value.trim();
if (value.length === 0) return;
this.onFinish?.(value);
this.remove();
};
private readonly _toggleNetworkSearch = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const enable = this.networkSearchConfig.enabled.value;
this.networkSearchConfig.setEnabled(!enable);
};
override render() {
return html`<div class="root">
<div class="star">${AIStarIcon}</div>
<div class="textarea-container">
<textarea
placeholder="What are your thoughts?"
rows="1"
@keydown=${this._onKeyDown}
@input=${this._onInput}
@pointerdown=${stopPropagation}
@click=${stopPropagation}
@dblclick=${stopPropagation}
@cut=${stopPropagation}
@copy=${stopPropagation}
@paste=${stopPropagation}
@keyup=${stopPropagation}
></textarea>
${this.networkSearchConfig.visible.value
? html`
<div
class="network"
data-active=${!!this.networkSearchConfig.enabled.value}
@click=${this._toggleNetworkSearch}
@pointerdown=${stopPropagation}
>
${PublishIcon()}
<affine-tooltip .offset=${12}
>Toggle Network Search</affine-tooltip
>
</div>
`
: nothing}
<div
class="arrow"
@click=${this._sendToAI}
@pointerdown=${stopPropagation}
>
${SendIcon()}
${this._hasContent
? html`<affine-tooltip .offset=${12}>Send to AI</affine-tooltip>`
: nothing}
</div>
</div>
</div>`;
}
override updated(_changedProperties: Map<PropertyKey, unknown>): void {
const result = super.updated(_changedProperties);
this.textarea.style.height = this.textarea.scrollHeight + 'px';
return result;
}
@query('.arrow')
private accessor _arrow!: HTMLDivElement;
@state()
private accessor _hasContent = false;
@property({ attribute: false })
accessor networkSearchConfig!: AINetworkSearchConfig;
@property({ attribute: false })
accessor onFinish: ((input: string) => void) | undefined = undefined;
@property({ attribute: false })
accessor onInput: ((input: string) => void) | undefined = undefined;
@query('textarea')
accessor textarea!: HTMLTextAreaElement;
}
declare global {
interface HTMLElementTagNameMap {
'ai-panel-input': AIPanelInput;
}
}

View File

@@ -1,66 +0,0 @@
import type {
AIError,
AIItemGroupConfig,
} from '@blocksuite/affine-components/ai-item';
import type { Signal } from '@preact/signals-core';
import type { nothing, TemplateResult } from 'lit';
export interface CopyConfig {
allowed: boolean;
onCopy: () => boolean | Promise<boolean>;
}
export interface AIPanelAnswerConfig {
responses: AIItemGroupConfig[];
actions: AIItemGroupConfig[];
}
export interface AIPanelErrorConfig {
login: () => void;
upgrade: () => void;
cancel: () => void;
responses: AIItemGroupConfig[];
error?: AIError;
}
export interface AIPanelGeneratingConfig {
generatingIcon: TemplateResult<1>;
height?: number;
stages?: string[];
}
export interface AINetworkSearchConfig {
visible: Signal<boolean | undefined>;
enabled: Signal<boolean | undefined>;
setEnabled: (state: boolean) => void;
}
export interface AffineAIPanelWidgetConfig {
answerRenderer: (
answer: string,
state?: AffineAIPanelState
) => TemplateResult<1> | typeof nothing;
generateAnswer?: (props: {
input: string;
update: (answer: string) => void;
finish: (type: 'success' | 'error' | 'aborted', err?: AIError) => void;
// Used to allow users to stop actively when generating
signal: AbortSignal;
}) => void;
finishStateConfig: AIPanelAnswerConfig;
generatingStateConfig: AIPanelGeneratingConfig;
errorStateConfig: AIPanelErrorConfig;
networkSearchConfig: AINetworkSearchConfig;
hideCallback?: () => void;
discardCallback?: () => void;
inputCallback?: (input: string) => void;
copy?: CopyConfig;
}
export type AffineAIPanelState =
| 'hidden'
| 'input'
| 'generating'
| 'finished'
| 'error';

View File

@@ -1,20 +0,0 @@
import type { AIItemGroupConfig } from '@blocksuite/affine-components/ai-item';
import { isInsidePageEditor } from '@blocksuite/affine-shared/utils';
import type { EditorHost } from '@blocksuite/block-std';
export function filterAIItemGroup(
host: EditorHost,
configs: AIItemGroupConfig[]
): AIItemGroupConfig[] {
const editorMode = isInsidePageEditor(host) ? 'page' : 'edgeless';
return configs
.map(group => ({
...group,
items: group.items.filter(item =>
item.showWhen
? item.showWhen(host.command.chain(), editorMode, host)
: true
),
}))
.filter(group => group.items.length > 0);
}

View File

@@ -1,92 +0,0 @@
import type { AIItemGroupConfig } from '@blocksuite/affine-components/ai-item';
import { scrollbarStyle } from '@blocksuite/affine-shared/styles';
import { on, stopPropagation } from '@blocksuite/affine-shared/utils';
import type { EditorHost } from '@blocksuite/block-std';
import { WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement, nothing } from 'lit';
import { property } from 'lit/decorators.js';
export class EdgelessCopilotPanel extends WithDisposable(LitElement) {
static override styles = css`
:host {
display: flex;
position: absolute;
}
.edgeless-copilot-panel {
box-sizing: border-box;
padding: 8px 4px 8px 8px;
min-width: 330px;
max-height: 374px;
overflow-y: auto;
background: var(--affine-background-overlay-panel-color);
box-shadow: var(--affine-shadow-2);
border-radius: 8px;
z-index: var(--affine-z-index-popover);
}
${scrollbarStyle('.edgeless-copilot-panel')}
.edgeless-copilot-panel:hover::-webkit-scrollbar-thumb {
background-color: var(--affine-black-30);
}
`;
private _getChain() {
return this.host.std.command.chain();
}
override connectedCallback(): void {
super.connectedCallback();
this._disposables.add(on(this, 'wheel', stopPropagation));
this._disposables.add(on(this, 'pointerdown', stopPropagation));
}
hide() {
this.remove();
}
override render() {
const chain = this._getChain();
const groups = this.groups.reduce((pre, group) => {
const filtered = group.items.filter(item =>
item.showWhen?.(chain, 'edgeless', this.host)
);
if (filtered.length > 0) pre.push({ ...group, items: filtered });
return pre;
}, [] as AIItemGroupConfig[]);
if (groups.every(group => group.items.length === 0)) return nothing;
return html`
<div class="edgeless-copilot-panel">
<ai-item-list
.onClick=${() => {
this.onClick?.();
}}
.host=${this.host}
.groups=${groups}
></ai-item-list>
</div>
`;
}
@property({ attribute: false })
accessor entry: 'toolbar' | 'selection' | undefined = undefined;
@property({ attribute: false })
accessor groups!: AIItemGroupConfig[];
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor onClick: (() => void) | undefined = undefined;
}
declare global {
interface HTMLElementTagNameMap {
'edgeless-copilot-panel': EdgelessCopilotPanel;
}
}

View File

@@ -1,79 +0,0 @@
import type { AIItemGroupConfig } from '@blocksuite/affine-components/ai-item';
import { AIStarIcon } from '@blocksuite/affine-components/icons';
import type { EditorHost } from '@blocksuite/block-std';
import {
GfxControllerIdentifier,
isGfxGroupCompatibleModel,
} from '@blocksuite/block-std/gfx';
import { WithDisposable } from '@blocksuite/global/utils';
import { css, html, LitElement } from 'lit';
import { property } from 'lit/decorators.js';
import type { CopilotTool } from '../../edgeless/gfx-tool/copilot-tool.js';
import { sortEdgelessElements } from '../../edgeless/utils/clone-utils.js';
export class EdgelessCopilotToolbarEntry extends WithDisposable(LitElement) {
static override styles = css`
.copilot-icon-button {
line-height: 20px;
.label.medium {
color: var(--affine-brand-color);
}
}
`;
private readonly _onClick = () => {
this.onClick?.();
this._showCopilotPanel();
};
private get _gfx() {
return this.host.std.get(GfxControllerIdentifier);
}
private _showCopilotPanel() {
const selectedElements = sortEdgelessElements(
this._gfx.selection.selectedElements
);
const toBeSelected = new Set(selectedElements);
selectedElements.forEach(element => {
// its descendants are already selected
if (toBeSelected.has(element)) return;
toBeSelected.add(element);
if (isGfxGroupCompatibleModel(element)) {
element.descendantElements.forEach(descendant => {
toBeSelected.add(descendant);
});
}
});
this._gfx.tool.setTool('copilot');
(this._gfx.tool.currentTool$.peek() as CopilotTool).updateSelectionWith(
Array.from(toBeSelected),
10
);
}
override render() {
return html`<edgeless-tool-icon-button
aria-label="Ask AI"
class="copilot-icon-button"
@click=${this._onClick}
>
${AIStarIcon} <span class="label medium">Ask AI</span>
</edgeless-tool-icon-button>`;
}
@property({ attribute: false })
accessor groups!: AIItemGroupConfig[];
@property({ attribute: false })
accessor host!: EditorHost;
@property({ attribute: false })
accessor onClick: (() => void) | undefined = undefined;
}

View File

@@ -1,294 +0,0 @@
import { EdgelessLegacySlotIdentifier } from '@blocksuite/affine-block-surface';
import type { AIItemGroupConfig } from '@blocksuite/affine-components/ai-item';
import type { RootBlockModel } from '@blocksuite/affine-model';
import {
MOUSE_BUTTON,
requestConnectedFrame,
} from '@blocksuite/affine-shared/utils';
import { WidgetComponent } from '@blocksuite/block-std';
import { GfxControllerIdentifier } from '@blocksuite/block-std/gfx';
import { Bound, getCommonBoundWithRotation } from '@blocksuite/global/utils';
import {
autoUpdate,
computePosition,
flip,
offset,
shift,
size,
} from '@floating-ui/dom';
import { effect } from '@preact/signals-core';
import { css, html, nothing } from 'lit';
import { query, state } from 'lit/decorators.js';
import { styleMap } from 'lit/directives/style-map.js';
import {
AFFINE_AI_PANEL_WIDGET,
AffineAIPanelWidget,
} from '../ai-panel/ai-panel.js';
import { EdgelessCopilotPanel } from '../edgeless-copilot-panel/index.js';
export const AFFINE_EDGELESS_COPILOT_WIDGET = 'affine-edgeless-copilot-widget';
export class EdgelessCopilotWidget extends WidgetComponent<RootBlockModel> {
static override styles = css`
.copilot-selection-rect {
position: absolute;
box-sizing: border-box;
border-radius: 4px;
border: 2px dashed var(--affine-brand-color, #1e96eb);
}
`;
private _clickOutsideOff: (() => void) | null = null;
private _copilotPanel!: EdgelessCopilotPanel | null;
private _listenClickOutsideId: number | null = null;
private _selectionModelRect!: DOMRect;
groups: AIItemGroupConfig[] = [];
get gfx() {
return this.std.get(GfxControllerIdentifier);
}
get selectionModelRect() {
return this._selectionModelRect;
}
get selectionRect() {
return this._selectionRect;
}
get visible() {
return !!(
this._visible &&
this._selectionRect.width &&
this._selectionRect.height
);
}
set visible(visible: boolean) {
this._visible = visible;
}
private _showCopilotPanel() {
requestConnectedFrame(() => {
if (!this._copilotPanel) {
const panel = new EdgelessCopilotPanel();
panel.host = this.host;
panel.groups = this.groups;
this.renderRoot.append(panel);
this._copilotPanel = panel;
}
const referenceElement = this.selectionElem;
const panel = this._copilotPanel;
// @TODO: optimize
const viewport = this.gfx.viewport;
if (!referenceElement || !referenceElement.isConnected) return;
// show ai input
const rootBlockId = this.host.doc.root?.id;
if (rootBlockId) {
const aiPanel = this.host.view.getWidget(
AFFINE_AI_PANEL_WIDGET,
rootBlockId
);
if (aiPanel instanceof AffineAIPanelWidget && aiPanel.config) {
aiPanel.setState('input', referenceElement);
}
}
autoUpdate(referenceElement, panel, () => {
computePosition(referenceElement, panel, {
placement: 'right-start',
middleware: [
offset({
mainAxis: 16,
crossAxis: 45,
}),
flip({
mainAxis: true,
crossAxis: true,
flipAlignment: true,
}),
shift(() => {
const { left, top, width, height } = viewport;
return {
padding: 20,
crossAxis: true,
rootBoundary: {
x: left,
y: top,
width,
height: height - 100,
},
};
}),
size({
apply: ({ elements }) => {
const { height } = viewport;
elements.floating.style.maxHeight = `${height - 140}px`;
},
}),
],
})
.then(({ x, y }) => {
panel.style.left = `${x}px`;
panel.style.top = `${y}px`;
})
.catch(e => {
console.warn("Can't compute EdgelessCopilotPanel position", e);
});
});
}, this);
}
private _updateSelection(rect: DOMRect) {
this._selectionModelRect = rect;
const zoom = this.gfx.viewport.zoom;
const [x, y] = this.gfx.viewport.toViewCoord(rect.left, rect.top);
const [width, height] = [rect.width * zoom, rect.height * zoom];
this._selectionRect = { x, y, width, height };
}
private _watchClickOutside() {
this._clickOutsideOff?.();
const { width, height } = this._selectionRect;
if (width && height) {
this._listenClickOutsideId &&
cancelAnimationFrame(this._listenClickOutsideId);
this._listenClickOutsideId = requestConnectedFrame(() => {
if (!this.isConnected) {
return;
}
const off = this.std.event.add('pointerDown', ctx => {
const e = ctx.get('pointerState').raw;
if (
e.button === MOUSE_BUTTON.MAIN &&
!this.contains(e.target as HTMLElement)
) {
off();
this._visible = false;
this.hideCopilotPanel();
}
});
this._listenClickOutsideId = null;
this._clickOutsideOff = off;
}, this);
}
}
override connectedCallback(): void {
super.connectedCallback();
const CopilotSelectionTool = this.gfx.tool.get('copilot');
this._disposables.add(
CopilotSelectionTool.draggingAreaUpdated.on(shouldShowPanel => {
this._visible = true;
this._updateSelection(CopilotSelectionTool.area);
if (shouldShowPanel) {
this._showCopilotPanel();
this._watchClickOutside();
} else {
this.hideCopilotPanel();
}
})
);
this._disposables.add(
this.gfx.viewport.viewportUpdated.on(() => {
if (!this._visible) return;
this._updateSelection(CopilotSelectionTool.area);
})
);
this._disposables.add(
effect(() => {
const currentTool = this.gfx.tool.currentToolName$.value;
if (!this._visible || currentTool === 'copilot') return;
this._visible = false;
this._clickOutsideOff = null;
this._copilotPanel?.remove();
this._copilotPanel = null;
})
);
}
determineInsertionBounds(width = 800, height = 95) {
const elements = this.gfx.selection.selectedElements;
const offsetY = 20 / this.gfx.viewport.zoom;
const bounds = new Bound(0, 0, width, height);
if (elements.length) {
const { x, y, h } = getCommonBoundWithRotation(elements);
bounds.x = x;
bounds.y = y + h + offsetY;
} else {
const { x, y, height: h } = this.selectionModelRect;
bounds.x = x;
bounds.y = y + h + offsetY;
}
return bounds;
}
hideCopilotPanel() {
this._copilotPanel?.hide();
this._copilotPanel = null;
this._clickOutsideOff = null;
}
lockToolbar(disabled: boolean) {
const legacySlot = this.std.get(EdgelessLegacySlotIdentifier);
legacySlot.toolbarLocked.emit(disabled);
}
override render() {
if (!this._visible) return nothing;
const rect = this._selectionRect;
return html`<div class="affine-edgeless-ai">
<div
class="copilot-selection-rect"
style=${styleMap({
left: `${rect.x}px`,
top: `${rect.y}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
})}
></div>
</div>`;
}
@state()
private accessor _selectionRect: {
x: number;
y: number;
width: number;
height: number;
} = { x: 0, y: 0, width: 0, height: 0 };
@state()
private accessor _visible = false;
@query('.copilot-selection-rect')
accessor selectionElem!: HTMLDivElement;
}
declare global {
interface HTMLElementTagNameMap {
[AFFINE_EDGELESS_COPILOT_WIDGET]: EdgelessCopilotWidget;
}
}

View File

@@ -1,17 +1,4 @@
export { EDGELESS_TOOLBAR_WIDGET } from '../edgeless/components/toolbar/edgeless-toolbar.js';
export {
AFFINE_AI_PANEL_WIDGET,
AffineAIPanelWidget,
} from './ai-panel/ai-panel.js';
export {
type AffineAIPanelState,
type AffineAIPanelWidgetConfig,
} from './ai-panel/type.js';
export {
AFFINE_EDGELESS_COPILOT_WIDGET,
EdgelessCopilotWidget,
} from './edgeless-copilot/index.js';
export { EdgelessCopilotToolbarEntry } from './edgeless-copilot-panel/toolbar-entry.js';
export { AffineEdgelessZoomToolbarWidget } from './edgeless-zoom-toolbar/index.js';
export {
EDGELESS_ELEMENT_TOOLBAR_WIDGET,
@@ -53,4 +40,5 @@ export {
type AffineSlashSubMenu,
} from './slash-menu/index.js';
export { AffineSurfaceRefToolbar } from './surface-ref-toolbar/surface-ref-toolbar.js';
export * from './viewport-overlay/viewport-overlay.js';
export { AffineFrameTitleWidget } from '@blocksuite/affine-widget-frame-title';