mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-30 08:39:53 +08:00
chore: merge blocksuite source code (#9213)
This commit is contained in:
@@ -0,0 +1,568 @@
|
||||
import {
|
||||
NotificationProvider,
|
||||
ThemeProvider,
|
||||
} from '@blocksuite/affine-shared/services';
|
||||
import {
|
||||
getPageRootByElement,
|
||||
stopPropagation,
|
||||
} from '@blocksuite/affine-shared/utils';
|
||||
import type { BaseSelection } from '@blocksuite/block-std';
|
||||
import { WidgetComponent } from '@blocksuite/block-std';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
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 type { AIError } from '../../../_common/components/index.js';
|
||||
import type { EdgelessRootService } from '../../edgeless/edgeless-root-service.js';
|
||||
import { PageRootService } from '../../page/page-root-service.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);
|
||||
}
|
||||
|
||||
.ai-panel-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
padding: 8px 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 _cancelCallback = () => {
|
||||
this.focus();
|
||||
};
|
||||
|
||||
private _clearDiscardModal = () => {
|
||||
if (this._discardModalAbort) {
|
||||
this._discardModalAbort.abort();
|
||||
this._discardModalAbort = null;
|
||||
}
|
||||
};
|
||||
|
||||
private _clickOutside = () => {
|
||||
switch (this.state) {
|
||||
case 'hidden':
|
||||
return;
|
||||
case 'error':
|
||||
case 'finished':
|
||||
if (!this._answer) {
|
||||
this.hide();
|
||||
} else {
|
||||
this.discard();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
this.discard();
|
||||
}
|
||||
};
|
||||
|
||||
private _discardCallback = () => {
|
||||
this.hide();
|
||||
this.config?.discardCallback?.();
|
||||
};
|
||||
|
||||
private _discardModalAbort: AbortController | null = null;
|
||||
|
||||
private _inputFinish = (text: string) => {
|
||||
this._inputText = text;
|
||||
this.generate();
|
||||
};
|
||||
|
||||
private _inputText: string | null = null;
|
||||
|
||||
private _onDocumentClick = (e: MouseEvent) => {
|
||||
if (
|
||||
this.state !== 'hidden' &&
|
||||
e.target !== this &&
|
||||
!this.contains(e.target as Node)
|
||||
) {
|
||||
this._clickOutside();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
private _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 _resetAbortController = () => {
|
||||
if (this.state === 'generating') {
|
||||
this._abortController.abort();
|
||||
}
|
||||
this._abortController = new AbortController();
|
||||
};
|
||||
|
||||
private _selection?: BaseSelection[];
|
||||
|
||||
private _stopAutoUpdate?: undefined | (() => void);
|
||||
|
||||
ctx: unknown = null;
|
||||
|
||||
discard = () => {
|
||||
if ((this.state === 'finished' || this.state === 'error') && !this.answer) {
|
||||
this._discardCallback();
|
||||
return;
|
||||
}
|
||||
if (this.state === 'input') {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
this.showDiscardModal()
|
||||
.then(discard => {
|
||||
if (discard) {
|
||||
this._discardCallback();
|
||||
} else {
|
||||
this._cancelCallback();
|
||||
}
|
||||
this.restoreSelection();
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (input) {
|
||||
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 rootService = this.host.std.getService('affine:page');
|
||||
if (rootService instanceof PageRootService) {
|
||||
rootBoundary = undefined;
|
||||
} else {
|
||||
// TODO circular dependency: instanceof EdgelessRootService
|
||||
const viewport = (rootService as EdgelessRootService).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.discard}
|
||||
.onFinish=${this._inputFinish}
|
||||
.onInput=${this.onInput}
|
||||
></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';
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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 { css, html, LitElement, nothing } 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`
|
||||
.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;
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './divider.js';
|
||||
export * from './state/index.js';
|
||||
@@ -0,0 +1,152 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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 {
|
||||
AIErrorType,
|
||||
type AIItemGroupConfig,
|
||||
} from '../../../../../_common/components/index.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 _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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './answer.js';
|
||||
export * from './error.js';
|
||||
export * from './generating.js';
|
||||
export * from './input.js';
|
||||
@@ -0,0 +1,174 @@
|
||||
import { AIStarIcon } from '@blocksuite/affine-components/icons';
|
||||
import { stopPropagation } from '@blocksuite/affine-shared/utils';
|
||||
import { WithDisposable } from '@blocksuite/global/utils';
|
||||
import { SendIcon } from '@blocksuite/icons/lit';
|
||||
import { css, html, LitElement, nothing } from 'lit';
|
||||
import { property, query, state } from 'lit/decorators.js';
|
||||
|
||||
export class AIPanelInput extends 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);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
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: 10px;
|
||||
border-radius: 4px;
|
||||
background: var(--affine-black-10, rgba(0, 0, 0, 0.1));
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--affine-pure-white, #fff);
|
||||
}
|
||||
}
|
||||
.arrow[data-active] {
|
||||
background: var(--affine-brand-color, #1e96eb);
|
||||
}
|
||||
.arrow[data-active]:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
private _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 _onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
this._sendToAI();
|
||||
}
|
||||
};
|
||||
|
||||
private _sendToAI = () => {
|
||||
const value = this.textarea.value.trim();
|
||||
if (value.length === 0) return;
|
||||
|
||||
this.onFinish?.(value);
|
||||
this.remove();
|
||||
};
|
||||
|
||||
override render() {
|
||||
return html`<div class="root">
|
||||
<div class="icon">${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>
|
||||
<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 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { nothing, TemplateResult } from 'lit';
|
||||
|
||||
import type {
|
||||
AIError,
|
||||
AIItemGroupConfig,
|
||||
} from '../../../_common/components/ai-item/types.js';
|
||||
|
||||
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 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;
|
||||
hideCallback?: () => void;
|
||||
discardCallback?: () => void;
|
||||
inputCallback?: (input: string) => void;
|
||||
|
||||
copy?: CopyConfig;
|
||||
}
|
||||
|
||||
export type AffineAIPanelState =
|
||||
| 'hidden'
|
||||
| 'input'
|
||||
| 'generating'
|
||||
| 'finished'
|
||||
| 'error';
|
||||
@@ -0,0 +1,21 @@
|
||||
import { isInsidePageEditor } from '@blocksuite/affine-shared/utils';
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
|
||||
import type { AIItemGroupConfig } from '../../../_common/components/ai-item/types.js';
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user