mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 18:26:05 +08:00
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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './divider.js';
|
||||
export * from './state/index.js';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './answer.js';
|
||||
export * from './error.js';
|
||||
export * from './generating.js';
|
||||
export * from './input.js';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user