chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
@@ -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;
}
}
@@ -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);
}