mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-09-11 11:47:00 +03:00
refactor(core): migrate ai preset to AFFiNE (#7219)
## TL;DR Move `@blocksuite/presets/ai` to AFFiNE. After this PR is merged, you can use AI features from `@affine/core/blocksuite/presets/ai`.
This commit is contained in:
parent
0fe672efa5
commit
b3ec3a2b3e
@ -102,6 +102,7 @@
|
|||||||
"string-width": "^7.1.0",
|
"string-width": "^7.1.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
|
"unplugin-swc": "^1.4.5",
|
||||||
"vite": "^5.2.8",
|
"vite": "^5.2.8",
|
||||||
"vite-plugin-istanbul": "^6.0.0",
|
"vite-plugin-istanbul": "^6.0.0",
|
||||||
"vite-plugin-static-copy": "^1.0.2",
|
"vite-plugin-static-copy": "^1.0.2",
|
||||||
|
2
packages/common/env/package.json
vendored
2
packages/common/env/package.json
vendored
@ -27,4 +27,4 @@
|
|||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"version": "0.14.0"
|
"version": "0.14.0"
|
||||||
}
|
}
|
@ -68,4 +68,4 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "0.14.0"
|
"version": "0.14.0"
|
||||||
}
|
}
|
@ -109,4 +109,4 @@
|
|||||||
"yjs": "^13.6.14"
|
"yjs": "^13.6.14"
|
||||||
},
|
},
|
||||||
"version": "0.14.0"
|
"version": "0.14.0"
|
||||||
}
|
}
|
@ -33,10 +33,12 @@
|
|||||||
"@dnd-kit/modifiers": "^7.0.0",
|
"@dnd-kit/modifiers": "^7.0.0",
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@dotlottie/player-component": "^2.7.12",
|
||||||
"@emotion/cache": "^11.11.0",
|
"@emotion/cache": "^11.11.0",
|
||||||
"@emotion/react": "^11.11.4",
|
"@emotion/react": "^11.11.4",
|
||||||
"@emotion/server": "^11.11.0",
|
"@emotion/server": "^11.11.0",
|
||||||
"@emotion/styled": "^11.11.5",
|
"@emotion/styled": "^11.11.5",
|
||||||
|
"@floating-ui/dom": "^1.6.5",
|
||||||
"@juggle/resize-observer": "^3.4.0",
|
"@juggle/resize-observer": "^3.4.0",
|
||||||
"@marsidev/react-turnstile": "^0.7.0",
|
"@marsidev/react-turnstile": "^0.7.0",
|
||||||
"@radix-ui/react-collapsible": "^1.0.3",
|
"@radix-ui/react-collapsible": "^1.0.3",
|
||||||
@ -69,7 +71,7 @@
|
|||||||
"jotai-devtools": "^0.10.0",
|
"jotai-devtools": "^0.10.0",
|
||||||
"jotai-effect": "^1.0.0",
|
"jotai-effect": "^1.0.0",
|
||||||
"jotai-scope": "^0.6.0",
|
"jotai-scope": "^0.6.0",
|
||||||
"lit": "^3.1.2",
|
"lit": "^3.1.3",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"lottie-react": "^2.4.0",
|
"lottie-react": "^2.4.0",
|
||||||
"lottie-web": "^5.12.2",
|
"lottie-web": "^5.12.2",
|
||||||
@ -113,4 +115,4 @@
|
|||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"vitest": "1.6.0"
|
"vitest": "1.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -0,0 +1,210 @@
|
|||||||
|
import './ask-ai-panel.js';
|
||||||
|
|
||||||
|
import { type EditorHost, WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import {
|
||||||
|
type AIItemGroupConfig,
|
||||||
|
AIStarIcon,
|
||||||
|
EdgelessRootService,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { createLitPortal, HoverController } from '@blocksuite/blocks';
|
||||||
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
|
import { flip, offset } from '@floating-ui/dom';
|
||||||
|
import { css, html, LitElement, nothing } from 'lit';
|
||||||
|
import { customElement, property, query } from 'lit/decorators.js';
|
||||||
|
import { ref } from 'lit/directives/ref.js';
|
||||||
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
|
|
||||||
|
import { getRootService } from '../../utils/selection-utils.js';
|
||||||
|
|
||||||
|
type buttonSize = 'small' | 'middle' | 'large';
|
||||||
|
type toggleType = 'hover' | 'click';
|
||||||
|
|
||||||
|
const buttonWidthMap: Record<buttonSize, string> = {
|
||||||
|
small: '72px',
|
||||||
|
middle: '76px',
|
||||||
|
large: '82px',
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonHeightMap: Record<buttonSize, string> = {
|
||||||
|
small: '24px',
|
||||||
|
middle: '32px',
|
||||||
|
large: '32px',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AskAIButtonOptions = {
|
||||||
|
size: buttonSize;
|
||||||
|
backgroundColor?: string;
|
||||||
|
boxShadow?: string;
|
||||||
|
panelWidth?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@customElement('ask-ai-button')
|
||||||
|
export class AskAIButton extends WithDisposable(LitElement) {
|
||||||
|
get _edgeless() {
|
||||||
|
const rootService = getRootService(this.host);
|
||||||
|
if (rootService instanceof EdgelessRootService) {
|
||||||
|
return rootService;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = css`
|
||||||
|
.ask-ai-button {
|
||||||
|
border-radius: 4px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-ai-icon-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--affine-brand-color);
|
||||||
|
font-size: var(--affine-font-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-ai-icon-button.small {
|
||||||
|
font-size: var(--affine-font-xs);
|
||||||
|
svg {
|
||||||
|
scale: 0.8;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-ai-icon-button.large {
|
||||||
|
font-size: var(--affine-font-md);
|
||||||
|
svg {
|
||||||
|
scale: 1.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-ai-icon-button span {
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-ai-icon-button svg {
|
||||||
|
margin-right: 4px;
|
||||||
|
color: var(--affine-brand-color);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@query('.ask-ai-button')
|
||||||
|
private accessor _askAIButton!: HTMLDivElement;
|
||||||
|
|
||||||
|
private _abortController: AbortController | null = null;
|
||||||
|
|
||||||
|
private readonly _whenHover = new HoverController(
|
||||||
|
this,
|
||||||
|
({ abortController }) => {
|
||||||
|
return {
|
||||||
|
template: html`<ask-ai-panel
|
||||||
|
.host=${this.host}
|
||||||
|
.actionGroups=${this.actionGroups}
|
||||||
|
.abortController=${abortController}
|
||||||
|
></ask-ai-panel>`,
|
||||||
|
computePosition: {
|
||||||
|
referenceElement: this,
|
||||||
|
placement: 'top-start',
|
||||||
|
middleware: [flip(), offset(-40)],
|
||||||
|
autoUpdate: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ allowMultiple: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor actionGroups!: AIItemGroupConfig[];
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor toggleType: toggleType = 'hover';
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor options: AskAIButtonOptions = {
|
||||||
|
size: 'middle',
|
||||||
|
backgroundColor: undefined,
|
||||||
|
boxShadow: undefined,
|
||||||
|
panelWidth: 330,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _clearAbortController = () => {
|
||||||
|
if (this._abortController) {
|
||||||
|
this._abortController.abort();
|
||||||
|
this._abortController = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _toggleAIPanel = () => {
|
||||||
|
if (this.toggleType !== 'click') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._abortController) {
|
||||||
|
this._clearAbortController();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._abortController = new AbortController();
|
||||||
|
assertExists(this._askAIButton);
|
||||||
|
const panelMinWidth = this.options.panelWidth || 330;
|
||||||
|
createLitPortal({
|
||||||
|
template: html`<ask-ai-panel
|
||||||
|
.host=${this.host}
|
||||||
|
.actionGroups=${this.actionGroups}
|
||||||
|
.minWidth=${panelMinWidth}
|
||||||
|
></ask-ai-panel>`,
|
||||||
|
container: this._askAIButton,
|
||||||
|
computePosition: {
|
||||||
|
referenceElement: this._askAIButton,
|
||||||
|
placement: 'bottom-start',
|
||||||
|
middleware: [flip(), offset(4)],
|
||||||
|
autoUpdate: true,
|
||||||
|
},
|
||||||
|
abortController: this._abortController,
|
||||||
|
closeOnClickAway: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
override firstUpdated() {
|
||||||
|
this.disposables.add(() => {
|
||||||
|
this._edgeless?.tool.setEdgelessTool({ type: 'default' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this._clearAbortController();
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
const { size = 'small', backgroundColor, boxShadow } = this.options;
|
||||||
|
const { toggleType } = this;
|
||||||
|
const buttonStyles = styleMap({
|
||||||
|
backgroundColor: backgroundColor || 'transparent',
|
||||||
|
boxShadow: boxShadow || 'none',
|
||||||
|
});
|
||||||
|
return html`<div
|
||||||
|
class="ask-ai-button"
|
||||||
|
style=${buttonStyles}
|
||||||
|
${toggleType === 'hover' ? ref(this._whenHover.setReference) : nothing}
|
||||||
|
@click=${this._toggleAIPanel}
|
||||||
|
>
|
||||||
|
<icon-button
|
||||||
|
class="ask-ai-icon-button ${size}"
|
||||||
|
width=${buttonWidthMap[size]}
|
||||||
|
height=${buttonHeightMap[size]}
|
||||||
|
>
|
||||||
|
${AIStarIcon} <span>Ask AI</span></icon-button
|
||||||
|
>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'ask-ai-button': AskAIButton;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,100 @@
|
|||||||
|
import { type EditorHost, WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import {
|
||||||
|
type AIItemGroupConfig,
|
||||||
|
EdgelessRootService,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
|
|
||||||
|
import { getRootService } from '../../utils/selection-utils.js';
|
||||||
|
|
||||||
|
@customElement('ask-ai-panel')
|
||||||
|
export class AskAIPanel extends WithDisposable(LitElement) {
|
||||||
|
static override styles = css`
|
||||||
|
:host {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-ai-panel {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-ai-panel::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
max-height: 100px;
|
||||||
|
}
|
||||||
|
.ask-ai-panel::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
.ask-ai-panel:hover::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--affine-black-30);
|
||||||
|
}
|
||||||
|
.ask-ai-panel::-webkit-scrollbar-corner {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor actionGroups!: AIItemGroupConfig[];
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor abortController: AbortController | null = null;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor minWidth = 330;
|
||||||
|
|
||||||
|
get _edgeless() {
|
||||||
|
const rootService = getRootService(this.host);
|
||||||
|
if (rootService instanceof EdgelessRootService) {
|
||||||
|
return rootService;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get _actionGroups() {
|
||||||
|
const filteredConfig = this.actionGroups
|
||||||
|
.map(group => ({
|
||||||
|
...group,
|
||||||
|
items: group.items.filter(item =>
|
||||||
|
item.showWhen
|
||||||
|
? item.showWhen(
|
||||||
|
this.host.command.chain(),
|
||||||
|
this._edgeless ? 'edgeless' : 'page',
|
||||||
|
this.host
|
||||||
|
)
|
||||||
|
: true
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter(group => group.items.length > 0);
|
||||||
|
return filteredConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
const style = styleMap({
|
||||||
|
minWidth: `${this.minWidth}px`,
|
||||||
|
});
|
||||||
|
return html`<div class="ask-ai-panel" style=${style}>
|
||||||
|
<ai-item-list
|
||||||
|
.host=${this.host}
|
||||||
|
.groups=${this._actionGroups}
|
||||||
|
></ai-item-list>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'ask-ai-panel': AskAIPanel;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,521 @@
|
|||||||
|
import type { Chain, EditorHost, InitCommandCtx } from '@blocksuite/block-std';
|
||||||
|
import {
|
||||||
|
type AIItemGroupConfig,
|
||||||
|
type AISubItemConfig,
|
||||||
|
type CopilotSelectionController,
|
||||||
|
EDGELESS_ELEMENT_TOOLBAR_WIDGET,
|
||||||
|
type EdgelessElementToolbarWidget,
|
||||||
|
matchFlavours,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import type { TemplateResult } from 'lit';
|
||||||
|
|
||||||
|
import { actionToHandler } from '../actions/doc-handler.js';
|
||||||
|
import { actionToHandler as edgelessActionToHandler } from '../actions/edgeless-handler.js';
|
||||||
|
import {
|
||||||
|
imageFilterStyles,
|
||||||
|
imageProcessingTypes,
|
||||||
|
textTones,
|
||||||
|
translateLangs,
|
||||||
|
} from '../actions/types.js';
|
||||||
|
import { getAIPanel } from '../ai-panel.js';
|
||||||
|
import { AIProvider } from '../provider.js';
|
||||||
|
import {
|
||||||
|
getSelectedImagesAsBlobs,
|
||||||
|
getSelectedTextContent,
|
||||||
|
getSelections,
|
||||||
|
} from '../utils/selection-utils.js';
|
||||||
|
import {
|
||||||
|
AIDoneIcon,
|
||||||
|
AIImageIcon,
|
||||||
|
AIImageIconWithAnimation,
|
||||||
|
AIMindMapIcon,
|
||||||
|
AIPenIcon,
|
||||||
|
AIPenIconWithAnimation,
|
||||||
|
AIPresentationIcon,
|
||||||
|
AIPresentationIconWithAnimation,
|
||||||
|
AISearchIcon,
|
||||||
|
AIStarIconWithAnimation,
|
||||||
|
ChatWithAIIcon,
|
||||||
|
ExplainIcon,
|
||||||
|
ImproveWritingIcon,
|
||||||
|
LanguageIcon,
|
||||||
|
LongerIcon,
|
||||||
|
MakeItRealIcon,
|
||||||
|
MakeItRealIconWithAnimation,
|
||||||
|
SelectionIcon,
|
||||||
|
ShorterIcon,
|
||||||
|
ToneIcon,
|
||||||
|
} from './icons.js';
|
||||||
|
|
||||||
|
export const translateSubItem: AISubItemConfig[] = translateLangs.map(lang => {
|
||||||
|
return {
|
||||||
|
type: lang,
|
||||||
|
handler: actionToHandler('translate', AIStarIconWithAnimation, { lang }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const toneSubItem: AISubItemConfig[] = textTones.map(tone => {
|
||||||
|
return {
|
||||||
|
type: tone,
|
||||||
|
handler: actionToHandler('changeTone', AIStarIconWithAnimation, { tone }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createImageFilterSubItem(
|
||||||
|
trackerOptions?: BlockSuitePresets.TrackerOptions
|
||||||
|
) {
|
||||||
|
return imageFilterStyles.map(style => {
|
||||||
|
return {
|
||||||
|
type: style,
|
||||||
|
handler: edgelessHandler(
|
||||||
|
'filterImage',
|
||||||
|
AIImageIconWithAnimation,
|
||||||
|
{
|
||||||
|
style,
|
||||||
|
},
|
||||||
|
trackerOptions
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createImageProcessingSubItem(
|
||||||
|
trackerOptions?: BlockSuitePresets.TrackerOptions
|
||||||
|
) {
|
||||||
|
return imageProcessingTypes.map(type => {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
handler: edgelessHandler(
|
||||||
|
'processImage',
|
||||||
|
AIImageIconWithAnimation,
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
},
|
||||||
|
trackerOptions
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockActionTrackerOptions: BlockSuitePresets.TrackerOptions = {
|
||||||
|
control: 'block-action-bar',
|
||||||
|
where: 'ai-panel',
|
||||||
|
};
|
||||||
|
|
||||||
|
const textBlockShowWhen = (chain: Chain<InitCommandCtx>) => {
|
||||||
|
const [_, ctx] = chain
|
||||||
|
.getSelectedModels({
|
||||||
|
types: ['block', 'text'],
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
const { selectedModels } = ctx;
|
||||||
|
if (!selectedModels || selectedModels.length === 0) return false;
|
||||||
|
|
||||||
|
return selectedModels.some(model =>
|
||||||
|
matchFlavours(model, ['affine:paragraph', 'affine:list'])
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const codeBlockShowWhen = (chain: Chain<InitCommandCtx>) => {
|
||||||
|
const [_, ctx] = chain
|
||||||
|
.getSelectedModels({
|
||||||
|
types: ['block', 'text'],
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
const { selectedModels } = ctx;
|
||||||
|
if (!selectedModels || selectedModels.length > 1) return false;
|
||||||
|
|
||||||
|
const model = selectedModels[0];
|
||||||
|
return matchFlavours(model, ['affine:code']);
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageBlockShowWhen = (chain: Chain<InitCommandCtx>) => {
|
||||||
|
const [_, ctx] = chain
|
||||||
|
.getSelectedModels({
|
||||||
|
types: ['block'],
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
const { selectedModels } = ctx;
|
||||||
|
if (!selectedModels || selectedModels.length > 1) return false;
|
||||||
|
|
||||||
|
const model = selectedModels[0];
|
||||||
|
return matchFlavours(model, ['affine:image']);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EditAIGroup: AIItemGroupConfig = {
|
||||||
|
name: 'edit with ai',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Translate to',
|
||||||
|
icon: LanguageIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
subItem: translateSubItem,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Change tone to',
|
||||||
|
icon: ToneIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
subItem: toneSubItem,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Improve writing',
|
||||||
|
icon: ImproveWritingIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: actionToHandler('improveWriting', AIStarIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Make it longer',
|
||||||
|
icon: LongerIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: actionToHandler('makeLonger', AIStarIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Make it shorter',
|
||||||
|
icon: ShorterIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: actionToHandler('makeShorter', AIStarIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Continue writing',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: actionToHandler('continueWriting', AIPenIconWithAnimation),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DraftAIGroup: AIItemGroupConfig = {
|
||||||
|
name: 'draft with ai',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Write an article about this',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: actionToHandler('writeArticle', AIPenIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Write a tweet about this',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: actionToHandler('writeTwitterPost', AIPenIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Write a poem about this',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: actionToHandler('writePoem', AIPenIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Write a blog post about this',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: actionToHandler('writeBlogPost', AIPenIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Brainstorm ideas about this',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: actionToHandler('brainstorm', AIPenIconWithAnimation),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// actions that initiated from a note in edgeless mode
|
||||||
|
// 1. when running in doc mode, call requestRunInEdgeless (let affine to show toast)
|
||||||
|
// 2. when running in edgeless mode
|
||||||
|
// a. get selected in the note and let the edgeless action to handle it
|
||||||
|
// b. insert the result using the note shape
|
||||||
|
function edgelessHandler<T extends keyof BlockSuitePresets.AIActions>(
|
||||||
|
id: T,
|
||||||
|
generatingIcon: TemplateResult<1>,
|
||||||
|
variants?: Omit<
|
||||||
|
Parameters<BlockSuitePresets.AIActions[T]>[0],
|
||||||
|
keyof BlockSuitePresets.AITextActionOptions
|
||||||
|
>,
|
||||||
|
trackerOptions?: BlockSuitePresets.TrackerOptions
|
||||||
|
) {
|
||||||
|
return (host: EditorHost) => {
|
||||||
|
if (host.doc.root?.id === undefined) return;
|
||||||
|
const edgeless = (
|
||||||
|
host.view.getWidget(
|
||||||
|
EDGELESS_ELEMENT_TOOLBAR_WIDGET,
|
||||||
|
host.doc.root.id
|
||||||
|
) as EdgelessElementToolbarWidget
|
||||||
|
)?.edgeless;
|
||||||
|
|
||||||
|
if (!edgeless) {
|
||||||
|
AIProvider.slots.requestRunInEdgeless.emit({ host });
|
||||||
|
} else {
|
||||||
|
edgeless.tools.setEdgelessTool({ type: 'copilot' });
|
||||||
|
const currentController = edgeless.tools.controllers[
|
||||||
|
'copilot'
|
||||||
|
] as CopilotSelectionController;
|
||||||
|
const selectedElements = edgeless.service.selection.selectedElements;
|
||||||
|
currentController.updateDragPointsWith(selectedElements, 10);
|
||||||
|
currentController.draggingAreaUpdated.emit(false); // do not show edgeless panel
|
||||||
|
|
||||||
|
return edgelessActionToHandler(
|
||||||
|
id,
|
||||||
|
generatingIcon,
|
||||||
|
variants,
|
||||||
|
async () => {
|
||||||
|
const selections = getSelections(host);
|
||||||
|
const [markdown, attachments] = await Promise.all([
|
||||||
|
getSelectedTextContent(host),
|
||||||
|
getSelectedImagesAsBlobs(host),
|
||||||
|
]);
|
||||||
|
// for now if there are more than one selected blocks, we will not omit the attachments
|
||||||
|
const sendAttachments =
|
||||||
|
selections?.selectedBlocks?.length === 1 && attachments.length > 0;
|
||||||
|
return {
|
||||||
|
attachments: sendAttachments ? attachments : undefined,
|
||||||
|
content: sendAttachments ? '' : markdown,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
trackerOptions
|
||||||
|
)(host);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ReviewWIthAIGroup: AIItemGroupConfig = {
|
||||||
|
name: 'review with ai',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Fix spelling',
|
||||||
|
icon: AIDoneIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: actionToHandler('fixSpelling', AIStarIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Fix grammar',
|
||||||
|
icon: AIDoneIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: actionToHandler('improveGrammar', AIStarIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Explain this image',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: imageBlockShowWhen,
|
||||||
|
handler: actionToHandler('explainImage', AIStarIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Explain this code',
|
||||||
|
icon: ExplainIcon,
|
||||||
|
showWhen: codeBlockShowWhen,
|
||||||
|
handler: actionToHandler('explainCode', AIStarIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Check code error',
|
||||||
|
icon: ExplainIcon,
|
||||||
|
showWhen: codeBlockShowWhen,
|
||||||
|
handler: actionToHandler('checkCodeErrors', AIStarIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Explain selection',
|
||||||
|
icon: SelectionIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: actionToHandler('explain', AIStarIconWithAnimation),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const GenerateWithAIGroup: AIItemGroupConfig = {
|
||||||
|
name: 'generate with ai',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Summarize',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: actionToHandler('summary', AIPenIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Generate headings',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
beta: true,
|
||||||
|
handler: actionToHandler('createHeadings', AIPenIconWithAnimation),
|
||||||
|
showWhen: chain => {
|
||||||
|
const [_, ctx] = chain
|
||||||
|
.getSelectedModels({
|
||||||
|
types: ['block', 'text'],
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
const { selectedModels } = ctx;
|
||||||
|
if (!selectedModels || selectedModels.length === 0) return false;
|
||||||
|
|
||||||
|
return selectedModels.every(
|
||||||
|
model =>
|
||||||
|
matchFlavours(model, ['affine:paragraph', 'affine:list']) &&
|
||||||
|
!model.type.startsWith('h')
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Generate an image',
|
||||||
|
icon: AIImageIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: edgelessHandler('createImage', AIImageIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Generate outline',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: actionToHandler('writeOutline', AIPenIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Brainstorm ideas with mind map',
|
||||||
|
icon: AIMindMapIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: edgelessHandler('brainstormMindmap', AIPenIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Generate presentation',
|
||||||
|
icon: AIPresentationIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: edgelessHandler('createSlides', AIPresentationIconWithAnimation),
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Make it real',
|
||||||
|
icon: MakeItRealIcon,
|
||||||
|
beta: true,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: edgelessHandler('makeItReal', MakeItRealIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Find actions',
|
||||||
|
icon: AISearchIcon,
|
||||||
|
showWhen: textBlockShowWhen,
|
||||||
|
handler: actionToHandler('findActions', AIStarIconWithAnimation),
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const OthersAIGroup: AIItemGroupConfig = {
|
||||||
|
name: 'Others',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Open AI Chat',
|
||||||
|
icon: ChatWithAIIcon,
|
||||||
|
handler: host => {
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
AIProvider.slots.requestContinueInChat.emit({
|
||||||
|
host: host,
|
||||||
|
show: true,
|
||||||
|
});
|
||||||
|
panel.hide();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AIItemGroups: AIItemGroupConfig[] = [
|
||||||
|
ReviewWIthAIGroup,
|
||||||
|
EditAIGroup,
|
||||||
|
GenerateWithAIGroup,
|
||||||
|
DraftAIGroup,
|
||||||
|
OthersAIGroup,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function buildAIImageItemGroups(): AIItemGroupConfig[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'edit with ai',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Explain this image',
|
||||||
|
icon: ExplainIcon,
|
||||||
|
showWhen: () => true,
|
||||||
|
handler: actionToHandler(
|
||||||
|
'explainImage',
|
||||||
|
AIStarIconWithAnimation,
|
||||||
|
undefined,
|
||||||
|
blockActionTrackerOptions
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'generate with ai',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Generate an image',
|
||||||
|
icon: AIImageIcon,
|
||||||
|
showWhen: () => true,
|
||||||
|
handler: edgelessHandler(
|
||||||
|
'createImage',
|
||||||
|
AIImageIconWithAnimation,
|
||||||
|
undefined,
|
||||||
|
blockActionTrackerOptions
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'AI image filter',
|
||||||
|
icon: ImproveWritingIcon,
|
||||||
|
showWhen: (_, __, host) =>
|
||||||
|
!!host.doc.awarenessStore.getFlag('enable_new_image_actions'),
|
||||||
|
subItem: createImageFilterSubItem(blockActionTrackerOptions),
|
||||||
|
subItemOffset: [12, -4],
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Image processing',
|
||||||
|
icon: AIImageIcon,
|
||||||
|
showWhen: (_, __, host) =>
|
||||||
|
!!host.doc.awarenessStore.getFlag('enable_new_image_actions'),
|
||||||
|
subItem: createImageProcessingSubItem(blockActionTrackerOptions),
|
||||||
|
subItemOffset: [12, -6],
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Generate a caption',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: (_, __, host) =>
|
||||||
|
!!host.doc.awarenessStore.getFlag('enable_new_image_actions'),
|
||||||
|
beta: true,
|
||||||
|
handler: actionToHandler(
|
||||||
|
'generateCaption',
|
||||||
|
AIStarIconWithAnimation,
|
||||||
|
undefined,
|
||||||
|
blockActionTrackerOptions
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
OthersAIGroup,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAICodeItemGroups(): AIItemGroupConfig[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'edit with ai',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Explain this code',
|
||||||
|
icon: ExplainIcon,
|
||||||
|
showWhen: () => true,
|
||||||
|
handler: actionToHandler(
|
||||||
|
'explainCode',
|
||||||
|
AIStarIconWithAnimation,
|
||||||
|
undefined,
|
||||||
|
blockActionTrackerOptions
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Check code error',
|
||||||
|
icon: ExplainIcon,
|
||||||
|
showWhen: () => true,
|
||||||
|
handler: actionToHandler(
|
||||||
|
'checkCodeErrors',
|
||||||
|
AIStarIconWithAnimation,
|
||||||
|
undefined,
|
||||||
|
blockActionTrackerOptions
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
OthersAIGroup,
|
||||||
|
];
|
||||||
|
}
|
1063
packages/frontend/core/src/blocksuite/presets/ai/_common/icons.ts
Normal file
1063
packages/frontend/core/src/blocksuite/presets/ai/_common/icons.ts
Normal file
File diff suppressed because one or more lines are too long
@ -0,0 +1,70 @@
|
|||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import { MarkdownAdapter, titleMiddleware } from '@blocksuite/blocks';
|
||||||
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
|
import { type BlockModel, Job, type Slice } from '@blocksuite/store';
|
||||||
|
|
||||||
|
export async function getMarkdownFromSlice(host: EditorHost, slice: Slice) {
|
||||||
|
const job = new Job({
|
||||||
|
collection: host.std.doc.collection,
|
||||||
|
middlewares: [titleMiddleware],
|
||||||
|
});
|
||||||
|
const markdownAdapter = new MarkdownAdapter(job);
|
||||||
|
const markdown = await markdownAdapter.fromSlice(slice);
|
||||||
|
|
||||||
|
return markdown.file;
|
||||||
|
}
|
||||||
|
export const markdownToSnapshot = async (
|
||||||
|
markdown: string,
|
||||||
|
host: EditorHost
|
||||||
|
) => {
|
||||||
|
const job = new Job({ collection: host.std.doc.collection });
|
||||||
|
const markdownAdapter = new MarkdownAdapter(job);
|
||||||
|
const { blockVersions, workspaceVersion, pageVersion } =
|
||||||
|
host.std.doc.collection.meta;
|
||||||
|
if (!blockVersions || !workspaceVersion || !pageVersion)
|
||||||
|
throw new Error(
|
||||||
|
'Need blockVersions, workspaceVersion, pageVersion meta information to get slice'
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
file: markdown,
|
||||||
|
assets: job.assetsManager,
|
||||||
|
blockVersions,
|
||||||
|
pageVersion,
|
||||||
|
workspaceVersion,
|
||||||
|
workspaceId: host.std.doc.collection.id,
|
||||||
|
pageId: host.std.doc.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = await markdownAdapter.toSliceSnapshot(payload);
|
||||||
|
assertExists(snapshot, 'import markdown failed, expected to get a snapshot');
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshot,
|
||||||
|
job,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
export async function insertFromMarkdown(
|
||||||
|
host: EditorHost,
|
||||||
|
markdown: string,
|
||||||
|
parent?: string,
|
||||||
|
index?: number
|
||||||
|
) {
|
||||||
|
const { snapshot, job } = await markdownToSnapshot(markdown, host);
|
||||||
|
|
||||||
|
const snapshots = snapshot.content[0].children;
|
||||||
|
|
||||||
|
const models: BlockModel[] = [];
|
||||||
|
for (let i = 0; i < snapshots.length; i++) {
|
||||||
|
const blockSnapshot = snapshots[i];
|
||||||
|
const model = await job.snapshotToBlock(
|
||||||
|
blockSnapshot,
|
||||||
|
host.std.doc,
|
||||||
|
parent,
|
||||||
|
(index ?? 0) + i
|
||||||
|
);
|
||||||
|
models.push(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
return models;
|
||||||
|
}
|
@ -0,0 +1,117 @@
|
|||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import {
|
||||||
|
BlocksUtils,
|
||||||
|
EdgelessRootService,
|
||||||
|
type FrameBlockModel,
|
||||||
|
type ImageBlockModel,
|
||||||
|
type SurfaceBlockComponent,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
|
import { Slice } from '@blocksuite/store';
|
||||||
|
|
||||||
|
import { getMarkdownFromSlice } from './markdown-utils.js';
|
||||||
|
|
||||||
|
export const getRootService = (host: EditorHost) => {
|
||||||
|
return host.std.spec.getService('affine:page');
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getEdgelessRootFromEditor(editor: EditorHost) {
|
||||||
|
const edgelessRoot = editor.getElementsByTagName('affine-edgeless-root')[0];
|
||||||
|
if (!edgelessRoot) {
|
||||||
|
alert('Please switch to edgeless mode');
|
||||||
|
throw new Error('Please open switch to edgeless mode');
|
||||||
|
}
|
||||||
|
return edgelessRoot;
|
||||||
|
}
|
||||||
|
export function getEdgelessService(editor: EditorHost) {
|
||||||
|
const rootService = editor.std.spec.getService('affine:page');
|
||||||
|
if (rootService instanceof EdgelessRootService) {
|
||||||
|
return rootService;
|
||||||
|
}
|
||||||
|
alert('Please switch to edgeless mode');
|
||||||
|
throw new Error('Please open switch to edgeless mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectedToCanvas(editor: EditorHost) {
|
||||||
|
const edgelessRoot = getEdgelessRootFromEditor(editor);
|
||||||
|
const { notes, frames, shapes, images } = BlocksUtils.splitElements(
|
||||||
|
edgelessRoot.service.selection.selectedElements
|
||||||
|
);
|
||||||
|
if (notes.length + frames.length + images.length + shapes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canvas = await edgelessRoot.clipboardController.toCanvas(
|
||||||
|
[...notes, ...frames, ...images],
|
||||||
|
shapes
|
||||||
|
);
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function frameToCanvas(
|
||||||
|
frame: FrameBlockModel,
|
||||||
|
editor: EditorHost
|
||||||
|
) {
|
||||||
|
const edgelessRoot = getEdgelessRootFromEditor(editor);
|
||||||
|
const { notes, frames, shapes, images } = BlocksUtils.splitElements(
|
||||||
|
edgelessRoot.service.frame.getElementsInFrame(frame, true)
|
||||||
|
);
|
||||||
|
if (notes.length + frames.length + images.length + shapes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canvas = await edgelessRoot.clipboardController.toCanvas(
|
||||||
|
[...notes, ...frames, ...images],
|
||||||
|
shapes
|
||||||
|
);
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectedToPng(editor: EditorHost) {
|
||||||
|
return (await selectedToCanvas(editor))?.toDataURL('image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSelectedTextContent(editorHost: EditorHost) {
|
||||||
|
const slice = Slice.fromModels(
|
||||||
|
editorHost.std.doc,
|
||||||
|
getRootService(editorHost).selectedModels
|
||||||
|
);
|
||||||
|
return getMarkdownFromSlice(editorHost, slice);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stopPropagation = (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSurfaceElementFromEditor(editor: EditorHost) {
|
||||||
|
const { doc } = editor;
|
||||||
|
const surfaceModel = doc.getBlockByFlavour('affine:surface')[0];
|
||||||
|
assertExists(surfaceModel);
|
||||||
|
|
||||||
|
const surfaceId = surfaceModel.id;
|
||||||
|
const surfaceElement = editor.querySelector(
|
||||||
|
`affine-surface[data-block-id="${surfaceId}"]`
|
||||||
|
) as SurfaceBlockComponent;
|
||||||
|
assertExists(surfaceElement);
|
||||||
|
|
||||||
|
return surfaceElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getFirstImageInFrame = (
|
||||||
|
frame: FrameBlockModel,
|
||||||
|
editor: EditorHost
|
||||||
|
) => {
|
||||||
|
const edgelessRoot = getEdgelessRootFromEditor(editor);
|
||||||
|
const elements = edgelessRoot.service.frame.getElementsInFrame(frame, false);
|
||||||
|
const image = elements.find(ele => {
|
||||||
|
if (!BlocksUtils.isCanvasElement(ele)) {
|
||||||
|
return ele.flavour === 'affine:image';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}) as ImageBlockModel | undefined;
|
||||||
|
return image?.id;
|
||||||
|
};
|
@ -0,0 +1,29 @@
|
|||||||
|
export const EXCLUDING_COPY_ACTIONS = [
|
||||||
|
'brainstormMindmap',
|
||||||
|
'expandMindmap',
|
||||||
|
'makeItReal',
|
||||||
|
'createSlides',
|
||||||
|
'createImage',
|
||||||
|
'findActions',
|
||||||
|
'filterImage',
|
||||||
|
'processImage',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EXCLUDING_INSERT_ACTIONS = ['generateCaption'];
|
||||||
|
|
||||||
|
export const IMAGE_ACTIONS = ['createImage', 'processImage', 'filterImage'];
|
||||||
|
|
||||||
|
const commonImageStages = ['Generating image', 'Rendering image'];
|
||||||
|
|
||||||
|
export const generatingStages: {
|
||||||
|
[key in keyof Partial<BlockSuitePresets.AIActions>]: string[];
|
||||||
|
} = {
|
||||||
|
makeItReal: ['Coding for you', 'Rendering the code'],
|
||||||
|
brainstormMindmap: ['Thinking about this topic', 'Rendering mindmap'],
|
||||||
|
createSlides: ['Thinking about this topic', 'Rendering slides'],
|
||||||
|
createImage: commonImageStages,
|
||||||
|
processImage: commonImageStages,
|
||||||
|
filterImage: commonImageStages,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const INSERT_ABOVE_ACTIONS = ['createHeadings'];
|
@ -0,0 +1,247 @@
|
|||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import type {
|
||||||
|
AffineAIPanelWidget,
|
||||||
|
AffineAIPanelWidgetConfig,
|
||||||
|
AIError,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
|
import type { TemplateResult } from 'lit';
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildCopyConfig,
|
||||||
|
buildErrorConfig,
|
||||||
|
buildFinishConfig,
|
||||||
|
buildGeneratingConfig,
|
||||||
|
getAIPanel,
|
||||||
|
} from '../ai-panel.js';
|
||||||
|
import { createTextRenderer } from '../messages/text.js';
|
||||||
|
import { AIProvider } from '../provider.js';
|
||||||
|
import { reportResponse } from '../utils/action-reporter.js';
|
||||||
|
import {
|
||||||
|
getSelectedImagesAsBlobs,
|
||||||
|
getSelectedTextContent,
|
||||||
|
getSelections,
|
||||||
|
selectAboveBlocks,
|
||||||
|
} from '../utils/selection-utils.js';
|
||||||
|
|
||||||
|
export function bindTextStream(
|
||||||
|
stream: BlockSuitePresets.TextStream,
|
||||||
|
{
|
||||||
|
update,
|
||||||
|
finish,
|
||||||
|
signal,
|
||||||
|
}: {
|
||||||
|
update: (text: string) => void;
|
||||||
|
finish: (state: 'success' | 'error' | 'aborted', err?: AIError) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
(async () => {
|
||||||
|
let answer = '';
|
||||||
|
signal?.addEventListener('abort', () => {
|
||||||
|
finish('aborted');
|
||||||
|
reportResponse('aborted:stop');
|
||||||
|
});
|
||||||
|
for await (const data of stream) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
answer += data;
|
||||||
|
update(answer);
|
||||||
|
}
|
||||||
|
finish('success');
|
||||||
|
})().catch(err => {
|
||||||
|
if (signal?.aborted) return;
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
finish('aborted');
|
||||||
|
} else {
|
||||||
|
finish('error', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actionToStream<T extends keyof BlockSuitePresets.AIActions>(
|
||||||
|
id: T,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
variants?: Omit<
|
||||||
|
Parameters<BlockSuitePresets.AIActions[T]>[0],
|
||||||
|
keyof BlockSuitePresets.AITextActionOptions
|
||||||
|
>,
|
||||||
|
trackerOptions?: BlockSuitePresets.TrackerOptions
|
||||||
|
) {
|
||||||
|
const action = AIProvider.actions[id];
|
||||||
|
if (!action || typeof action !== 'function') return;
|
||||||
|
return (host: EditorHost): BlockSuitePresets.TextStream => {
|
||||||
|
let stream: BlockSuitePresets.TextStream | undefined;
|
||||||
|
return {
|
||||||
|
async *[Symbol.asyncIterator]() {
|
||||||
|
const { currentTextSelection, selectedBlocks } = getSelections(host);
|
||||||
|
|
||||||
|
let markdown: string;
|
||||||
|
let attachments: File[] = [];
|
||||||
|
|
||||||
|
if (currentTextSelection?.isCollapsed()) {
|
||||||
|
markdown = await selectAboveBlocks(host);
|
||||||
|
} else {
|
||||||
|
[markdown, attachments] = await Promise.all([
|
||||||
|
getSelectedTextContent(host),
|
||||||
|
getSelectedImagesAsBlobs(host),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// for now if there are more than one selected blocks, we will not omit the attachments
|
||||||
|
const sendAttachments =
|
||||||
|
selectedBlocks?.length === 1 && attachments.length > 0;
|
||||||
|
const models = selectedBlocks?.map(block => block.model);
|
||||||
|
const control = trackerOptions?.control ?? 'format-bar';
|
||||||
|
const where = trackerOptions?.where ?? 'ai-panel';
|
||||||
|
const options = {
|
||||||
|
...variants,
|
||||||
|
attachments: sendAttachments ? attachments : undefined,
|
||||||
|
input: sendAttachments ? '' : markdown,
|
||||||
|
stream: true,
|
||||||
|
host,
|
||||||
|
models,
|
||||||
|
signal,
|
||||||
|
control,
|
||||||
|
where,
|
||||||
|
docId: host.doc.id,
|
||||||
|
workspaceId: host.doc.collection.id,
|
||||||
|
} as Parameters<typeof action>[0];
|
||||||
|
// @ts-expect-error todo: maybe fix this
|
||||||
|
stream = action(options);
|
||||||
|
if (!stream) return;
|
||||||
|
yield* stream;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actionToGenerateAnswer<
|
||||||
|
T extends keyof BlockSuitePresets.AIActions,
|
||||||
|
>(
|
||||||
|
id: T,
|
||||||
|
variants?: Omit<
|
||||||
|
Parameters<BlockSuitePresets.AIActions[T]>[0],
|
||||||
|
keyof BlockSuitePresets.AITextActionOptions
|
||||||
|
>,
|
||||||
|
trackerOptions?: BlockSuitePresets.TrackerOptions
|
||||||
|
) {
|
||||||
|
return (host: EditorHost) => {
|
||||||
|
return ({
|
||||||
|
signal,
|
||||||
|
update,
|
||||||
|
finish,
|
||||||
|
}: {
|
||||||
|
input: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
update: (text: string) => void;
|
||||||
|
finish: (state: 'success' | 'error' | 'aborted', err?: AIError) => void;
|
||||||
|
}) => {
|
||||||
|
const { selectedBlocks: blocks } = getSelections(host);
|
||||||
|
if (!blocks || blocks.length === 0) return;
|
||||||
|
const stream = actionToStream(
|
||||||
|
id,
|
||||||
|
signal,
|
||||||
|
variants,
|
||||||
|
trackerOptions
|
||||||
|
)?.(host);
|
||||||
|
if (!stream) return;
|
||||||
|
bindTextStream(stream, { update, finish, signal });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: Should update config according to the action type
|
||||||
|
* When support mind-map. generate image, generate slides on doc mode or in edgeless note block
|
||||||
|
* Currently, only support text action
|
||||||
|
*/
|
||||||
|
function updateAIPanelConfig<T extends keyof BlockSuitePresets.AIActions>(
|
||||||
|
aiPanel: AffineAIPanelWidget,
|
||||||
|
id: T,
|
||||||
|
generatingIcon: TemplateResult<1>,
|
||||||
|
variants?: Omit<
|
||||||
|
Parameters<BlockSuitePresets.AIActions[T]>[0],
|
||||||
|
keyof BlockSuitePresets.AITextActionOptions
|
||||||
|
>,
|
||||||
|
trackerOptions?: BlockSuitePresets.TrackerOptions
|
||||||
|
) {
|
||||||
|
const { config, host } = aiPanel;
|
||||||
|
assertExists(config);
|
||||||
|
config.generateAnswer = actionToGenerateAnswer(
|
||||||
|
id,
|
||||||
|
variants,
|
||||||
|
trackerOptions
|
||||||
|
)(host);
|
||||||
|
config.answerRenderer = createTextRenderer(host, { maxHeight: 320 });
|
||||||
|
config.finishStateConfig = buildFinishConfig(aiPanel, id);
|
||||||
|
config.generatingStateConfig = buildGeneratingConfig(generatingIcon);
|
||||||
|
config.errorStateConfig = buildErrorConfig(aiPanel);
|
||||||
|
config.copy = buildCopyConfig(aiPanel);
|
||||||
|
config.discardCallback = () => {
|
||||||
|
reportResponse('result:discard');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actionToHandler<T extends keyof BlockSuitePresets.AIActions>(
|
||||||
|
id: T,
|
||||||
|
generatingIcon: TemplateResult<1>,
|
||||||
|
variants?: Omit<
|
||||||
|
Parameters<BlockSuitePresets.AIActions[T]>[0],
|
||||||
|
keyof BlockSuitePresets.AITextActionOptions
|
||||||
|
>,
|
||||||
|
trackerOptions?: BlockSuitePresets.TrackerOptions
|
||||||
|
) {
|
||||||
|
return (host: EditorHost) => {
|
||||||
|
const aiPanel = getAIPanel(host);
|
||||||
|
updateAIPanelConfig(aiPanel, id, generatingIcon, variants, trackerOptions);
|
||||||
|
const { selectedBlocks: blocks } = getSelections(aiPanel.host);
|
||||||
|
if (!blocks || blocks.length === 0) return;
|
||||||
|
const block = blocks.at(-1);
|
||||||
|
assertExists(block);
|
||||||
|
aiPanel.toggle(block, 'placeholder');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleInlineAskAIAction(host: EditorHost) {
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
const selection = host.selection.find('text');
|
||||||
|
const lastBlockPath = selection
|
||||||
|
? selection.to?.blockId ?? selection.blockId
|
||||||
|
: null;
|
||||||
|
if (!lastBlockPath) return;
|
||||||
|
const block = host.view.getBlock(lastBlockPath);
|
||||||
|
if (!block) return;
|
||||||
|
const generateAnswer: AffineAIPanelWidgetConfig['generateAnswer'] = ({
|
||||||
|
finish,
|
||||||
|
input,
|
||||||
|
signal,
|
||||||
|
update,
|
||||||
|
}) => {
|
||||||
|
if (!AIProvider.actions.chat) return;
|
||||||
|
|
||||||
|
// recover selection to get content from above blocks
|
||||||
|
assertExists(selection);
|
||||||
|
host.selection.set([selection]);
|
||||||
|
|
||||||
|
selectAboveBlocks(host)
|
||||||
|
.then(context => {
|
||||||
|
assertExists(AIProvider.actions.chat);
|
||||||
|
const stream = AIProvider.actions.chat({
|
||||||
|
input: `${context}\n${input}`,
|
||||||
|
stream: true,
|
||||||
|
host,
|
||||||
|
where: 'inline-chat-panel',
|
||||||
|
control: 'chat-send',
|
||||||
|
docId: host.doc.id,
|
||||||
|
workspaceId: host.doc.collection.id,
|
||||||
|
});
|
||||||
|
bindTextStream(stream, { update, finish, signal });
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
};
|
||||||
|
assertExists(panel.config);
|
||||||
|
panel.config.generateAnswer = generateAnswer;
|
||||||
|
panel.toggle(block);
|
||||||
|
}
|
@ -0,0 +1,544 @@
|
|||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import type {
|
||||||
|
AffineAIPanelWidget,
|
||||||
|
AIError,
|
||||||
|
EdgelessCopilotWidget,
|
||||||
|
MindmapElementModel,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import {
|
||||||
|
BlocksUtils,
|
||||||
|
EdgelessTextBlockModel,
|
||||||
|
ImageBlockModel,
|
||||||
|
NoteBlockModel,
|
||||||
|
ShapeElementModel,
|
||||||
|
TextElementModel,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
|
import { Slice } from '@blocksuite/store';
|
||||||
|
import type { TemplateResult } from 'lit';
|
||||||
|
|
||||||
|
import { getAIPanel } from '../ai-panel.js';
|
||||||
|
import {
|
||||||
|
createMindmapExecuteRenderer,
|
||||||
|
createMindmapRenderer,
|
||||||
|
} from '../messages/mindmap.js';
|
||||||
|
import { createSlidesRenderer } from '../messages/slides-renderer.js';
|
||||||
|
import { createTextRenderer } from '../messages/text.js';
|
||||||
|
import {
|
||||||
|
createIframeRenderer,
|
||||||
|
createImageRenderer,
|
||||||
|
} from '../messages/wrapper.js';
|
||||||
|
import { AIProvider } from '../provider.js';
|
||||||
|
import { reportResponse } from '../utils/action-reporter.js';
|
||||||
|
import {
|
||||||
|
getEdgelessCopilotWidget,
|
||||||
|
isMindmapChild,
|
||||||
|
isMindMapRoot,
|
||||||
|
} from '../utils/edgeless.js';
|
||||||
|
import { copyTextAnswer } from '../utils/editor-actions.js';
|
||||||
|
import { getContentFromSlice } from '../utils/markdown-utils.js';
|
||||||
|
import {
|
||||||
|
getCopilotSelectedElems,
|
||||||
|
getSelectedNoteAnchor,
|
||||||
|
getSelections,
|
||||||
|
} from '../utils/selection-utils.js';
|
||||||
|
import { EXCLUDING_COPY_ACTIONS, IMAGE_ACTIONS } from './consts.js';
|
||||||
|
import { bindTextStream } from './doc-handler.js';
|
||||||
|
import {
|
||||||
|
actionToErrorResponse,
|
||||||
|
actionToGenerating,
|
||||||
|
actionToResponse,
|
||||||
|
getElementToolbar,
|
||||||
|
responses,
|
||||||
|
} from './edgeless-response.js';
|
||||||
|
import type { CtxRecord } from './types.js';
|
||||||
|
|
||||||
|
type AnswerRenderer = NonNullable<
|
||||||
|
AffineAIPanelWidget['config']
|
||||||
|
>['answerRenderer'];
|
||||||
|
|
||||||
|
function actionToRenderer<T extends keyof BlockSuitePresets.AIActions>(
|
||||||
|
id: T,
|
||||||
|
host: EditorHost,
|
||||||
|
ctx: CtxRecord
|
||||||
|
): AnswerRenderer {
|
||||||
|
if (id === 'brainstormMindmap') {
|
||||||
|
const selectedElements = ctx.get()[
|
||||||
|
'selectedElements'
|
||||||
|
] as BlockSuite.EdgelessModelType[];
|
||||||
|
|
||||||
|
if (
|
||||||
|
isMindMapRoot(selectedElements[0] || isMindmapChild(selectedElements[0]))
|
||||||
|
) {
|
||||||
|
const mindmap = selectedElements[0].group as MindmapElementModel;
|
||||||
|
|
||||||
|
return createMindmapRenderer(host, ctx, mindmap.style);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createMindmapRenderer(host, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === 'expandMindmap') {
|
||||||
|
return createMindmapExecuteRenderer(host, ctx, ctx => {
|
||||||
|
responses['expandMindmap']?.(host, ctx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === 'createSlides') {
|
||||||
|
return createSlidesRenderer(host, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id === 'makeItReal') {
|
||||||
|
return createIframeRenderer(host, { height: 300 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IMAGE_ACTIONS.includes(id)) {
|
||||||
|
return createImageRenderer(host, { height: 300 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return createTextRenderer(host, { maxHeight: 320 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getContentFromHubBlockModel(
|
||||||
|
host: EditorHost,
|
||||||
|
models: EdgelessTextBlockModel[] | NoteBlockModel[]
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
await Promise.all(
|
||||||
|
models.map(model => {
|
||||||
|
const slice = Slice.fromModels(host.doc, model.children);
|
||||||
|
return getContentFromSlice(host, slice);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.map(content => content.trim())
|
||||||
|
.filter(content => content.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContentFromSelected(
|
||||||
|
host: EditorHost,
|
||||||
|
selected: BlockSuite.EdgelessModelType[]
|
||||||
|
) {
|
||||||
|
type RemoveUndefinedKey<T, K extends keyof T> = T & {
|
||||||
|
[P in K]-?: Exclude<T[P], undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isShapeWithText(
|
||||||
|
el: ShapeElementModel
|
||||||
|
): el is RemoveUndefinedKey<ShapeElementModel, 'text'> {
|
||||||
|
return el.text !== undefined && el.text.length !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImageWithCaption(
|
||||||
|
el: ImageBlockModel
|
||||||
|
): el is RemoveUndefinedKey<ImageBlockModel, 'caption'> {
|
||||||
|
return el.caption !== undefined && el.caption.length !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { notes, texts, shapes, images, edgelessTexts } = selected.reduce<{
|
||||||
|
notes: NoteBlockModel[];
|
||||||
|
texts: TextElementModel[];
|
||||||
|
shapes: RemoveUndefinedKey<ShapeElementModel, 'text'>[];
|
||||||
|
images: RemoveUndefinedKey<ImageBlockModel, 'caption'>[];
|
||||||
|
edgelessTexts: EdgelessTextBlockModel[];
|
||||||
|
}>(
|
||||||
|
(pre, cur) => {
|
||||||
|
if (cur instanceof NoteBlockModel) {
|
||||||
|
pre.notes.push(cur);
|
||||||
|
} else if (cur instanceof TextElementModel) {
|
||||||
|
pre.texts.push(cur);
|
||||||
|
} else if (cur instanceof ShapeElementModel && isShapeWithText(cur)) {
|
||||||
|
pre.shapes.push(cur);
|
||||||
|
} else if (cur instanceof ImageBlockModel && isImageWithCaption(cur)) {
|
||||||
|
pre.images.push(cur);
|
||||||
|
} else if (cur instanceof EdgelessTextBlockModel) {
|
||||||
|
pre.edgelessTexts.push(cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pre;
|
||||||
|
},
|
||||||
|
{ notes: [], texts: [], shapes: [], images: [], edgelessTexts: [] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const noteContent = await getContentFromHubBlockModel(host, notes);
|
||||||
|
const edgelessTextContent = await getContentFromHubBlockModel(
|
||||||
|
host,
|
||||||
|
edgelessTexts
|
||||||
|
);
|
||||||
|
|
||||||
|
return `${noteContent.join('\n')}
|
||||||
|
${edgelessTextContent.join('\n')}
|
||||||
|
${texts.map(text => text.text.toString()).join('\n')}
|
||||||
|
${shapes.map(shape => shape.text.toString()).join('\n')}
|
||||||
|
${images.map(image => image.caption.toString()).join('\n')}
|
||||||
|
`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTextFromSelected(host: EditorHost) {
|
||||||
|
const selected = getCopilotSelectedElems(host);
|
||||||
|
return getContentFromSelected(host, selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionToStream<T extends keyof BlockSuitePresets.AIActions>(
|
||||||
|
id: T,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
variants?: Omit<
|
||||||
|
Parameters<BlockSuitePresets.AIActions[T]>[0],
|
||||||
|
keyof BlockSuitePresets.AITextActionOptions
|
||||||
|
>,
|
||||||
|
extract?: (
|
||||||
|
host: EditorHost,
|
||||||
|
ctx: CtxRecord
|
||||||
|
) => Promise<{
|
||||||
|
content?: string;
|
||||||
|
attachments?: (string | Blob)[];
|
||||||
|
seed?: string;
|
||||||
|
} | void>,
|
||||||
|
trackerOptions?: BlockSuitePresets.TrackerOptions
|
||||||
|
) {
|
||||||
|
const action = AIProvider.actions[id];
|
||||||
|
|
||||||
|
if (!action || typeof action !== 'function') return;
|
||||||
|
|
||||||
|
if (extract && typeof extract === 'function') {
|
||||||
|
return (host: EditorHost, ctx: CtxRecord): BlockSuitePresets.TextStream => {
|
||||||
|
let stream: BlockSuitePresets.TextStream | undefined;
|
||||||
|
const control = trackerOptions?.control || 'format-bar';
|
||||||
|
const where = trackerOptions?.where || 'ai-panel';
|
||||||
|
return {
|
||||||
|
async *[Symbol.asyncIterator]() {
|
||||||
|
const models = getCopilotSelectedElems(host);
|
||||||
|
const options = {
|
||||||
|
...variants,
|
||||||
|
signal,
|
||||||
|
input: '',
|
||||||
|
stream: true,
|
||||||
|
control,
|
||||||
|
where,
|
||||||
|
models,
|
||||||
|
host,
|
||||||
|
docId: host.doc.id,
|
||||||
|
workspaceId: host.doc.collection.id,
|
||||||
|
} as Parameters<typeof action>[0];
|
||||||
|
|
||||||
|
const data = await extract(host, ctx);
|
||||||
|
if (data) {
|
||||||
|
Object.assign(options, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-expect-error todo: maybe fix this
|
||||||
|
stream = action(options);
|
||||||
|
if (!stream) return;
|
||||||
|
yield* stream;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return (host: EditorHost): BlockSuitePresets.TextStream => {
|
||||||
|
let stream: BlockSuitePresets.TextStream | undefined;
|
||||||
|
return {
|
||||||
|
async *[Symbol.asyncIterator]() {
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
const models = getCopilotSelectedElems(host);
|
||||||
|
const markdown = await getTextFromSelected(panel.host);
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
...variants,
|
||||||
|
signal,
|
||||||
|
input: markdown,
|
||||||
|
stream: true,
|
||||||
|
where: 'ai-panel',
|
||||||
|
models,
|
||||||
|
control: 'format-bar',
|
||||||
|
host,
|
||||||
|
docId: host.doc.id,
|
||||||
|
workspaceId: host.doc.collection.id,
|
||||||
|
} as Parameters<typeof action>[0];
|
||||||
|
|
||||||
|
// @ts-expect-error todo: maybe fix this
|
||||||
|
stream = action(options);
|
||||||
|
if (!stream) return;
|
||||||
|
yield* stream;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionToGeneration<T extends keyof BlockSuitePresets.AIActions>(
|
||||||
|
id: T,
|
||||||
|
variants?: Omit<
|
||||||
|
Parameters<BlockSuitePresets.AIActions[T]>[0],
|
||||||
|
keyof BlockSuitePresets.AITextActionOptions
|
||||||
|
>,
|
||||||
|
extract?: (
|
||||||
|
host: EditorHost,
|
||||||
|
ctx: CtxRecord
|
||||||
|
) => Promise<{
|
||||||
|
content?: string;
|
||||||
|
attachments?: (string | Blob)[];
|
||||||
|
seed?: string;
|
||||||
|
} | void>,
|
||||||
|
trackerOptions?: BlockSuitePresets.TrackerOptions
|
||||||
|
) {
|
||||||
|
return (host: EditorHost, ctx: CtxRecord) => {
|
||||||
|
return ({
|
||||||
|
signal,
|
||||||
|
update,
|
||||||
|
finish,
|
||||||
|
}: {
|
||||||
|
input: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
update: (text: string) => void;
|
||||||
|
finish: (state: 'success' | 'error' | 'aborted', err?: AIError) => void;
|
||||||
|
}) => {
|
||||||
|
if (!extract) {
|
||||||
|
const selectedElements = getCopilotSelectedElems(host);
|
||||||
|
if (selectedElements.length === 0) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = actionToStream(
|
||||||
|
id,
|
||||||
|
signal,
|
||||||
|
variants,
|
||||||
|
extract,
|
||||||
|
trackerOptions
|
||||||
|
)?.(host, ctx);
|
||||||
|
|
||||||
|
if (!stream) return;
|
||||||
|
|
||||||
|
bindTextStream(stream, { update, finish, signal });
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEdgelessAIPanelConfig<
|
||||||
|
T extends keyof BlockSuitePresets.AIActions,
|
||||||
|
>(
|
||||||
|
aiPanel: AffineAIPanelWidget,
|
||||||
|
edgelessCopilot: EdgelessCopilotWidget,
|
||||||
|
id: T,
|
||||||
|
generatingIcon: TemplateResult<1>,
|
||||||
|
ctx: CtxRecord,
|
||||||
|
variants?: Omit<
|
||||||
|
Parameters<BlockSuitePresets.AIActions[T]>[0],
|
||||||
|
keyof BlockSuitePresets.AITextActionOptions
|
||||||
|
>,
|
||||||
|
customInput?: (
|
||||||
|
host: EditorHost,
|
||||||
|
ctx: CtxRecord
|
||||||
|
) => Promise<{
|
||||||
|
input?: string;
|
||||||
|
content?: string;
|
||||||
|
attachments?: (string | Blob)[];
|
||||||
|
seed?: string;
|
||||||
|
} | void>,
|
||||||
|
trackerOptions?: BlockSuitePresets.TrackerOptions
|
||||||
|
) {
|
||||||
|
const host = aiPanel.host;
|
||||||
|
const { config } = aiPanel;
|
||||||
|
assertExists(config);
|
||||||
|
config.answerRenderer = actionToRenderer(id, host, ctx);
|
||||||
|
config.generateAnswer = actionToGeneration(
|
||||||
|
id,
|
||||||
|
variants,
|
||||||
|
customInput,
|
||||||
|
trackerOptions
|
||||||
|
)(host, ctx);
|
||||||
|
config.finishStateConfig = actionToResponse(id, host, ctx, variants);
|
||||||
|
config.generatingStateConfig = actionToGenerating(id, generatingIcon);
|
||||||
|
config.errorStateConfig = actionToErrorResponse(
|
||||||
|
aiPanel,
|
||||||
|
id,
|
||||||
|
host,
|
||||||
|
ctx,
|
||||||
|
variants
|
||||||
|
);
|
||||||
|
config.copy = {
|
||||||
|
allowed: !EXCLUDING_COPY_ACTIONS.includes(id),
|
||||||
|
onCopy: () => {
|
||||||
|
return copyTextAnswer(aiPanel);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
config.discardCallback = () => {
|
||||||
|
reportResponse('result:discard');
|
||||||
|
};
|
||||||
|
config.hideCallback = () => {
|
||||||
|
aiPanel.updateComplete
|
||||||
|
.finally(() => {
|
||||||
|
edgelessCopilot.edgeless.service.tool.switchToDefaultMode({
|
||||||
|
elements: [],
|
||||||
|
editing: false,
|
||||||
|
});
|
||||||
|
host.selection.clear();
|
||||||
|
edgelessCopilot.lockToolbar(false);
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actionToHandler<T extends keyof BlockSuitePresets.AIActions>(
|
||||||
|
id: T,
|
||||||
|
generatingIcon: TemplateResult<1>,
|
||||||
|
variants?: Omit<
|
||||||
|
Parameters<BlockSuitePresets.AIActions[T]>[0],
|
||||||
|
keyof BlockSuitePresets.AITextActionOptions
|
||||||
|
>,
|
||||||
|
customInput?: (
|
||||||
|
host: EditorHost,
|
||||||
|
ctx: CtxRecord
|
||||||
|
) => Promise<{
|
||||||
|
input?: string;
|
||||||
|
content?: string;
|
||||||
|
attachments?: (string | Blob)[];
|
||||||
|
seed?: string;
|
||||||
|
} | void>,
|
||||||
|
trackerOptions?: BlockSuitePresets.TrackerOptions
|
||||||
|
) {
|
||||||
|
return (host: EditorHost) => {
|
||||||
|
const aiPanel = getAIPanel(host);
|
||||||
|
const edgelessCopilot = getEdgelessCopilotWidget(host);
|
||||||
|
let internal: Record<string, unknown> = {};
|
||||||
|
const selectedElements = getCopilotSelectedElems(host);
|
||||||
|
const { selectedBlocks } = getSelections(host);
|
||||||
|
const ctx = {
|
||||||
|
get() {
|
||||||
|
return {
|
||||||
|
...internal,
|
||||||
|
selectedElements,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
set(data: Record<string, unknown>) {
|
||||||
|
internal = data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
edgelessCopilot.hideCopilotPanel();
|
||||||
|
edgelessCopilot.lockToolbar(true);
|
||||||
|
|
||||||
|
aiPanel.host = host;
|
||||||
|
updateEdgelessAIPanelConfig(
|
||||||
|
aiPanel,
|
||||||
|
edgelessCopilot,
|
||||||
|
id,
|
||||||
|
generatingIcon,
|
||||||
|
ctx,
|
||||||
|
variants,
|
||||||
|
customInput,
|
||||||
|
trackerOptions
|
||||||
|
);
|
||||||
|
|
||||||
|
const elementToolbar = getElementToolbar(host);
|
||||||
|
const isEmpty = selectedElements.length === 0;
|
||||||
|
const isCreateImageAction = id === 'createImage';
|
||||||
|
const isMakeItRealAction = !isCreateImageAction && id === 'makeItReal';
|
||||||
|
let referenceElement = null;
|
||||||
|
let togglePanel = () => Promise.resolve(isEmpty);
|
||||||
|
|
||||||
|
if (selectedBlocks && selectedBlocks.length !== 0) {
|
||||||
|
referenceElement = selectedBlocks.at(-1);
|
||||||
|
} else if (edgelessCopilot.visible && edgelessCopilot.selectionElem) {
|
||||||
|
referenceElement = edgelessCopilot.selectionElem;
|
||||||
|
} else if (elementToolbar.toolbarVisible) {
|
||||||
|
referenceElement = getElementToolbar(host);
|
||||||
|
} else if (!isEmpty) {
|
||||||
|
const lastSelected = selectedElements.at(-1)?.id;
|
||||||
|
assertExists(lastSelected);
|
||||||
|
referenceElement = getSelectedNoteAnchor(host, lastSelected);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!referenceElement) return;
|
||||||
|
|
||||||
|
if (isCreateImageAction || isMakeItRealAction) {
|
||||||
|
togglePanel = async () => {
|
||||||
|
if (isEmpty) return true;
|
||||||
|
const {
|
||||||
|
notes,
|
||||||
|
shapes,
|
||||||
|
images,
|
||||||
|
edgelessTexts,
|
||||||
|
frames: _,
|
||||||
|
} = BlocksUtils.splitElements(selectedElements);
|
||||||
|
const blocks = [...notes, ...shapes, ...images, ...edgelessTexts];
|
||||||
|
if (blocks.length === 0) return true;
|
||||||
|
const content = await getContentFromSelected(host, blocks);
|
||||||
|
ctx.set({
|
||||||
|
content,
|
||||||
|
});
|
||||||
|
return content.length === 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePanel()
|
||||||
|
.then(isEmpty => {
|
||||||
|
aiPanel.toggle(referenceElement, isEmpty ? undefined : 'placeholder');
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function noteBlockOrTextShowWhen(
|
||||||
|
_: unknown,
|
||||||
|
__: unknown,
|
||||||
|
host: EditorHost
|
||||||
|
) {
|
||||||
|
const selected = getCopilotSelectedElems(host);
|
||||||
|
|
||||||
|
return selected.some(
|
||||||
|
el =>
|
||||||
|
el instanceof NoteBlockModel ||
|
||||||
|
el instanceof TextElementModel ||
|
||||||
|
el instanceof EdgelessTextBlockModel
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the selected element is a NoteBlockModel with a single child element of code block.
|
||||||
|
*/
|
||||||
|
export function noteWithCodeBlockShowWen(
|
||||||
|
_: unknown,
|
||||||
|
__: unknown,
|
||||||
|
host: EditorHost
|
||||||
|
) {
|
||||||
|
const selected = getCopilotSelectedElems(host);
|
||||||
|
if (!selected.length) return false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
selected[0] instanceof NoteBlockModel &&
|
||||||
|
selected[0].children.length === 1 &&
|
||||||
|
BlocksUtils.matchFlavours(selected[0].children[0], ['affine:code'])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mindmapChildShowWhen(
|
||||||
|
_: unknown,
|
||||||
|
__: unknown,
|
||||||
|
host: EditorHost
|
||||||
|
) {
|
||||||
|
const selected = getCopilotSelectedElems(host);
|
||||||
|
|
||||||
|
return selected.length === 1 && isMindmapChild(selected[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function imageOnlyShowWhen(_: unknown, __: unknown, host: EditorHost) {
|
||||||
|
const selected = getCopilotSelectedElems(host);
|
||||||
|
|
||||||
|
return selected.length === 1 && selected[0] instanceof ImageBlockModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function experimentalImageActionsShowWhen(
|
||||||
|
_: unknown,
|
||||||
|
__: unknown,
|
||||||
|
host: EditorHost
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
!!host.doc.awarenessStore.getFlag('enable_new_image_actions') &&
|
||||||
|
imageOnlyShowWhen(_, __, host)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mindmapRootShowWhen(_: unknown, __: unknown, host: EditorHost) {
|
||||||
|
const selected = getCopilotSelectedElems(host);
|
||||||
|
|
||||||
|
return selected.length === 1 && isMindMapRoot(selected[0]);
|
||||||
|
}
|
@ -0,0 +1,532 @@
|
|||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import type {
|
||||||
|
AffineAIPanelWidget,
|
||||||
|
AIItemConfig,
|
||||||
|
EdgelessCopilotWidget,
|
||||||
|
EdgelessElementToolbarWidget,
|
||||||
|
EdgelessRootService,
|
||||||
|
MindmapElementModel,
|
||||||
|
ShapeElementModel,
|
||||||
|
SurfaceBlockModel,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import {
|
||||||
|
DeleteIcon,
|
||||||
|
EDGELESS_ELEMENT_TOOLBAR_WIDGET,
|
||||||
|
EmbedHtmlBlockSpec,
|
||||||
|
fitContent,
|
||||||
|
ImageBlockModel,
|
||||||
|
InsertBelowIcon,
|
||||||
|
NoteDisplayMode,
|
||||||
|
ResetIcon,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
|
import type { TemplateResult } from 'lit';
|
||||||
|
|
||||||
|
import { AIPenIcon, ChatWithAIIcon } from '../_common/icons.js';
|
||||||
|
import { insertFromMarkdown } from '../_common/markdown-utils.js';
|
||||||
|
import { getSurfaceElementFromEditor } from '../_common/selection-utils.js';
|
||||||
|
import { getAIPanel } from '../ai-panel.js';
|
||||||
|
import { AIProvider } from '../provider.js';
|
||||||
|
import { reportResponse } from '../utils/action-reporter.js';
|
||||||
|
import {
|
||||||
|
getEdgelessCopilotWidget,
|
||||||
|
getService,
|
||||||
|
isMindMapRoot,
|
||||||
|
} from '../utils/edgeless.js';
|
||||||
|
import { preprocessHtml } from '../utils/html.js';
|
||||||
|
import { fetchImageToFile } from '../utils/image.js';
|
||||||
|
import {
|
||||||
|
getCopilotSelectedElems,
|
||||||
|
getEdgelessRootFromEditor,
|
||||||
|
getEdgelessService,
|
||||||
|
} from '../utils/selection-utils.js';
|
||||||
|
import { EXCLUDING_INSERT_ACTIONS, generatingStages } from './consts.js';
|
||||||
|
import type { CtxRecord } from './types.js';
|
||||||
|
|
||||||
|
type FinishConfig = Exclude<
|
||||||
|
AffineAIPanelWidget['config'],
|
||||||
|
null
|
||||||
|
>['finishStateConfig'];
|
||||||
|
|
||||||
|
type ErrorConfig = Exclude<
|
||||||
|
AffineAIPanelWidget['config'],
|
||||||
|
null
|
||||||
|
>['errorStateConfig'];
|
||||||
|
|
||||||
|
export function getElementToolbar(
|
||||||
|
host: EditorHost
|
||||||
|
): EdgelessElementToolbarWidget {
|
||||||
|
const rootBlockId = host.doc.root?.id as string;
|
||||||
|
const elementToolbar = host.view.getWidget(
|
||||||
|
EDGELESS_ELEMENT_TOOLBAR_WIDGET,
|
||||||
|
rootBlockId
|
||||||
|
) as EdgelessElementToolbarWidget;
|
||||||
|
|
||||||
|
return elementToolbar;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTriggerEntry(host: EditorHost) {
|
||||||
|
const copilotWidget = getEdgelessCopilotWidget(host);
|
||||||
|
|
||||||
|
return copilotWidget.visible ? 'selection' : 'toolbar';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discard(
|
||||||
|
panel: AffineAIPanelWidget,
|
||||||
|
_: EdgelessCopilotWidget
|
||||||
|
): AIItemConfig {
|
||||||
|
return {
|
||||||
|
name: 'Discard',
|
||||||
|
icon: DeleteIcon,
|
||||||
|
showWhen: () => !!panel.answer,
|
||||||
|
handler: () => {
|
||||||
|
panel.discard();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function retry(panel: AffineAIPanelWidget): AIItemConfig {
|
||||||
|
return {
|
||||||
|
name: 'Retry',
|
||||||
|
icon: ResetIcon,
|
||||||
|
handler: () => {
|
||||||
|
reportResponse('result:retry');
|
||||||
|
panel.generate();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInsertResp<T extends keyof BlockSuitePresets.AIActions>(
|
||||||
|
id: T,
|
||||||
|
handler: (host: EditorHost, ctx: CtxRecord) => void,
|
||||||
|
host: EditorHost,
|
||||||
|
ctx: CtxRecord,
|
||||||
|
buttonText: string = 'Insert below'
|
||||||
|
): AIItemConfig {
|
||||||
|
return {
|
||||||
|
name: buttonText,
|
||||||
|
icon: InsertBelowIcon,
|
||||||
|
showWhen: () => {
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
return !EXCLUDING_INSERT_ACTIONS.includes(id) && !!panel.answer;
|
||||||
|
},
|
||||||
|
handler: () => {
|
||||||
|
reportResponse('result:insert');
|
||||||
|
handler(host, ctx);
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
panel.hide();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asCaption<T extends keyof BlockSuitePresets.AIActions>(
|
||||||
|
id: T,
|
||||||
|
host: EditorHost
|
||||||
|
): AIItemConfig {
|
||||||
|
return {
|
||||||
|
name: 'Use as caption',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: () => {
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
return id === 'generateCaption' && !!panel.answer;
|
||||||
|
},
|
||||||
|
handler: () => {
|
||||||
|
reportResponse('result:use-as-caption');
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
const caption = panel.answer;
|
||||||
|
if (!caption) return;
|
||||||
|
|
||||||
|
const selectedElements = getCopilotSelectedElems(host);
|
||||||
|
if (selectedElements.length !== 1) return;
|
||||||
|
|
||||||
|
const imageBlock = selectedElements[0];
|
||||||
|
if (!(imageBlock instanceof ImageBlockModel)) return;
|
||||||
|
|
||||||
|
host.doc.updateBlock(imageBlock, { caption });
|
||||||
|
panel.hide();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type MindMapNode = {
|
||||||
|
text: string;
|
||||||
|
children: MindMapNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultHandler = (host: EditorHost) => {
|
||||||
|
const doc = host.doc;
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
const edgelessCopilot = getEdgelessCopilotWidget(host);
|
||||||
|
const bounds = edgelessCopilot.determineInsertionBounds(800, 95);
|
||||||
|
|
||||||
|
doc.transact(() => {
|
||||||
|
assertExists(doc.root);
|
||||||
|
const noteBlockId = doc.addBlock(
|
||||||
|
'affine:note',
|
||||||
|
{
|
||||||
|
xywh: bounds.serialize(),
|
||||||
|
displayMode: NoteDisplayMode.EdgelessOnly,
|
||||||
|
},
|
||||||
|
doc.root.id
|
||||||
|
);
|
||||||
|
|
||||||
|
assertExists(panel.answer);
|
||||||
|
insertFromMarkdown(host, panel.answer, noteBlockId)
|
||||||
|
.then(() => {
|
||||||
|
const service = getService(host);
|
||||||
|
|
||||||
|
service.selection.set({
|
||||||
|
elements: [noteBlockId],
|
||||||
|
editing: false,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageHandler = (host: EditorHost) => {
|
||||||
|
const aiPanel = getAIPanel(host);
|
||||||
|
// `DataURL` or `URL`
|
||||||
|
const data = aiPanel.answer;
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
|
const edgelessCopilot = getEdgelessCopilotWidget(host);
|
||||||
|
const bounds = edgelessCopilot.determineInsertionBounds();
|
||||||
|
|
||||||
|
edgelessCopilot.hideCopilotPanel();
|
||||||
|
aiPanel.hide();
|
||||||
|
|
||||||
|
const filename = 'image';
|
||||||
|
const imageProxy = host.std.clipboard.configs.get('imageProxy');
|
||||||
|
|
||||||
|
fetchImageToFile(data, filename, imageProxy)
|
||||||
|
.then(img => {
|
||||||
|
if (!img) return;
|
||||||
|
|
||||||
|
const edgelessRoot = getEdgelessRootFromEditor(host);
|
||||||
|
const { minX, minY } = bounds;
|
||||||
|
const [x, y] = edgelessRoot.service.viewport.toViewCoord(minX, minY);
|
||||||
|
|
||||||
|
host.doc.transact(() => {
|
||||||
|
edgelessRoot.addImages([img], [x, y], true).catch(console.error);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const responses: {
|
||||||
|
[key in keyof Partial<BlockSuitePresets.AIActions>]: (
|
||||||
|
host: EditorHost,
|
||||||
|
ctx: CtxRecord
|
||||||
|
) => void;
|
||||||
|
} = {
|
||||||
|
expandMindmap: (host, ctx) => {
|
||||||
|
const [surface] = host.doc.getBlockByFlavour(
|
||||||
|
'affine:surface'
|
||||||
|
) as SurfaceBlockModel[];
|
||||||
|
|
||||||
|
const elements = ctx.get()[
|
||||||
|
'selectedElements'
|
||||||
|
] as BlockSuite.EdgelessModelType[];
|
||||||
|
const data = ctx.get() as {
|
||||||
|
node: MindMapNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
getAIPanel(host).hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
const mindmap = elements[0].group as MindmapElementModel;
|
||||||
|
|
||||||
|
if (!data?.node) return;
|
||||||
|
|
||||||
|
if (data.node.children) {
|
||||||
|
data.node.children.forEach(childTree => {
|
||||||
|
mindmap.addTree(elements[0].id, childTree);
|
||||||
|
});
|
||||||
|
|
||||||
|
const subtree = mindmap.getNode(elements[0].id);
|
||||||
|
|
||||||
|
if (!subtree) return;
|
||||||
|
|
||||||
|
surface.doc.transact(() => {
|
||||||
|
const updateNodeSize = (node: typeof subtree) => {
|
||||||
|
fitContent(node.element as ShapeElementModel);
|
||||||
|
|
||||||
|
node.children.forEach(child => {
|
||||||
|
updateNodeSize(child);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateNodeSize(subtree);
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const edgelessService = getEdgelessService(host);
|
||||||
|
|
||||||
|
edgelessService.selection.set({
|
||||||
|
elements: [subtree.element.id],
|
||||||
|
editing: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
brainstormMindmap: (host, ctx) => {
|
||||||
|
const aiPanel = getAIPanel(host);
|
||||||
|
const edgelessService = getEdgelessService(host);
|
||||||
|
const edgelessCopilot = getEdgelessCopilotWidget(host);
|
||||||
|
const selectionRect = edgelessCopilot.selectionModelRect;
|
||||||
|
const [surface] = host.doc.getBlockByFlavour(
|
||||||
|
'affine:surface'
|
||||||
|
) as SurfaceBlockModel[];
|
||||||
|
const elements = ctx.get()[
|
||||||
|
'selectedElements'
|
||||||
|
] as BlockSuite.EdgelessModelType[];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const data = ctx.get() as any;
|
||||||
|
let newGenerated = true;
|
||||||
|
|
||||||
|
// This means regenerate
|
||||||
|
if (isMindMapRoot(elements[0])) {
|
||||||
|
const mindmap = elements[0].group as MindmapElementModel;
|
||||||
|
const xywh = mindmap.tree.element.xywh;
|
||||||
|
|
||||||
|
surface.removeElement(mindmap.id);
|
||||||
|
|
||||||
|
if (data.node) {
|
||||||
|
data.node.xywh = xywh;
|
||||||
|
newGenerated = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgelessCopilot.hideCopilotPanel();
|
||||||
|
aiPanel.hide();
|
||||||
|
|
||||||
|
const mindmapId = surface.addElement({
|
||||||
|
type: 'mindmap',
|
||||||
|
children: data.node,
|
||||||
|
style: data.style,
|
||||||
|
});
|
||||||
|
const mindmap = surface.getElementById(mindmapId) as MindmapElementModel;
|
||||||
|
|
||||||
|
host.doc.transact(() => {
|
||||||
|
mindmap.childElements.forEach(shape => {
|
||||||
|
fitContent(shape as ShapeElementModel);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
edgelessService.telemetryService?.track('CanvasElementAdded', {
|
||||||
|
control: 'ai',
|
||||||
|
page: 'whiteboard editor',
|
||||||
|
module: 'toolbar',
|
||||||
|
segment: 'toolbar',
|
||||||
|
type: 'mindmap',
|
||||||
|
});
|
||||||
|
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (newGenerated && selectionRect) {
|
||||||
|
mindmap.moveTo([
|
||||||
|
selectionRect.x,
|
||||||
|
selectionRect.y,
|
||||||
|
selectionRect.width,
|
||||||
|
selectionRect.height,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// This is a workaround to make sure mindmap and other microtask are done
|
||||||
|
setTimeout(() => {
|
||||||
|
edgelessService.viewport.setViewportByBound(
|
||||||
|
mindmap.elementBound,
|
||||||
|
[20, 20, 20, 20],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
edgelessService.selection.set({
|
||||||
|
elements: [mindmap.tree.element.id],
|
||||||
|
editing: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
makeItReal: (host, ctx) => {
|
||||||
|
const aiPanel = getAIPanel(host);
|
||||||
|
let html = aiPanel.answer;
|
||||||
|
if (!html) return;
|
||||||
|
html = preprocessHtml(html);
|
||||||
|
|
||||||
|
const edgelessCopilot = getEdgelessCopilotWidget(host);
|
||||||
|
const [surface] = host.doc.getBlockByFlavour(
|
||||||
|
'affine:surface'
|
||||||
|
) as SurfaceBlockModel[];
|
||||||
|
|
||||||
|
const data = ctx.get();
|
||||||
|
const bounds = edgelessCopilot.determineInsertionBounds(
|
||||||
|
(data['width'] as number) || 800,
|
||||||
|
(data['height'] as number) || 600
|
||||||
|
);
|
||||||
|
|
||||||
|
edgelessCopilot.hideCopilotPanel();
|
||||||
|
aiPanel.hide();
|
||||||
|
|
||||||
|
const edgelessRoot = getEdgelessRootFromEditor(host);
|
||||||
|
|
||||||
|
host.doc.transact(() => {
|
||||||
|
edgelessRoot.doc.addBlock(
|
||||||
|
EmbedHtmlBlockSpec.schema.model.flavour as 'affine:embed-html',
|
||||||
|
{
|
||||||
|
html,
|
||||||
|
design: 'ai:makeItReal', // as tag
|
||||||
|
xywh: bounds.serialize(),
|
||||||
|
},
|
||||||
|
surface.id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
createSlides: (host, ctx) => {
|
||||||
|
const data = ctx.get();
|
||||||
|
const contents = data.contents as unknown[];
|
||||||
|
if (!contents) return;
|
||||||
|
const images = data.images as { url: string; id: string }[][];
|
||||||
|
const service = host.spec.getService<EdgelessRootService>('affine:page');
|
||||||
|
|
||||||
|
(async function () {
|
||||||
|
for (let i = 0; i < contents.length - 1; i++) {
|
||||||
|
const image = images[i];
|
||||||
|
const content = contents[i];
|
||||||
|
const job = service.createTemplateJob('template');
|
||||||
|
await Promise.all(
|
||||||
|
image.map(({ id, url }) =>
|
||||||
|
fetch(url)
|
||||||
|
.then(res => res.blob())
|
||||||
|
.then(blob => job.job.assets.set(id, blob))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
await job.insertTemplate(content);
|
||||||
|
getSurfaceElementFromEditor(host).refresh();
|
||||||
|
}
|
||||||
|
})().catch(console.error);
|
||||||
|
},
|
||||||
|
createImage: imageHandler,
|
||||||
|
processImage: imageHandler,
|
||||||
|
filterImage: imageHandler,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getButtonText: {
|
||||||
|
[key in keyof Partial<BlockSuitePresets.AIActions>]: (
|
||||||
|
variants?: Omit<
|
||||||
|
Parameters<BlockSuitePresets.AIActions[key]>[0],
|
||||||
|
keyof BlockSuitePresets.AITextActionOptions
|
||||||
|
>
|
||||||
|
) => string | undefined;
|
||||||
|
} = {
|
||||||
|
brainstormMindmap: variants => {
|
||||||
|
return variants?.regenerate ? 'Replace' : undefined;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getInsertAndReplaceHandler<
|
||||||
|
T extends keyof BlockSuitePresets.AIActions,
|
||||||
|
>(
|
||||||
|
id: T,
|
||||||
|
host: EditorHost,
|
||||||
|
ctx: CtxRecord,
|
||||||
|
variants?: Omit<
|
||||||
|
Parameters<BlockSuitePresets.AIActions[T]>[0],
|
||||||
|
keyof BlockSuitePresets.AITextActionOptions
|
||||||
|
>
|
||||||
|
) {
|
||||||
|
const handler = responses[id] ?? defaultHandler;
|
||||||
|
const buttonText = getButtonText[id]?.(variants) ?? undefined;
|
||||||
|
|
||||||
|
return createInsertResp(id, handler, host, ctx, buttonText);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actionToResponse<T extends keyof BlockSuitePresets.AIActions>(
|
||||||
|
id: T,
|
||||||
|
host: EditorHost,
|
||||||
|
ctx: CtxRecord,
|
||||||
|
variants?: Omit<
|
||||||
|
Parameters<BlockSuitePresets.AIActions[T]>[0],
|
||||||
|
keyof BlockSuitePresets.AITextActionOptions
|
||||||
|
>
|
||||||
|
): FinishConfig {
|
||||||
|
return {
|
||||||
|
responses: [
|
||||||
|
{
|
||||||
|
name: 'Response',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Continue in chat',
|
||||||
|
icon: ChatWithAIIcon,
|
||||||
|
handler: () => {
|
||||||
|
reportResponse('result:continue-in-chat');
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
AIProvider.slots.requestContinueInChat.emit({
|
||||||
|
host: host,
|
||||||
|
show: true,
|
||||||
|
});
|
||||||
|
panel.hide();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getInsertAndReplaceHandler(id, host, ctx, variants),
|
||||||
|
asCaption(id, host),
|
||||||
|
retry(getAIPanel(host)),
|
||||||
|
discard(getAIPanel(host), getEdgelessCopilotWidget(host)),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actionToGenerating<T extends keyof BlockSuitePresets.AIActions>(
|
||||||
|
id: T,
|
||||||
|
generatingIcon: TemplateResult<1>
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
generatingIcon,
|
||||||
|
stages: generatingStages[id],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actionToErrorResponse<
|
||||||
|
T extends keyof BlockSuitePresets.AIActions,
|
||||||
|
>(
|
||||||
|
panel: AffineAIPanelWidget,
|
||||||
|
id: T,
|
||||||
|
host: EditorHost,
|
||||||
|
ctx: CtxRecord,
|
||||||
|
variants?: Omit<
|
||||||
|
Parameters<BlockSuitePresets.AIActions[T]>[0],
|
||||||
|
keyof BlockSuitePresets.AITextActionOptions
|
||||||
|
>
|
||||||
|
): ErrorConfig {
|
||||||
|
return {
|
||||||
|
upgrade: () => {
|
||||||
|
AIProvider.slots.requestUpgradePlan.emit({ host: panel.host });
|
||||||
|
panel.hide();
|
||||||
|
},
|
||||||
|
login: () => {
|
||||||
|
AIProvider.slots.requestLogin.emit({ host: panel.host });
|
||||||
|
panel.hide();
|
||||||
|
},
|
||||||
|
cancel: () => {
|
||||||
|
panel.hide();
|
||||||
|
},
|
||||||
|
responses: [
|
||||||
|
{
|
||||||
|
name: 'Response',
|
||||||
|
items: [getInsertAndReplaceHandler(id, host, ctx, variants)],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
items: [
|
||||||
|
retry(getAIPanel(host)),
|
||||||
|
discard(getAIPanel(host), getEdgelessCopilotWidget(host)),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,2 @@
|
|||||||
|
export * from './doc-handler.js';
|
||||||
|
export * from './types.js';
|
@ -0,0 +1,256 @@
|
|||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import type { BlockModel } from '@blocksuite/store';
|
||||||
|
|
||||||
|
export const translateLangs = [
|
||||||
|
'English',
|
||||||
|
'Spanish',
|
||||||
|
'German',
|
||||||
|
'French',
|
||||||
|
'Italian',
|
||||||
|
'Simplified Chinese',
|
||||||
|
'Traditional Chinese',
|
||||||
|
'Japanese',
|
||||||
|
'Russian',
|
||||||
|
'Korean',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const textTones = [
|
||||||
|
'Professional',
|
||||||
|
'Informal',
|
||||||
|
'Friendly',
|
||||||
|
'Critical',
|
||||||
|
'Humorous',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const imageFilterStyles = [
|
||||||
|
'Clay style',
|
||||||
|
'Sketch style',
|
||||||
|
'Anime style',
|
||||||
|
'Pixel style',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const imageProcessingTypes = [
|
||||||
|
'Clearer',
|
||||||
|
'Remove background',
|
||||||
|
'Convert to sticker',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type CtxRecord = {
|
||||||
|
get(): Record<string, unknown>;
|
||||||
|
set(data: Record<string, unknown>): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
|
namespace BlockSuitePresets {
|
||||||
|
type TrackerControl =
|
||||||
|
| 'format-bar'
|
||||||
|
| 'slash-menu'
|
||||||
|
| 'chat-send'
|
||||||
|
| 'block-action-bar';
|
||||||
|
|
||||||
|
type TrackerWhere = 'chat-panel' | 'inline-chat-panel' | 'ai-panel';
|
||||||
|
|
||||||
|
interface TrackerOptions {
|
||||||
|
control: TrackerControl;
|
||||||
|
where: TrackerWhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AITextActionOptions {
|
||||||
|
input?: string;
|
||||||
|
stream?: boolean;
|
||||||
|
attachments?: (string | File | Blob)[]; // blob could only be strings for the moments (url or data urls)
|
||||||
|
signal?: AbortSignal;
|
||||||
|
retry?: boolean;
|
||||||
|
|
||||||
|
// action's context
|
||||||
|
docId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
|
||||||
|
// internal context
|
||||||
|
host: EditorHost;
|
||||||
|
models?: (BlockModel | BlockSuite.SurfaceElementModelType)[];
|
||||||
|
control: TrackerControl;
|
||||||
|
where: TrackerWhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AIImageActionOptions extends AITextActionOptions {
|
||||||
|
content?: string;
|
||||||
|
seed?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterImageOptions extends AIImageActionOptions {
|
||||||
|
style: (typeof imageFilterStyles)[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProcessImageOptions extends AIImageActionOptions {
|
||||||
|
type: (typeof imageProcessingTypes)[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextStream = {
|
||||||
|
[Symbol.asyncIterator](): AsyncIterableIterator<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AIActionTextResponse<T extends AITextActionOptions> =
|
||||||
|
T['stream'] extends true ? TextStream : Promise<string>;
|
||||||
|
|
||||||
|
interface TranslateOptions extends AITextActionOptions {
|
||||||
|
lang: (typeof translateLangs)[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChangeToneOptions extends AITextActionOptions {
|
||||||
|
tone: (typeof textTones)[number];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExpandMindMap extends AITextActionOptions {
|
||||||
|
mindmap: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BrainstormMindMap extends AITextActionOptions {
|
||||||
|
regenerate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AIActions {
|
||||||
|
// chat is a bit special because it's has a internally maintained session
|
||||||
|
chat<T extends AITextActionOptions>(options: T): AIActionTextResponse<T>;
|
||||||
|
|
||||||
|
summary<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
improveWriting<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
improveGrammar<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
fixSpelling<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
createHeadings<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
makeLonger<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
makeShorter<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
continueWriting<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
checkCodeErrors<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
explainCode<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
writeArticle<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
writeTwitterPost<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
writePoem<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
writeBlogPost<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
brainstorm<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
writeOutline<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
|
||||||
|
explainImage<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
|
||||||
|
findActions<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
|
||||||
|
// mindmap
|
||||||
|
brainstormMindmap<T extends BrainstormMindMap>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
expandMindmap<T extends ExpandMindMap>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
|
||||||
|
// presentation
|
||||||
|
createSlides<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
|
||||||
|
// explain this
|
||||||
|
explain<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
|
||||||
|
// actions with variants
|
||||||
|
translate<T extends TranslateOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
changeTone<T extends ChangeToneOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
|
||||||
|
// make it real, image to text
|
||||||
|
makeItReal<T extends AIImageActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
createImage<T extends AIImageActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
processImage<T extends ProcessImageOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
filterImage<T extends FilterImageOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
generateCaption<T extends AITextActionOptions>(
|
||||||
|
options: T
|
||||||
|
): AIActionTextResponse<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// todo: should be refactored to get rid of implement details (like messages, action, role, etc.)
|
||||||
|
interface AIHistory {
|
||||||
|
sessionId: string;
|
||||||
|
tokens: number;
|
||||||
|
action: string;
|
||||||
|
createdAt: string;
|
||||||
|
messages: {
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AIHistoryService {
|
||||||
|
// non chat histories
|
||||||
|
actions: (
|
||||||
|
workspaceId: string,
|
||||||
|
docId?: string
|
||||||
|
) => Promise<AIHistory[] | undefined>;
|
||||||
|
chats: (
|
||||||
|
workspaceId: string,
|
||||||
|
docId?: string
|
||||||
|
) => Promise<AIHistory[] | undefined>;
|
||||||
|
cleanup: (
|
||||||
|
workspaceId: string,
|
||||||
|
docId: string,
|
||||||
|
sessionIds: string[]
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AIPhotoEngineService {
|
||||||
|
searchImages(options: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
query: string;
|
||||||
|
}): Promise<string[]>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
394
packages/frontend/core/src/blocksuite/presets/ai/ai-panel.ts
Normal file
394
packages/frontend/core/src/blocksuite/presets/ai/ai-panel.ts
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import {
|
||||||
|
AFFINE_AI_PANEL_WIDGET,
|
||||||
|
AffineAIPanelWidget,
|
||||||
|
type AffineAIPanelWidgetConfig,
|
||||||
|
type AIItemConfig,
|
||||||
|
Bound,
|
||||||
|
ImageBlockModel,
|
||||||
|
isInsideEdgelessEditor,
|
||||||
|
matchFlavours,
|
||||||
|
NoteDisplayMode,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
|
import type { TemplateResult } from 'lit';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AIPenIcon,
|
||||||
|
AIStarIconWithAnimation,
|
||||||
|
ChatWithAIIcon,
|
||||||
|
CreateIcon,
|
||||||
|
DiscardIcon,
|
||||||
|
InsertBelowIcon,
|
||||||
|
InsertTopIcon,
|
||||||
|
ReplaceIcon,
|
||||||
|
RetryIcon,
|
||||||
|
} from './_common/icons.js';
|
||||||
|
import { INSERT_ABOVE_ACTIONS } from './actions/consts.js';
|
||||||
|
import { createTextRenderer } from './messages/text.js';
|
||||||
|
import { AIProvider } from './provider.js';
|
||||||
|
import { reportResponse } from './utils/action-reporter.js';
|
||||||
|
import { findNoteBlockModel, getService } from './utils/edgeless.js';
|
||||||
|
import {
|
||||||
|
copyTextAnswer,
|
||||||
|
insertAbove,
|
||||||
|
insertBelow,
|
||||||
|
replace,
|
||||||
|
} from './utils/editor-actions.js';
|
||||||
|
import { insertFromMarkdown } from './utils/markdown-utils.js';
|
||||||
|
import { getSelections } from './utils/selection-utils.js';
|
||||||
|
|
||||||
|
function getSelection(host: EditorHost) {
|
||||||
|
const textSelection = host.selection.find('text');
|
||||||
|
const mode = textSelection ? 'flat' : 'highest';
|
||||||
|
const { selectedBlocks } = getSelections(host, mode);
|
||||||
|
assertExists(selectedBlocks);
|
||||||
|
const length = selectedBlocks.length;
|
||||||
|
const firstBlock = selectedBlocks[0];
|
||||||
|
const lastBlock = selectedBlocks[length - 1];
|
||||||
|
const selectedModels = selectedBlocks.map(block => block.model);
|
||||||
|
return {
|
||||||
|
textSelection,
|
||||||
|
selectedModels,
|
||||||
|
firstBlock,
|
||||||
|
lastBlock,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function asCaption<T extends keyof BlockSuitePresets.AIActions>(
|
||||||
|
host: EditorHost,
|
||||||
|
id?: T
|
||||||
|
): AIItemConfig {
|
||||||
|
return {
|
||||||
|
name: 'Use as caption',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: () => {
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
return id === 'generateCaption' && !!panel.answer;
|
||||||
|
},
|
||||||
|
handler: () => {
|
||||||
|
reportResponse('result:use-as-caption');
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
const caption = panel.answer;
|
||||||
|
if (!caption) return;
|
||||||
|
|
||||||
|
const { selectedBlocks } = getSelections(host);
|
||||||
|
if (!selectedBlocks || selectedBlocks.length !== 1) return;
|
||||||
|
|
||||||
|
const imageBlock = selectedBlocks[0].model;
|
||||||
|
if (!(imageBlock instanceof ImageBlockModel)) return;
|
||||||
|
|
||||||
|
host.doc.updateBlock(imageBlock, { caption });
|
||||||
|
panel.hide();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createNewNote(host: EditorHost): AIItemConfig {
|
||||||
|
return {
|
||||||
|
name: 'Create new note',
|
||||||
|
icon: CreateIcon,
|
||||||
|
showWhen: () => {
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
return !!panel.answer && isInsideEdgelessEditor(host);
|
||||||
|
},
|
||||||
|
handler: () => {
|
||||||
|
reportResponse('result:add-note');
|
||||||
|
// get the note block
|
||||||
|
const { selectedBlocks } = getSelections(host);
|
||||||
|
if (!selectedBlocks || !selectedBlocks.length) return;
|
||||||
|
const firstBlock = selectedBlocks[0];
|
||||||
|
const noteModel = findNoteBlockModel(firstBlock);
|
||||||
|
if (!noteModel) return;
|
||||||
|
|
||||||
|
// create a new note block at the left of the current note block
|
||||||
|
const bound = Bound.deserialize(noteModel.xywh);
|
||||||
|
const newBound = new Bound(bound.x - bound.w - 20, bound.y, bound.w, 72);
|
||||||
|
const doc = host.doc;
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
const service = getService(host);
|
||||||
|
doc.transact(() => {
|
||||||
|
assertExists(doc.root);
|
||||||
|
const noteBlockId = doc.addBlock(
|
||||||
|
'affine:note',
|
||||||
|
{
|
||||||
|
xywh: newBound.serialize(),
|
||||||
|
displayMode: NoteDisplayMode.EdgelessOnly,
|
||||||
|
index: service.generateIndex('affine:note'),
|
||||||
|
},
|
||||||
|
doc.root.id
|
||||||
|
);
|
||||||
|
|
||||||
|
assertExists(panel.answer);
|
||||||
|
insertFromMarkdown(host, panel.answer, noteBlockId)
|
||||||
|
.then(() => {
|
||||||
|
service.selection.set({
|
||||||
|
elements: [noteBlockId],
|
||||||
|
editing: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// set the viewport to show the new note block and original note block
|
||||||
|
const newNote = doc.getBlock(noteBlockId)?.model;
|
||||||
|
if (!newNote || !matchFlavours(newNote, ['affine:note'])) return;
|
||||||
|
const newNoteBound = Bound.deserialize(newNote.xywh);
|
||||||
|
|
||||||
|
const bounds = [bound, newNoteBound];
|
||||||
|
const { zoom, centerX, centerY } = service.getFitToScreenData(
|
||||||
|
[20, 20, 20, 20],
|
||||||
|
bounds
|
||||||
|
);
|
||||||
|
service.viewport.setViewport(zoom, [centerX, centerY]);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// hide the panel
|
||||||
|
panel.hide();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceWithAnswer(panel: AffineAIPanelWidget) {
|
||||||
|
const { host } = panel;
|
||||||
|
const selection = getSelection(host);
|
||||||
|
if (!selection || !panel.answer) return;
|
||||||
|
|
||||||
|
const { textSelection, firstBlock, selectedModels } = selection;
|
||||||
|
await replace(host, panel.answer, firstBlock, selectedModels, textSelection);
|
||||||
|
|
||||||
|
panel.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertAnswerBelow(panel: AffineAIPanelWidget) {
|
||||||
|
const { host } = panel;
|
||||||
|
const selection = getSelection(host);
|
||||||
|
|
||||||
|
if (!selection || !panel.answer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lastBlock } = selection;
|
||||||
|
await insertBelow(host, panel.answer, lastBlock);
|
||||||
|
panel.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insertAnswerAbove(panel: AffineAIPanelWidget) {
|
||||||
|
const { host } = panel;
|
||||||
|
const selection = getSelection(host);
|
||||||
|
if (!selection || !panel.answer) return;
|
||||||
|
|
||||||
|
const { firstBlock } = selection;
|
||||||
|
await insertAbove(host, panel.answer, firstBlock);
|
||||||
|
panel.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTextResponseConfig<
|
||||||
|
T extends keyof BlockSuitePresets.AIActions,
|
||||||
|
>(panel: AffineAIPanelWidget, id?: T) {
|
||||||
|
const host = panel.host;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'Response',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Insert below',
|
||||||
|
icon: InsertBelowIcon,
|
||||||
|
showWhen: () =>
|
||||||
|
!!panel.answer && (!id || !INSERT_ABOVE_ACTIONS.includes(id)),
|
||||||
|
handler: () => {
|
||||||
|
reportResponse('result:insert');
|
||||||
|
insertAnswerBelow(panel).catch(console.error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Insert above',
|
||||||
|
icon: InsertTopIcon,
|
||||||
|
showWhen: () =>
|
||||||
|
!!panel.answer && !!id && INSERT_ABOVE_ACTIONS.includes(id),
|
||||||
|
handler: () => {
|
||||||
|
reportResponse('result:insert');
|
||||||
|
insertAnswerAbove(panel).catch(console.error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
asCaption(host, id),
|
||||||
|
{
|
||||||
|
name: 'Replace selection',
|
||||||
|
icon: ReplaceIcon,
|
||||||
|
showWhen: () => !!panel.answer,
|
||||||
|
handler: () => {
|
||||||
|
reportResponse('result:replace');
|
||||||
|
replaceWithAnswer(panel).catch(console.error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createNewNote(host),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Continue in chat',
|
||||||
|
icon: ChatWithAIIcon,
|
||||||
|
handler: () => {
|
||||||
|
reportResponse('result:continue-in-chat');
|
||||||
|
AIProvider.slots.requestContinueInChat.emit({
|
||||||
|
host: panel.host,
|
||||||
|
show: true,
|
||||||
|
});
|
||||||
|
panel.hide();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Regenerate',
|
||||||
|
icon: RetryIcon,
|
||||||
|
handler: () => {
|
||||||
|
reportResponse('result:retry');
|
||||||
|
panel.generate();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Discard',
|
||||||
|
icon: DiscardIcon,
|
||||||
|
handler: () => {
|
||||||
|
panel.discard();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildErrorResponseConfig<
|
||||||
|
T extends keyof BlockSuitePresets.AIActions,
|
||||||
|
>(panel: AffineAIPanelWidget, id?: T) {
|
||||||
|
const host = panel.host;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'Response',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Replace selection',
|
||||||
|
icon: ReplaceIcon,
|
||||||
|
showWhen: () => !!panel.answer,
|
||||||
|
handler: () => {
|
||||||
|
replaceWithAnswer(panel).catch(console.error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Insert below',
|
||||||
|
icon: InsertBelowIcon,
|
||||||
|
showWhen: () =>
|
||||||
|
!!panel.answer && (!id || !INSERT_ABOVE_ACTIONS.includes(id)),
|
||||||
|
handler: () => {
|
||||||
|
insertAnswerBelow(panel).catch(console.error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Insert above',
|
||||||
|
icon: InsertTopIcon,
|
||||||
|
showWhen: () =>
|
||||||
|
!!panel.answer && !!id && INSERT_ABOVE_ACTIONS.includes(id),
|
||||||
|
handler: () => {
|
||||||
|
reportResponse('result:insert');
|
||||||
|
insertAnswerAbove(panel).catch(console.error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
asCaption(host, id),
|
||||||
|
createNewNote(host),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Retry',
|
||||||
|
icon: RetryIcon,
|
||||||
|
showWhen: () => true,
|
||||||
|
handler: () => {
|
||||||
|
reportResponse('result:retry');
|
||||||
|
panel.generate();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Discard',
|
||||||
|
icon: DiscardIcon,
|
||||||
|
showWhen: () => !!panel.answer,
|
||||||
|
handler: () => {
|
||||||
|
panel.discard();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFinishConfig<T extends keyof BlockSuitePresets.AIActions>(
|
||||||
|
panel: AffineAIPanelWidget,
|
||||||
|
id?: T
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
responses: buildTextResponseConfig(panel, id),
|
||||||
|
actions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildErrorConfig<T extends keyof BlockSuitePresets.AIActions>(
|
||||||
|
panel: AffineAIPanelWidget,
|
||||||
|
id?: T
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
upgrade: () => {
|
||||||
|
AIProvider.slots.requestUpgradePlan.emit({ host: panel.host });
|
||||||
|
panel.hide();
|
||||||
|
},
|
||||||
|
login: () => {
|
||||||
|
AIProvider.slots.requestLogin.emit({ host: panel.host });
|
||||||
|
panel.hide();
|
||||||
|
},
|
||||||
|
cancel: () => {
|
||||||
|
panel.hide();
|
||||||
|
},
|
||||||
|
responses: buildErrorResponseConfig(panel, id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildGeneratingConfig(generatingIcon?: TemplateResult<1>) {
|
||||||
|
return {
|
||||||
|
generatingIcon: generatingIcon ?? AIStarIconWithAnimation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCopyConfig(panel: AffineAIPanelWidget) {
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
onCopy: () => {
|
||||||
|
return copyTextAnswer(panel);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAIPanelConfig(
|
||||||
|
panel: AffineAIPanelWidget
|
||||||
|
): AffineAIPanelWidgetConfig {
|
||||||
|
return {
|
||||||
|
answerRenderer: createTextRenderer(panel.host, { maxHeight: 320 }),
|
||||||
|
finishStateConfig: buildFinishConfig(panel),
|
||||||
|
generatingStateConfig: buildGeneratingConfig(),
|
||||||
|
errorStateConfig: buildErrorConfig(panel),
|
||||||
|
copy: buildCopyConfig(panel),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAIPanel = (host: EditorHost): AffineAIPanelWidget => {
|
||||||
|
const rootBlockId = host.doc.root?.id;
|
||||||
|
assertExists(rootBlockId);
|
||||||
|
const aiPanel = host.view.getWidget(AFFINE_AI_PANEL_WIDGET, rootBlockId);
|
||||||
|
assertExists(aiPanel);
|
||||||
|
if (!(aiPanel instanceof AffineAIPanelWidget)) {
|
||||||
|
throw new Error('AI panel not found');
|
||||||
|
}
|
||||||
|
return aiPanel;
|
||||||
|
};
|
156
packages/frontend/core/src/blocksuite/presets/ai/ai-spec.ts
Normal file
156
packages/frontend/core/src/blocksuite/presets/ai/ai-spec.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import type { BlockSpec } from '@blocksuite/block-std';
|
||||||
|
import {
|
||||||
|
AFFINE_AI_PANEL_WIDGET,
|
||||||
|
AFFINE_EDGELESS_COPILOT_WIDGET,
|
||||||
|
AffineAIPanelWidget,
|
||||||
|
AffineCodeToolbarWidget,
|
||||||
|
AffineFormatBarWidget,
|
||||||
|
AffineImageToolbarWidget,
|
||||||
|
AffineSlashMenuWidget,
|
||||||
|
CodeBlockSpec,
|
||||||
|
EdgelessCopilotWidget,
|
||||||
|
EdgelessElementToolbarWidget,
|
||||||
|
EdgelessRootBlockSpec,
|
||||||
|
ImageBlockSpec,
|
||||||
|
PageRootBlockSpec,
|
||||||
|
ParagraphBlockService,
|
||||||
|
ParagraphBlockSpec,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { assertInstanceOf } from '@blocksuite/global/utils';
|
||||||
|
import { literal, unsafeStatic } from 'lit/static-html.js';
|
||||||
|
|
||||||
|
import { buildAIPanelConfig } from './ai-panel.js';
|
||||||
|
import { setupCodeToolbarEntry } from './entries/code-toolbar/setup-code-toolbar.js';
|
||||||
|
import {
|
||||||
|
setupEdgelessCopilot,
|
||||||
|
setupEdgelessElementToolbarEntry,
|
||||||
|
} from './entries/edgeless/index.js';
|
||||||
|
import { setupFormatBarEntry } from './entries/format-bar/setup-format-bar.js';
|
||||||
|
import { setupImageToolbarEntry } from './entries/image-toolbar/setup-image-toolbar.js';
|
||||||
|
import { setupSlashMenuEntry } from './entries/slash-menu/setup-slash-menu.js';
|
||||||
|
import { setupSpaceEntry } from './entries/space/setup-space.js';
|
||||||
|
|
||||||
|
export const AIPageRootBlockSpec: BlockSpec = {
|
||||||
|
...PageRootBlockSpec,
|
||||||
|
view: {
|
||||||
|
...PageRootBlockSpec.view,
|
||||||
|
widgets: {
|
||||||
|
...PageRootBlockSpec.view.widgets,
|
||||||
|
[AFFINE_AI_PANEL_WIDGET]: literal`${unsafeStatic(
|
||||||
|
AFFINE_AI_PANEL_WIDGET
|
||||||
|
)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup: (slots, disposableGroup) => {
|
||||||
|
PageRootBlockSpec.setup?.(slots, disposableGroup);
|
||||||
|
disposableGroup.add(
|
||||||
|
slots.widgetConnected.on(view => {
|
||||||
|
if (view.component instanceof AffineAIPanelWidget) {
|
||||||
|
view.component.style.width = '630px';
|
||||||
|
view.component.config = buildAIPanelConfig(view.component);
|
||||||
|
setupSpaceEntry(view.component);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view.component instanceof AffineFormatBarWidget) {
|
||||||
|
setupFormatBarEntry(view.component);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view.component instanceof AffineSlashMenuWidget) {
|
||||||
|
setupSlashMenuEntry(view.component);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AIEdgelessRootBlockSpec: BlockSpec = {
|
||||||
|
...EdgelessRootBlockSpec,
|
||||||
|
view: {
|
||||||
|
...EdgelessRootBlockSpec.view,
|
||||||
|
widgets: {
|
||||||
|
...EdgelessRootBlockSpec.view.widgets,
|
||||||
|
[AFFINE_EDGELESS_COPILOT_WIDGET]: literal`${unsafeStatic(
|
||||||
|
AFFINE_EDGELESS_COPILOT_WIDGET
|
||||||
|
)}`,
|
||||||
|
[AFFINE_AI_PANEL_WIDGET]: literal`${unsafeStatic(
|
||||||
|
AFFINE_AI_PANEL_WIDGET
|
||||||
|
)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(slots, disposableGroup) {
|
||||||
|
EdgelessRootBlockSpec.setup?.(slots, disposableGroup);
|
||||||
|
slots.widgetConnected.on(view => {
|
||||||
|
if (view.component instanceof AffineAIPanelWidget) {
|
||||||
|
view.component.style.width = '430px';
|
||||||
|
view.component.config = buildAIPanelConfig(view.component);
|
||||||
|
setupSpaceEntry(view.component);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view.component instanceof EdgelessCopilotWidget) {
|
||||||
|
setupEdgelessCopilot(view.component);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view.component instanceof EdgelessElementToolbarWidget) {
|
||||||
|
setupEdgelessElementToolbarEntry(view.component);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view.component instanceof AffineFormatBarWidget) {
|
||||||
|
setupFormatBarEntry(view.component);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view.component instanceof AffineSlashMenuWidget) {
|
||||||
|
setupSlashMenuEntry(view.component);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AIParagraphBlockSpec: BlockSpec = {
|
||||||
|
...ParagraphBlockSpec,
|
||||||
|
setup(slots, disposableGroup) {
|
||||||
|
ParagraphBlockSpec.setup?.(slots, disposableGroup);
|
||||||
|
slots.mounted.on(({ service }) => {
|
||||||
|
assertInstanceOf(service, ParagraphBlockService);
|
||||||
|
service.placeholderGenerator = model => {
|
||||||
|
if (model.type === 'text') {
|
||||||
|
return "Type '/' for commands, 'space' for AI";
|
||||||
|
}
|
||||||
|
|
||||||
|
const placeholders = {
|
||||||
|
h1: 'Heading 1',
|
||||||
|
h2: 'Heading 2',
|
||||||
|
h3: 'Heading 3',
|
||||||
|
h4: 'Heading 4',
|
||||||
|
h5: 'Heading 5',
|
||||||
|
h6: 'Heading 6',
|
||||||
|
quote: '',
|
||||||
|
};
|
||||||
|
return placeholders[model.type];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AICodeBlockSpec: BlockSpec = {
|
||||||
|
...CodeBlockSpec,
|
||||||
|
setup(slots, disposableGroup) {
|
||||||
|
CodeBlockSpec.setup?.(slots, disposableGroup);
|
||||||
|
slots.widgetConnected.on(view => {
|
||||||
|
if (view.component instanceof AffineCodeToolbarWidget) {
|
||||||
|
setupCodeToolbarEntry(view.component);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AIImageBlockSpec: BlockSpec = {
|
||||||
|
...ImageBlockSpec,
|
||||||
|
setup(slots, disposableGroup) {
|
||||||
|
ImageBlockSpec.setup?.(slots, disposableGroup);
|
||||||
|
slots.widgetConnected.on(view => {
|
||||||
|
if (view.component instanceof AffineImageToolbarWidget) {
|
||||||
|
setupImageToolbarEntry(view.component);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
@ -0,0 +1,166 @@
|
|||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import { WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import { css, html, LitElement, nothing, type TemplateResult } from 'lit';
|
||||||
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
AIChangeToneIcon,
|
||||||
|
AIDoneIcon,
|
||||||
|
AIExpandMindMapIcon,
|
||||||
|
AIExplainIcon,
|
||||||
|
AIExplainSelectionIcon,
|
||||||
|
AIFindActionsIcon,
|
||||||
|
AIImageIcon,
|
||||||
|
AIImproveWritingIcon,
|
||||||
|
AIMakeLongerIcon,
|
||||||
|
AIMakeRealIcon,
|
||||||
|
AIMakeShorterIcon,
|
||||||
|
AIMindMapIcon,
|
||||||
|
AIPenIcon,
|
||||||
|
AIPresentationIcon,
|
||||||
|
ArrowDownIcon,
|
||||||
|
ArrowUpIcon,
|
||||||
|
} from '../../_common/icons.js';
|
||||||
|
import { createTextRenderer } from '../../messages/text.js';
|
||||||
|
import type { ChatAction } from '../chat-context.js';
|
||||||
|
import { renderImages } from '../components/images.js';
|
||||||
|
import { HISTORY_IMAGE_ACTIONS } from '../const.js';
|
||||||
|
|
||||||
|
const icons: Record<string, TemplateResult<1>> = {
|
||||||
|
'Fix spelling for it': AIDoneIcon,
|
||||||
|
'Improve grammar for it': AIDoneIcon,
|
||||||
|
'Explain this code': AIExplainIcon,
|
||||||
|
'Check code error': AIExplainIcon,
|
||||||
|
'Explain this': AIExplainSelectionIcon,
|
||||||
|
Translate: ActionIcon,
|
||||||
|
'Change tone': AIChangeToneIcon,
|
||||||
|
'Improve writing for it': AIImproveWritingIcon,
|
||||||
|
'Make it longer': AIMakeLongerIcon,
|
||||||
|
'Make it shorter': AIMakeShorterIcon,
|
||||||
|
'Continue writing': AIPenIcon,
|
||||||
|
'Make it real': AIMakeRealIcon,
|
||||||
|
'Find action items from it': AIFindActionsIcon,
|
||||||
|
Summary: AIPenIcon,
|
||||||
|
'Create headings': AIPenIcon,
|
||||||
|
'Write outline': AIPenIcon,
|
||||||
|
image: AIImageIcon,
|
||||||
|
'Brainstorm mindmap': AIMindMapIcon,
|
||||||
|
'Expand mind map': AIExpandMindMapIcon,
|
||||||
|
'Create a presentation': AIPresentationIcon,
|
||||||
|
'Write a poem about this': AIPenIcon,
|
||||||
|
'Write a blog post about this': AIPenIcon,
|
||||||
|
'AI image filter clay style': AIImageIcon,
|
||||||
|
'AI image filter sketch style': AIImageIcon,
|
||||||
|
'AI image filter anime style': AIImageIcon,
|
||||||
|
'AI image filter pixel style': AIImageIcon,
|
||||||
|
Clearer: AIImageIcon,
|
||||||
|
'Remove background': AIImageIcon,
|
||||||
|
'Convert to sticker': AIImageIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
@customElement('action-wrapper')
|
||||||
|
export class ActionWrapper extends WithDisposable(LitElement) {
|
||||||
|
static override styles = css`
|
||||||
|
.action-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 22px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--affine-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
div:last-child {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
div:last-child svg {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.answer-prompt {
|
||||||
|
padding: 8px;
|
||||||
|
background-color: var(--affine-background-secondary-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--affine-text-primary-color);
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--affine-text-secondary-color);
|
||||||
|
height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor promptShow = false;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor item!: ChatAction;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
protected override render() {
|
||||||
|
const { item } = this;
|
||||||
|
|
||||||
|
const originalText = item.messages[1].content;
|
||||||
|
const answer = item.messages[2]?.content;
|
||||||
|
const images = item.messages[1].attachments;
|
||||||
|
|
||||||
|
return html`<style></style>
|
||||||
|
<slot></slot>
|
||||||
|
<div
|
||||||
|
class="action-name"
|
||||||
|
@click=${() => (this.promptShow = !this.promptShow)}
|
||||||
|
>
|
||||||
|
${icons[item.action] ? icons[item.action] : ActionIcon}
|
||||||
|
<div>
|
||||||
|
<div>${item.action}</div>
|
||||||
|
<div>${this.promptShow ? ArrowDownIcon : ArrowUpIcon}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${this.promptShow
|
||||||
|
? html`
|
||||||
|
<div class="answer-prompt">
|
||||||
|
<div class="subtitle">Answer</div>
|
||||||
|
${HISTORY_IMAGE_ACTIONS.includes(item.action)
|
||||||
|
? images && renderImages(images)
|
||||||
|
: nothing}
|
||||||
|
${answer
|
||||||
|
? createTextRenderer(this.host, { customHeading: true })(answer)
|
||||||
|
: nothing}
|
||||||
|
${originalText
|
||||||
|
? html`<div class="subtitle prompt">Prompt</div>
|
||||||
|
${createTextRenderer(this.host, { customHeading: true })(
|
||||||
|
item.messages[0].content + originalText
|
||||||
|
)}`
|
||||||
|
: nothing}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing} `;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'action-wrapper': ActionWrapper;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,166 @@
|
|||||||
|
import type {
|
||||||
|
BlockSelection,
|
||||||
|
EditorHost,
|
||||||
|
TextSelection,
|
||||||
|
} from '@blocksuite/block-std';
|
||||||
|
import type {
|
||||||
|
EdgelessRootService,
|
||||||
|
ImageSelection,
|
||||||
|
SerializedXYWH,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import {
|
||||||
|
BlocksUtils,
|
||||||
|
Bound,
|
||||||
|
getElementsBound,
|
||||||
|
NoteDisplayMode,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CreateIcon,
|
||||||
|
InsertBelowIcon,
|
||||||
|
ReplaceIcon,
|
||||||
|
} from '../../_common/icons.js';
|
||||||
|
import { reportResponse } from '../../utils/action-reporter.js';
|
||||||
|
import { insertBelow, replace } from '../../utils/editor-actions.js';
|
||||||
|
import { insertFromMarkdown } from '../../utils/markdown-utils.js';
|
||||||
|
|
||||||
|
const { matchFlavours } = BlocksUtils;
|
||||||
|
|
||||||
|
const CommonActions = [
|
||||||
|
{
|
||||||
|
icon: ReplaceIcon,
|
||||||
|
title: 'Replace selection',
|
||||||
|
handler: async (
|
||||||
|
host: EditorHost,
|
||||||
|
content: string,
|
||||||
|
currentTextSelection?: TextSelection,
|
||||||
|
currentBlockSelections?: BlockSelection[]
|
||||||
|
) => {
|
||||||
|
const [_, data] = host.command
|
||||||
|
.chain()
|
||||||
|
.getSelectedBlocks({
|
||||||
|
currentTextSelection,
|
||||||
|
currentBlockSelections,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
if (!data.selectedBlocks) return;
|
||||||
|
|
||||||
|
reportResponse('result:replace');
|
||||||
|
|
||||||
|
if (currentTextSelection) {
|
||||||
|
const { doc } = host;
|
||||||
|
const block = doc.getBlock(currentTextSelection.blockId);
|
||||||
|
if (matchFlavours(block?.model ?? null, ['affine:paragraph'])) {
|
||||||
|
block?.model.text?.replace(
|
||||||
|
currentTextSelection.from.index,
|
||||||
|
currentTextSelection.from.length,
|
||||||
|
content
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await replace(
|
||||||
|
host,
|
||||||
|
content,
|
||||||
|
data.selectedBlocks[0],
|
||||||
|
data.selectedBlocks.map(block => block.model),
|
||||||
|
currentTextSelection
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: InsertBelowIcon,
|
||||||
|
title: 'Insert below',
|
||||||
|
handler: async (
|
||||||
|
host: EditorHost,
|
||||||
|
content: string,
|
||||||
|
currentTextSelection?: TextSelection,
|
||||||
|
currentBlockSelections?: BlockSelection[],
|
||||||
|
currentImageSelections?: ImageSelection[]
|
||||||
|
) => {
|
||||||
|
const [_, data] = host.command
|
||||||
|
.chain()
|
||||||
|
.getSelectedBlocks({
|
||||||
|
currentTextSelection,
|
||||||
|
currentBlockSelections,
|
||||||
|
currentImageSelections,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
if (!data.selectedBlocks) return;
|
||||||
|
reportResponse('result:insert');
|
||||||
|
await insertBelow(
|
||||||
|
host,
|
||||||
|
content,
|
||||||
|
data.selectedBlocks[data.selectedBlocks?.length - 1]
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PageEditorActions = [
|
||||||
|
...CommonActions,
|
||||||
|
{
|
||||||
|
icon: CreateIcon,
|
||||||
|
title: 'Create as a doc',
|
||||||
|
handler: (host: EditorHost, content: string) => {
|
||||||
|
reportResponse('result:add-page');
|
||||||
|
const newDoc = host.doc.collection.createDoc();
|
||||||
|
newDoc.load();
|
||||||
|
const rootId = newDoc.addBlock('affine:page');
|
||||||
|
newDoc.addBlock('affine:surface', {}, rootId);
|
||||||
|
const noteId = newDoc.addBlock('affine:note', {}, rootId);
|
||||||
|
|
||||||
|
host.spec.getService('affine:page').slots.docLinkClicked.emit({
|
||||||
|
docId: newDoc.id,
|
||||||
|
});
|
||||||
|
let complete = false;
|
||||||
|
(function addContent() {
|
||||||
|
if (complete) return;
|
||||||
|
const newHost = document.querySelector('editor-host');
|
||||||
|
// FIXME: this is a hack to wait for the host to be ready, now we don't have a way to know if the new host is ready
|
||||||
|
if (!newHost || newHost === host) {
|
||||||
|
setTimeout(addContent, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
complete = true;
|
||||||
|
insertFromMarkdown(newHost, content, noteId, 0).catch(console.error);
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EdgelessEditorActions = [
|
||||||
|
...CommonActions,
|
||||||
|
{
|
||||||
|
icon: CreateIcon,
|
||||||
|
title: 'Add to edgeless as note',
|
||||||
|
handler: async (host: EditorHost, content: string) => {
|
||||||
|
reportResponse('result:add-note');
|
||||||
|
const { doc } = host;
|
||||||
|
const service = host.spec.getService<EdgelessRootService>('affine:page');
|
||||||
|
const elements = service.selection.selectedElements;
|
||||||
|
|
||||||
|
const props: { displayMode: NoteDisplayMode; xywh?: SerializedXYWH } = {
|
||||||
|
displayMode: NoteDisplayMode.EdgelessOnly,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (elements.length > 0) {
|
||||||
|
const bound = getElementsBound(
|
||||||
|
elements.map(e => Bound.deserialize(e.xywh))
|
||||||
|
);
|
||||||
|
const newBound = new Bound(bound.x, bound.maxY + 10, bound.w);
|
||||||
|
props.xywh = newBound.serialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = doc.addBlock('affine:note', props, doc.root?.id);
|
||||||
|
|
||||||
|
await insertFromMarkdown(host, content, id, 0);
|
||||||
|
|
||||||
|
service.selection.set({
|
||||||
|
elements: [id],
|
||||||
|
editing: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
@ -0,0 +1,39 @@
|
|||||||
|
import './action-wrapper.js';
|
||||||
|
|
||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import { ShadowlessElement, WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import { html, nothing } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
import { createTextRenderer } from '../../messages/text.js';
|
||||||
|
import { renderImages } from '../components/images.js';
|
||||||
|
@customElement('chat-text')
|
||||||
|
export class ChatText extends WithDisposable(ShadowlessElement) {
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor attachments: string[] | undefined = undefined;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor text!: string;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor state: 'finished' | 'generating' = 'finished';
|
||||||
|
|
||||||
|
protected override render() {
|
||||||
|
const { attachments, text, host } = this;
|
||||||
|
return html`${attachments && attachments.length > 0
|
||||||
|
? renderImages(attachments)
|
||||||
|
: nothing}${createTextRenderer(host, { customHeading: true })(
|
||||||
|
text,
|
||||||
|
this.state
|
||||||
|
)} `;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'chat-text': ChatText;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,226 @@
|
|||||||
|
import type {
|
||||||
|
BlockSelection,
|
||||||
|
EditorHost,
|
||||||
|
TextSelection,
|
||||||
|
} from '@blocksuite/block-std';
|
||||||
|
import { WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import { type AIError, createButtonPopper, Tooltip } from '@blocksuite/blocks';
|
||||||
|
import { noop } from '@blocksuite/global/utils';
|
||||||
|
import { css, html, LitElement, nothing, type PropertyValues } from 'lit';
|
||||||
|
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||||
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
|
|
||||||
|
import { CopyIcon, MoreIcon, RetryIcon } from '../../_common/icons.js';
|
||||||
|
import { AIProvider } from '../../provider.js';
|
||||||
|
import { copyText } from '../../utils/editor-actions.js';
|
||||||
|
import type { ChatContextValue, ChatMessage } from '../chat-context.js';
|
||||||
|
import { PageEditorActions } from './actions-handle.js';
|
||||||
|
|
||||||
|
noop(Tooltip);
|
||||||
|
|
||||||
|
@customElement('chat-copy-more')
|
||||||
|
export class ChatCopyMore extends WithDisposable(LitElement) {
|
||||||
|
static override styles = css`
|
||||||
|
.copy-more {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
height: 36px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
div {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
div:hover {
|
||||||
|
background-color: var(--affine-hover-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
color: var(--affine-icon-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-menu {
|
||||||
|
width: 226px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--affine-background-overlay-panel-color);
|
||||||
|
box-shadow: var(--affine-menu-shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> div:hover {
|
||||||
|
background-color: var(--affine-hover-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor _showMoreMenu = false;
|
||||||
|
|
||||||
|
@query('.more-button')
|
||||||
|
private accessor _moreButton!: HTMLDivElement;
|
||||||
|
|
||||||
|
@query('.more-menu')
|
||||||
|
private accessor _moreMenu!: HTMLDivElement;
|
||||||
|
|
||||||
|
private _morePopper: ReturnType<typeof createButtonPopper> | null = null;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor content!: string;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor isLast!: boolean;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor curTextSelection: TextSelection | undefined = undefined;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor curBlockSelections: BlockSelection[] | undefined = undefined;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor chatContextValue!: ChatContextValue;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor updateContext!: (context: Partial<ChatContextValue>) => void;
|
||||||
|
|
||||||
|
private _toggle() {
|
||||||
|
this._morePopper?.toggle();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _retry() {
|
||||||
|
const { doc } = this.host;
|
||||||
|
try {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const items = [...this.chatContextValue.items];
|
||||||
|
const last = items[items.length - 1];
|
||||||
|
if ('content' in last) {
|
||||||
|
last.content = '';
|
||||||
|
last.createdAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
this.updateContext({ items, status: 'loading', error: null });
|
||||||
|
|
||||||
|
const stream = AIProvider.actions.chat?.({
|
||||||
|
retry: true,
|
||||||
|
docId: doc.id,
|
||||||
|
workspaceId: doc.collection.id,
|
||||||
|
host: this.host,
|
||||||
|
stream: true,
|
||||||
|
signal: abortController.signal,
|
||||||
|
where: 'chat-panel',
|
||||||
|
control: 'chat-send',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
this.updateContext({ abortController });
|
||||||
|
for await (const text of stream) {
|
||||||
|
const items = [...this.chatContextValue.items];
|
||||||
|
const last = items[items.length - 1] as ChatMessage;
|
||||||
|
last.content += text;
|
||||||
|
this.updateContext({ items, status: 'transmitting' });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateContext({ status: 'success' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.updateContext({ status: 'error', error: error as AIError });
|
||||||
|
} finally {
|
||||||
|
this.updateContext({ abortController: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override updated(changed: PropertyValues): void {
|
||||||
|
if (changed.has('isLast')) {
|
||||||
|
if (this.isLast) {
|
||||||
|
this._morePopper?.dispose();
|
||||||
|
this._morePopper = null;
|
||||||
|
} else if (!this._morePopper) {
|
||||||
|
this._morePopper = createButtonPopper(
|
||||||
|
this._moreButton,
|
||||||
|
this._moreMenu,
|
||||||
|
({ display }) => (this._showMoreMenu = display === 'show')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
const { host, content, isLast } = this;
|
||||||
|
return html`<style>
|
||||||
|
.more-menu {
|
||||||
|
padding: ${this._showMoreMenu ? '8px' : '0px'};
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="copy-more">
|
||||||
|
${content
|
||||||
|
? html`<div @click=${() => copyText(host, content)}>
|
||||||
|
${CopyIcon}
|
||||||
|
<affine-tooltip>Copy</affine-tooltip>
|
||||||
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
${isLast
|
||||||
|
? html`<div @click=${() => this._retry()}>
|
||||||
|
${RetryIcon}
|
||||||
|
<affine-tooltip>Retry</affine-tooltip>
|
||||||
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
${isLast
|
||||||
|
? nothing
|
||||||
|
: html`<div class="more-button" @click=${this._toggle}>
|
||||||
|
${MoreIcon}
|
||||||
|
</div> `}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="more-menu">
|
||||||
|
${this._showMoreMenu
|
||||||
|
? repeat(
|
||||||
|
PageEditorActions,
|
||||||
|
action => action.title,
|
||||||
|
action => {
|
||||||
|
return html`<div
|
||||||
|
@click=${() =>
|
||||||
|
action.handler(
|
||||||
|
host,
|
||||||
|
content,
|
||||||
|
this.curTextSelection,
|
||||||
|
this.curBlockSelections
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
${action.icon}
|
||||||
|
<div>${action.title}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: nothing}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'chat-copy-more': ChatCopyMore;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
import './action-wrapper.js';
|
||||||
|
|
||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import { ShadowlessElement, WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import { html, nothing } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
|
|
||||||
|
import type { ChatAction } from '../chat-context.js';
|
||||||
|
import { renderImages } from '../components/images.js';
|
||||||
|
|
||||||
|
@customElement('action-image-to-text')
|
||||||
|
export class ActionImageToText extends WithDisposable(ShadowlessElement) {
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor item!: ChatAction;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
protected override render() {
|
||||||
|
const answer = this.item.messages[1].attachments;
|
||||||
|
|
||||||
|
return html`<action-wrapper .host=${this.host} .item=${this.item}>
|
||||||
|
<div style=${styleMap({ marginBottom: '12px' })}>
|
||||||
|
${answer ? renderImages(answer) : nothing}
|
||||||
|
</div>
|
||||||
|
</action-wrapper>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'action-image-to-text': ActionImageToText;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
import './action-wrapper.js';
|
||||||
|
|
||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import { ShadowlessElement, WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import { html, nothing } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
|
|
||||||
|
import type { ChatAction } from '../chat-context.js';
|
||||||
|
import { renderImages } from '../components/images.js';
|
||||||
|
|
||||||
|
@customElement('action-image')
|
||||||
|
export class ActionImage extends WithDisposable(ShadowlessElement) {
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor item!: ChatAction;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
protected override render() {
|
||||||
|
const answer = this.item.messages[0].attachments;
|
||||||
|
|
||||||
|
return html`<action-wrapper .host=${this.host} .item=${this.item}>
|
||||||
|
<div style=${styleMap({ marginBottom: '12px' })}>
|
||||||
|
${answer ? renderImages(answer) : nothing}
|
||||||
|
</div>
|
||||||
|
</action-wrapper>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'action-image': ActionImage;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
import './action-wrapper.js';
|
||||||
|
|
||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import { ShadowlessElement, WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import { html } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
|
|
||||||
|
import { createIframeRenderer } from '../../messages/wrapper.js';
|
||||||
|
import type { ChatAction } from '../chat-context.js';
|
||||||
|
|
||||||
|
@customElement('action-make-real')
|
||||||
|
export class ActionMakeReal extends WithDisposable(ShadowlessElement) {
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor item!: ChatAction;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
protected override render() {
|
||||||
|
const answer = this.item.messages[2].content;
|
||||||
|
return html`<action-wrapper .host=${this.host} .item=${this.item}>
|
||||||
|
<div style=${styleMap({ marginBottom: '12px' })}>
|
||||||
|
${createIframeRenderer(this.host)(answer, 'finished')}
|
||||||
|
</div>
|
||||||
|
</action-wrapper>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'action-make-real': ActionMakeReal;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,46 @@
|
|||||||
|
import './action-wrapper.js';
|
||||||
|
|
||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import { ShadowlessElement, WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import { MiniMindmapPreview } from '@blocksuite/blocks';
|
||||||
|
import { noop } from '@blocksuite/global/utils';
|
||||||
|
import { html } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
|
|
||||||
|
import type { ChatAction } from '../chat-context.js';
|
||||||
|
|
||||||
|
noop(MiniMindmapPreview);
|
||||||
|
|
||||||
|
@customElement('action-mindmap')
|
||||||
|
export class ActionMindmap extends WithDisposable(ShadowlessElement) {
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor item!: ChatAction;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
protected override render() {
|
||||||
|
const answer = this.item.messages[2].content;
|
||||||
|
return html`<action-wrapper .host=${this.host} .item=${this.item}>
|
||||||
|
<div style=${styleMap({ marginBottom: '12px', height: '140px' })}>
|
||||||
|
<mini-mindmap-preview
|
||||||
|
.host=${this.host}
|
||||||
|
.ctx=${{
|
||||||
|
get: () => ({}),
|
||||||
|
set: () => {},
|
||||||
|
}}
|
||||||
|
.answer=${answer}
|
||||||
|
.templateShow=${false}
|
||||||
|
.height=${140}
|
||||||
|
></mini-mindmap-preview>
|
||||||
|
</div>
|
||||||
|
</action-wrapper>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'action-mindmap': ActionMindmap;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
import './action-wrapper.js';
|
||||||
|
import '../../messages/slides-renderer.js';
|
||||||
|
|
||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import { ShadowlessElement, WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import { html, nothing } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
|
|
||||||
|
import type { ChatAction } from '../chat-context.js';
|
||||||
|
|
||||||
|
@customElement('action-slides')
|
||||||
|
export class ActionSlides extends WithDisposable(ShadowlessElement) {
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor item!: ChatAction;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
protected override render() {
|
||||||
|
const answer = this.item.messages[2]?.content;
|
||||||
|
if (!answer) return nothing;
|
||||||
|
|
||||||
|
return html`<action-wrapper .host=${this.host} .item=${this.item}>
|
||||||
|
<div style=${styleMap({ marginBottom: '12px', height: '174px' })}>
|
||||||
|
<ai-slides-renderer
|
||||||
|
.text=${answer}
|
||||||
|
.host=${this.host}
|
||||||
|
></ai-slides-renderer>
|
||||||
|
</div>
|
||||||
|
</action-wrapper>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'action-slides': ActionSlides;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,57 @@
|
|||||||
|
import './action-wrapper.js';
|
||||||
|
|
||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import { WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
|
|
||||||
|
import { createTextRenderer } from '../../messages/text.js';
|
||||||
|
import type { ChatAction } from '../chat-context.js';
|
||||||
|
|
||||||
|
@customElement('action-text')
|
||||||
|
export class ActionText extends WithDisposable(LitElement) {
|
||||||
|
static override styles = css`
|
||||||
|
.original-text {
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: var(--affine-font-sm);
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor item!: ChatAction;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor isCode = false;
|
||||||
|
|
||||||
|
protected override render() {
|
||||||
|
const originalText = this.item.messages[1].content;
|
||||||
|
const { isCode } = this;
|
||||||
|
|
||||||
|
return html` <action-wrapper .host=${this.host} .item=${this.item}>
|
||||||
|
<div
|
||||||
|
style=${styleMap({
|
||||||
|
padding: isCode ? '0' : '10px 16px',
|
||||||
|
border: isCode ? 'none' : '1px solid var(--affine-border-color)',
|
||||||
|
})}
|
||||||
|
class="original-text"
|
||||||
|
>
|
||||||
|
${createTextRenderer(this.host, {
|
||||||
|
customHeading: true,
|
||||||
|
maxHeight: 160,
|
||||||
|
})(originalText)}
|
||||||
|
</div>
|
||||||
|
</action-wrapper>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'action-text': ActionText;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,65 @@
|
|||||||
|
import { WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import { css, html, LitElement } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
import { AIStarIconWithAnimation } from '../_common/icons.js';
|
||||||
|
|
||||||
|
@customElement('ai-loading')
|
||||||
|
export class AILoading extends WithDisposable(LitElement) {
|
||||||
|
static override styles = css`
|
||||||
|
:host {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.generating-tip {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 22px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
color: var(--light-brandColor, #1e96eb);
|
||||||
|
|
||||||
|
.text {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 1 0 0;
|
||||||
|
|
||||||
|
/* light/smMedium */
|
||||||
|
font-family: Inter;
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 22px; /* 157.143% */
|
||||||
|
}
|
||||||
|
|
||||||
|
.left,
|
||||||
|
.right {
|
||||||
|
display: flex;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor stopGenerating!: () => void;
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html`
|
||||||
|
<div class="generating-tip">
|
||||||
|
<div class="left">${AIStarIconWithAnimation}</div>
|
||||||
|
<div class="text">AI is generating...</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'ai-loading': AILoading;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,359 @@
|
|||||||
|
import type { BaseSelection, EditorHost } from '@blocksuite/block-std';
|
||||||
|
import { WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import {
|
||||||
|
type CopilotSelectionController,
|
||||||
|
type ImageBlockModel,
|
||||||
|
type NoteBlockModel,
|
||||||
|
NoteDisplayMode,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { debounce } from '@blocksuite/global/utils';
|
||||||
|
import type { BlockModel } from '@blocksuite/store';
|
||||||
|
import { css, html, LitElement, nothing, type PropertyValues } from 'lit';
|
||||||
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CurrentSelectionIcon,
|
||||||
|
DocIcon,
|
||||||
|
SmallImageIcon,
|
||||||
|
} from '../_common/icons.js';
|
||||||
|
import {
|
||||||
|
getEdgelessRootFromEditor,
|
||||||
|
getSelectedImagesAsBlobs,
|
||||||
|
getSelectedTextContent,
|
||||||
|
getTextContentFromBlockModels,
|
||||||
|
selectedToCanvas,
|
||||||
|
} from '../utils/selection-utils.js';
|
||||||
|
import type { ChatContextValue } from './chat-context.js';
|
||||||
|
|
||||||
|
const cardsStyles = css`
|
||||||
|
.card-wrapper {
|
||||||
|
width: 90%;
|
||||||
|
max-height: 76px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--affine-border-color);
|
||||||
|
padding: 4px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
height: 22px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--affine-text-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.second-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--affine-text-secondary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ChatCardsConfig = [
|
||||||
|
{
|
||||||
|
name: 'current-selection',
|
||||||
|
render: (text?: string, _?: File, __?: string) => {
|
||||||
|
if (!text) return nothing;
|
||||||
|
|
||||||
|
const lines = text.split('\n');
|
||||||
|
|
||||||
|
return html`<div class="card-wrapper">
|
||||||
|
<div class="card-title">
|
||||||
|
${CurrentSelectionIcon}
|
||||||
|
<div>Start with current selection</div>
|
||||||
|
</div>
|
||||||
|
<div class="second-text">
|
||||||
|
${repeat(
|
||||||
|
lines.slice(0, 2),
|
||||||
|
line => line,
|
||||||
|
line => {
|
||||||
|
return html`<div
|
||||||
|
style=${styleMap({
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
${line}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div> `;
|
||||||
|
},
|
||||||
|
handler: (
|
||||||
|
updateContext: (context: Partial<ChatContextValue>) => void,
|
||||||
|
text: string,
|
||||||
|
markdown: string,
|
||||||
|
images?: File[]
|
||||||
|
) => {
|
||||||
|
const value: Partial<ChatContextValue> = {
|
||||||
|
quote: text,
|
||||||
|
markdown: markdown,
|
||||||
|
};
|
||||||
|
if (images) {
|
||||||
|
value.images = images;
|
||||||
|
}
|
||||||
|
updateContext(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'image',
|
||||||
|
render: (_?: string, image?: File, caption?: string) => {
|
||||||
|
if (!image) return nothing;
|
||||||
|
|
||||||
|
return html`<div
|
||||||
|
class="card-wrapper"
|
||||||
|
style=${styleMap({
|
||||||
|
display: 'flex',
|
||||||
|
gap: '8px',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style=${styleMap({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div class="card-title">
|
||||||
|
${SmallImageIcon}
|
||||||
|
<div>Start with this Image</div>
|
||||||
|
</div>
|
||||||
|
<div class="second-text">${caption ? caption : 'caption'}</div>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
style=${styleMap({
|
||||||
|
maxWidth: '72px',
|
||||||
|
maxHeight: '46px',
|
||||||
|
})}
|
||||||
|
src="${URL.createObjectURL(image)}"
|
||||||
|
/>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
handler: (
|
||||||
|
updateContext: (context: Partial<ChatContextValue>) => void,
|
||||||
|
_: string,
|
||||||
|
__: string,
|
||||||
|
images?: File[]
|
||||||
|
) => {
|
||||||
|
const value: Partial<ChatContextValue> = {};
|
||||||
|
if (images) {
|
||||||
|
value.images = images;
|
||||||
|
}
|
||||||
|
updateContext(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'doc',
|
||||||
|
render: () => {
|
||||||
|
return html`
|
||||||
|
<div class="card-wrapper">
|
||||||
|
<div class="card-title">
|
||||||
|
${DocIcon}
|
||||||
|
<div>Start with this doc</div>
|
||||||
|
</div>
|
||||||
|
<div class="second-text">you've chosen within the doc</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
handler: (
|
||||||
|
updateContext: (context: Partial<ChatContextValue>) => void,
|
||||||
|
text: string,
|
||||||
|
markdown: string,
|
||||||
|
images?: File[]
|
||||||
|
) => {
|
||||||
|
const value: Partial<ChatContextValue> = {
|
||||||
|
quote: text,
|
||||||
|
markdown: markdown,
|
||||||
|
};
|
||||||
|
if (images) {
|
||||||
|
value.images = images;
|
||||||
|
}
|
||||||
|
updateContext(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
@customElement('chat-cards')
|
||||||
|
export class ChatCards extends WithDisposable(LitElement) {
|
||||||
|
static override styles = css`
|
||||||
|
${cardsStyles}
|
||||||
|
.cards-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor chatContextValue!: ChatContextValue;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor updateContext!: (context: Partial<ChatContextValue>) => void;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor selectionValue: BaseSelection[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor text: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor markdown: string = '';
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor images: File[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor caption: string = '';
|
||||||
|
|
||||||
|
private _onEdgelessCopilotAreaUpdated() {
|
||||||
|
if (!this.host.closest('edgeless-editor')) return;
|
||||||
|
const edgeless = getEdgelessRootFromEditor(this.host);
|
||||||
|
|
||||||
|
const copilotSelectionTool = edgeless.tools.controllers
|
||||||
|
.copilot as CopilotSelectionController;
|
||||||
|
|
||||||
|
this._disposables.add(
|
||||||
|
copilotSelectionTool.draggingAreaUpdated.on(
|
||||||
|
debounce(() => {
|
||||||
|
selectedToCanvas(this.host)
|
||||||
|
.then(canvas => {
|
||||||
|
canvas?.toBlob(blob => {
|
||||||
|
if (!blob) return;
|
||||||
|
const file = new File([blob], 'selected.png');
|
||||||
|
this.images = [file];
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}, 300)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _updateState() {
|
||||||
|
if (
|
||||||
|
this.selectionValue.some(
|
||||||
|
selection => selection.is('text') || selection.is('image')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
this.text = await getSelectedTextContent(this.host, 'plain-text');
|
||||||
|
this.markdown = await getSelectedTextContent(this.host, 'markdown');
|
||||||
|
this.images = await getSelectedImagesAsBlobs(this.host);
|
||||||
|
const [_, data] = this.host.command
|
||||||
|
.chain()
|
||||||
|
.tryAll(chain => [
|
||||||
|
chain.getTextSelection(),
|
||||||
|
chain.getBlockSelections(),
|
||||||
|
chain.getImageSelections(),
|
||||||
|
])
|
||||||
|
.getSelectedBlocks({
|
||||||
|
types: ['image'],
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
if (data.currentBlockSelections?.[0]) {
|
||||||
|
this.caption =
|
||||||
|
(
|
||||||
|
this.host.doc.getBlock(data.currentBlockSelections[0].blockId)
|
||||||
|
?.model as ImageBlockModel
|
||||||
|
).caption ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleDocSelection() {
|
||||||
|
const notes = this.host.doc
|
||||||
|
.getBlocksByFlavour('affine:note')
|
||||||
|
.filter(
|
||||||
|
note =>
|
||||||
|
(note.model as NoteBlockModel).displayMode !==
|
||||||
|
NoteDisplayMode.EdgelessOnly
|
||||||
|
)
|
||||||
|
.map(note => note.model as NoteBlockModel);
|
||||||
|
const selectedModels = notes.reduce((acc, note) => {
|
||||||
|
acc.push(...note.children);
|
||||||
|
return acc;
|
||||||
|
}, [] as BlockModel[]);
|
||||||
|
const text = await getTextContentFromBlockModels(
|
||||||
|
this.host,
|
||||||
|
selectedModels,
|
||||||
|
'plain-text'
|
||||||
|
);
|
||||||
|
const markdown = await getTextContentFromBlockModels(
|
||||||
|
this.host,
|
||||||
|
selectedModels,
|
||||||
|
'markdown'
|
||||||
|
);
|
||||||
|
const blobs = await Promise.all(
|
||||||
|
selectedModels.map(async s => {
|
||||||
|
if (s.flavour !== 'affine:image') return null;
|
||||||
|
const sourceId = (s as ImageBlockModel)?.sourceId;
|
||||||
|
if (!sourceId) return null;
|
||||||
|
const blob = await (sourceId
|
||||||
|
? this.host.doc.blobSync.get(sourceId)
|
||||||
|
: null);
|
||||||
|
if (!blob) return null;
|
||||||
|
return new File([blob], sourceId);
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
|
const images = blobs.filter((blob): blob is File => !!blob);
|
||||||
|
this.text = text;
|
||||||
|
this.markdown = markdown;
|
||||||
|
this.images = images;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async updated(_changedProperties: PropertyValues) {
|
||||||
|
if (_changedProperties.has('selectionValue')) {
|
||||||
|
await this._updateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_changedProperties.has('host')) {
|
||||||
|
this._onEdgelessCopilotAreaUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override render() {
|
||||||
|
return html`<div class="cards-container">
|
||||||
|
${repeat(
|
||||||
|
ChatCardsConfig,
|
||||||
|
card => card.name,
|
||||||
|
card => {
|
||||||
|
if (
|
||||||
|
card.render(this.text, this.images[0], this.caption) !== nothing
|
||||||
|
) {
|
||||||
|
return html`<div
|
||||||
|
@click=${async () => {
|
||||||
|
if (card.name === 'doc') {
|
||||||
|
await this._handleDocSelection();
|
||||||
|
}
|
||||||
|
card.handler(
|
||||||
|
this.updateContext,
|
||||||
|
this.text,
|
||||||
|
this.markdown,
|
||||||
|
this.images
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${card.render(this.text, this.images[0], this.caption)}
|
||||||
|
</div> `;
|
||||||
|
}
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'chat-cards': ChatCards;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
import type { AIError } from '@blocksuite/blocks';
|
||||||
|
|
||||||
|
export type ChatMessage = {
|
||||||
|
content: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
attachments?: string[];
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatAction = {
|
||||||
|
action: string;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
sessionId: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatItem = ChatMessage | ChatAction;
|
||||||
|
|
||||||
|
export type ChatStatus =
|
||||||
|
| 'loading'
|
||||||
|
| 'success'
|
||||||
|
| 'error'
|
||||||
|
| 'idle'
|
||||||
|
| 'transmitting';
|
||||||
|
|
||||||
|
export type ChatContextValue = {
|
||||||
|
items: ChatItem[];
|
||||||
|
status: ChatStatus;
|
||||||
|
error: AIError | null;
|
||||||
|
quote: string;
|
||||||
|
markdown: string;
|
||||||
|
images: File[];
|
||||||
|
abortController: AbortController | null;
|
||||||
|
};
|
@ -0,0 +1,484 @@
|
|||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import { WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import { type AIError, openFileOrFiles } from '@blocksuite/blocks';
|
||||||
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
|
import { css, html, LitElement, nothing } from 'lit';
|
||||||
|
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||||
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChatAbortIcon,
|
||||||
|
ChatClearIcon,
|
||||||
|
ChatSendIcon,
|
||||||
|
CloseIcon,
|
||||||
|
ImageIcon,
|
||||||
|
} from '../_common/icons.js';
|
||||||
|
import { AIProvider } from '../provider.js';
|
||||||
|
import { reportResponse } from '../utils/action-reporter.js';
|
||||||
|
import { readBlobAsURL } from '../utils/image.js';
|
||||||
|
import type { ChatContextValue, ChatMessage } from './chat-context.js';
|
||||||
|
|
||||||
|
const MaximumImageCount = 8;
|
||||||
|
|
||||||
|
function getFirstTwoLines(text: string) {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
return lines.slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@customElement('chat-panel-input')
|
||||||
|
export class ChatPanelInput extends WithDisposable(LitElement) {
|
||||||
|
static override styles = css`
|
||||||
|
.chat-panel-input {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
position: relative;
|
||||||
|
margin-top: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
min-height: 94px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-width: 1px;
|
||||||
|
border-style: solid;
|
||||||
|
|
||||||
|
.chat-selection-quote {
|
||||||
|
padding: 4px 0px 8px 0px;
|
||||||
|
padding-left: 15px;
|
||||||
|
max-height: 56px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 22px;
|
||||||
|
color: var(--affine-text-secondary-color);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
div {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-quote-close {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
display: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--affine-border-color);
|
||||||
|
background-color: var(--affine-white);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-selection-quote:hover .chat-quote-close {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-selection-quote::after {
|
||||||
|
content: '';
|
||||||
|
width: 2px;
|
||||||
|
height: calc(100% - 10px);
|
||||||
|
margin-top: 5px;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
background: var(--affine-quote-color);
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-input-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
div {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
div:nth-child(2) {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-history-clear {
|
||||||
|
background-color: var(--affine-white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-upload {
|
||||||
|
background-color: var(--affine-white);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-input {
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
line-height: 22px;
|
||||||
|
font-size: var(--affine-font-sm);
|
||||||
|
font-weight: 400;
|
||||||
|
font-family: var(--affine-font-family);
|
||||||
|
color: var(--affine-text-primary-color);
|
||||||
|
box-sizing: border-box;
|
||||||
|
resize: none;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea::placeholder {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 400;
|
||||||
|
font-family: var(--affine-font-family);
|
||||||
|
color: var(--affine-placeholder-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-images {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.image-container {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--affine-border-color);
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-wrapper {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--affine-border-color);
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
background-color: var(--affine-white);
|
||||||
|
z-index: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-wrapper:hover {
|
||||||
|
background-color: var(--affine-background-error-color);
|
||||||
|
border: 1px solid var(--affine-error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-wrapper:hover svg path {
|
||||||
|
fill: var(--affine-error-color);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
@query('.chat-panel-images')
|
||||||
|
accessor imagesWrapper!: HTMLDivElement;
|
||||||
|
|
||||||
|
@query('textarea')
|
||||||
|
accessor textarea!: HTMLTextAreaElement;
|
||||||
|
|
||||||
|
@query('.close-wrapper')
|
||||||
|
accessor closeWrapper!: HTMLDivElement;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor curIndex = -1;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor isInputEmpty = true;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor focused = false;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor chatContextValue!: ChatContextValue;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor updateContext!: (context: Partial<ChatContextValue>) => void;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor cleanupHistories!: () => Promise<void>;
|
||||||
|
|
||||||
|
private _addImages(images: File[]) {
|
||||||
|
const oldImages = this.chatContextValue.images;
|
||||||
|
this.updateContext({
|
||||||
|
images: [...oldImages, ...images].slice(0, MaximumImageCount),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _renderImages(images: File[]) {
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="chat-panel-images"
|
||||||
|
@mouseleave=${() => {
|
||||||
|
this.closeWrapper.style.display = 'none';
|
||||||
|
this.curIndex = -1;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${repeat(
|
||||||
|
images,
|
||||||
|
image => image.name,
|
||||||
|
(image, index) =>
|
||||||
|
html`<div
|
||||||
|
class="image-container"
|
||||||
|
@mouseenter=${(evt: MouseEvent) => {
|
||||||
|
const ele = evt.target as HTMLImageElement;
|
||||||
|
const rect = ele.getBoundingClientRect();
|
||||||
|
assertExists(ele.parentElement);
|
||||||
|
const parentRect = ele.parentElement.getBoundingClientRect();
|
||||||
|
const left = Math.abs(rect.right - parentRect.left) - 8;
|
||||||
|
const top = Math.abs(parentRect.top - rect.top) - 8;
|
||||||
|
this.curIndex = index;
|
||||||
|
this.closeWrapper.style.display = 'flex';
|
||||||
|
this.closeWrapper.style.left = left + 'px';
|
||||||
|
this.closeWrapper.style.top = top + 'px';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img src="${URL.createObjectURL(image)}" alt="${image.name}" />
|
||||||
|
</div>`
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
class="close-wrapper"
|
||||||
|
@click=${() => {
|
||||||
|
if (this.curIndex >= 0 && this.curIndex < images.length) {
|
||||||
|
const newImages = [...images];
|
||||||
|
newImages.splice(this.curIndex, 1);
|
||||||
|
this.updateContext({ images: newImages });
|
||||||
|
this.curIndex = -1;
|
||||||
|
this.closeWrapper.style.display = 'none';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${CloseIcon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override render() {
|
||||||
|
const { images, status } = this.chatContextValue;
|
||||||
|
const hasImages = images.length > 0;
|
||||||
|
const maxHeight = hasImages ? 272 + 2 : 200 + 2;
|
||||||
|
|
||||||
|
return html`<style>
|
||||||
|
.chat-panel-send svg rect {
|
||||||
|
fill: ${this.isInputEmpty && hasImages
|
||||||
|
? 'var(--affine-text-disable-color)'
|
||||||
|
: 'var(--affine-primary-color)'};
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-input {
|
||||||
|
border-color: ${this.focused
|
||||||
|
? 'var(--affine-primary-color)'
|
||||||
|
: 'var(--affine-border-color)'};
|
||||||
|
box-shadow: ${this.focused ? 'var(--affine-active-shadow)' : 'none'};
|
||||||
|
max-height: ${maxHeight}px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="chat-panel-input">
|
||||||
|
${hasImages ? this._renderImages(images) : nothing}
|
||||||
|
${this.chatContextValue.quote
|
||||||
|
? html`<div class="chat-selection-quote">
|
||||||
|
${repeat(
|
||||||
|
getFirstTwoLines(this.chatContextValue.quote),
|
||||||
|
line => line,
|
||||||
|
line => html`<div>${line}</div>`
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
class="chat-quote-close"
|
||||||
|
@click=${() => {
|
||||||
|
this.updateContext({ quote: '', markdown: '' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${CloseIcon}
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
<textarea
|
||||||
|
rows="1"
|
||||||
|
placeholder="What are your thoughts?"
|
||||||
|
@input=${() => {
|
||||||
|
const { textarea } = this;
|
||||||
|
this.isInputEmpty = !textarea.value.trim();
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
textarea.style.height = textarea.scrollHeight + 'px';
|
||||||
|
let imagesHeight = this.imagesWrapper?.scrollHeight ?? 0;
|
||||||
|
if (imagesHeight) imagesHeight += 12;
|
||||||
|
if (this.scrollHeight >= 200 + imagesHeight) {
|
||||||
|
textarea.style.height = '148px';
|
||||||
|
textarea.style.overflowY = 'scroll';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
@keydown=${async (evt: KeyboardEvent) => {
|
||||||
|
if (evt.key === 'Enter' && !evt.shiftKey && !evt.isComposing) {
|
||||||
|
evt.preventDefault();
|
||||||
|
await this.send();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
@focus=${() => {
|
||||||
|
this.focused = true;
|
||||||
|
}}
|
||||||
|
@blur=${() => {
|
||||||
|
this.focused = false;
|
||||||
|
}}
|
||||||
|
@paste=${(event: ClipboardEvent) => {
|
||||||
|
const items = event.clipboardData?.items;
|
||||||
|
if (!items) return;
|
||||||
|
|
||||||
|
for (const index in items) {
|
||||||
|
const item = items[index];
|
||||||
|
if (item.kind === 'file' && item.type.indexOf('image') >= 0) {
|
||||||
|
const blob = item.getAsFile();
|
||||||
|
if (!blob) continue;
|
||||||
|
this._addImages([blob]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></textarea>
|
||||||
|
<div class="chat-panel-input-actions">
|
||||||
|
<div
|
||||||
|
class="chat-history-clear"
|
||||||
|
@click=${async () => {
|
||||||
|
await this.cleanupHistories();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${ChatClearIcon}
|
||||||
|
</div>
|
||||||
|
${images.length < MaximumImageCount
|
||||||
|
? html`<div
|
||||||
|
class="image-upload"
|
||||||
|
@click=${async () => {
|
||||||
|
const images = await openFileOrFiles({
|
||||||
|
acceptType: 'Images',
|
||||||
|
multiple: true,
|
||||||
|
});
|
||||||
|
if (!images) return;
|
||||||
|
this._addImages(images);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${ImageIcon}
|
||||||
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
${status === 'transmitting'
|
||||||
|
? html`<div
|
||||||
|
@click=${() => {
|
||||||
|
this.chatContextValue.abortController?.abort();
|
||||||
|
this.updateContext({ status: 'success' });
|
||||||
|
reportResponse('aborted:stop');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${ChatAbortIcon}
|
||||||
|
</div>`
|
||||||
|
: html`<div @click="${this.send}" class="chat-panel-send">
|
||||||
|
${ChatSendIcon}
|
||||||
|
</div>`}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
send = async () => {
|
||||||
|
const { status, markdown } = this.chatContextValue;
|
||||||
|
if (status === 'loading' || status === 'transmitting') return;
|
||||||
|
|
||||||
|
const text = this.textarea.value;
|
||||||
|
const { images } = this.chatContextValue;
|
||||||
|
if (!text && images.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { doc } = this.host;
|
||||||
|
this.textarea.value = '';
|
||||||
|
this.isInputEmpty = true;
|
||||||
|
this.updateContext({
|
||||||
|
images: [],
|
||||||
|
status: 'loading',
|
||||||
|
error: null,
|
||||||
|
quote: '',
|
||||||
|
markdown: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const attachments = await Promise.all(
|
||||||
|
images?.map(image => readBlobAsURL(image))
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = (markdown ? `${markdown}\n` : '') + text;
|
||||||
|
|
||||||
|
this.updateContext({
|
||||||
|
items: [
|
||||||
|
...this.chatContextValue.items,
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: content,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
attachments,
|
||||||
|
},
|
||||||
|
{ role: 'assistant', content: '', createdAt: new Date().toISOString() },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const stream = AIProvider.actions.chat?.({
|
||||||
|
input: content,
|
||||||
|
docId: doc.id,
|
||||||
|
attachments: images,
|
||||||
|
workspaceId: doc.collection.id,
|
||||||
|
host: this.host,
|
||||||
|
stream: true,
|
||||||
|
signal: abortController.signal,
|
||||||
|
where: 'chat-panel',
|
||||||
|
control: 'chat-send',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
this.updateContext({ abortController });
|
||||||
|
|
||||||
|
for await (const text of stream) {
|
||||||
|
const items = [...this.chatContextValue.items];
|
||||||
|
const last = items[items.length - 1] as ChatMessage;
|
||||||
|
last.content += text;
|
||||||
|
this.updateContext({ items, status: 'transmitting' });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateContext({ status: 'success' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.updateContext({ status: 'error', error: error as AIError });
|
||||||
|
} finally {
|
||||||
|
this.updateContext({ abortController: null });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'chat-panel-input': ChatPanelInput;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,523 @@
|
|||||||
|
import '../messages/slides-renderer.js';
|
||||||
|
import './ai-loading.js';
|
||||||
|
import '../messages/text.js';
|
||||||
|
import './actions/text.js';
|
||||||
|
import './actions/action-wrapper.js';
|
||||||
|
import './actions/make-real.js';
|
||||||
|
import './actions/slides.js';
|
||||||
|
import './actions/mindmap.js';
|
||||||
|
import './actions/chat-text.js';
|
||||||
|
import './actions/copy-more.js';
|
||||||
|
import './actions/image-to-text.js';
|
||||||
|
import './actions/image.js';
|
||||||
|
import './chat-cards.js';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BaseSelection,
|
||||||
|
BlockSelection,
|
||||||
|
EditorHost,
|
||||||
|
TextSelection,
|
||||||
|
} from '@blocksuite/block-std';
|
||||||
|
import { ShadowlessElement, WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import type { ImageSelection } from '@blocksuite/blocks';
|
||||||
|
import {
|
||||||
|
isInsidePageEditor,
|
||||||
|
PaymentRequiredError,
|
||||||
|
UnauthorizedError,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { css, html, nothing, type PropertyValues } from 'lit';
|
||||||
|
import { customElement, property, query, state } from 'lit/decorators.js';
|
||||||
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AffineAvatarIcon,
|
||||||
|
AffineIcon,
|
||||||
|
DownArrowIcon,
|
||||||
|
} from '../_common/icons.js';
|
||||||
|
import {
|
||||||
|
GeneralErrorRenderer,
|
||||||
|
PaymentRequiredErrorRenderer,
|
||||||
|
} from '../messages/error.js';
|
||||||
|
import { AIProvider } from '../provider.js';
|
||||||
|
import { insertBelow } from '../utils/editor-actions.js';
|
||||||
|
import {
|
||||||
|
EdgelessEditorActions,
|
||||||
|
PageEditorActions,
|
||||||
|
} from './actions/actions-handle.js';
|
||||||
|
import type {
|
||||||
|
ChatContextValue,
|
||||||
|
ChatItem,
|
||||||
|
ChatMessage,
|
||||||
|
} from './chat-context.js';
|
||||||
|
import { HISTORY_IMAGE_ACTIONS } from './const.js';
|
||||||
|
|
||||||
|
@customElement('chat-panel-messages')
|
||||||
|
export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
||||||
|
private get _currentTextSelection(): TextSelection | undefined {
|
||||||
|
return this._selectionValue.find(v => v.type === 'text') as TextSelection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get _currentBlockSelections(): BlockSelection[] | undefined {
|
||||||
|
return this._selectionValue.filter(v => v.type === 'block');
|
||||||
|
}
|
||||||
|
|
||||||
|
private get _currentImageSelections(): ImageSelection[] | undefined {
|
||||||
|
return this._selectionValue.filter(v => v.type === 'image');
|
||||||
|
}
|
||||||
|
|
||||||
|
static override styles = css`
|
||||||
|
chat-panel-messages {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-messages {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
chat-cards {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-messages-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-wrapper {
|
||||||
|
margin-left: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--affine-text-primary-color);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-container {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--affine-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-container img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.down-indicator {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
bottom: 24px;
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 0.5px solid var(--affine-border-color);
|
||||||
|
background-color: var(--affine-background-primary-color);
|
||||||
|
box-shadow: var(--affine-shadow-2);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
private _selectionValue: BaseSelection[] = [];
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor showDownIndicator = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor avatarUrl = '';
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor isLoading!: boolean;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor chatContextValue!: ChatContextValue;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor updateContext!: (context: Partial<ChatContextValue>) => void;
|
||||||
|
|
||||||
|
@query('.chat-panel-messages')
|
||||||
|
accessor messagesContainer!: HTMLDivElement;
|
||||||
|
|
||||||
|
protected override updated(_changedProperties: PropertyValues) {
|
||||||
|
if (_changedProperties.has('host')) {
|
||||||
|
const { disposables } = this;
|
||||||
|
|
||||||
|
disposables.add(
|
||||||
|
this.host.selection.slots.changed.on(() => {
|
||||||
|
this._selectionValue = this.host.selection.value;
|
||||||
|
this.requestUpdate();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const { docModeService } = this.host.spec.getService('affine:page');
|
||||||
|
disposables.add(docModeService.onModeChange(() => this.requestUpdate()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override render() {
|
||||||
|
const { items } = this.chatContextValue;
|
||||||
|
const { isLoading } = this;
|
||||||
|
const filteredItems = items.filter(item => {
|
||||||
|
return (
|
||||||
|
'role' in item ||
|
||||||
|
item.messages?.length === 3 ||
|
||||||
|
(HISTORY_IMAGE_ACTIONS.includes(item.action) &&
|
||||||
|
item.messages?.length === 2)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return html`<style>
|
||||||
|
.chat-panel-messages-placeholder div {
|
||||||
|
color: ${isLoading
|
||||||
|
? 'var(--affine-text-secondary-color)'
|
||||||
|
: 'var(--affine-text-primary-color)'};
|
||||||
|
font-size: ${isLoading ? 'var(--affine-font-sm)' : '18px'};
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="chat-panel-messages"
|
||||||
|
@scroll=${(evt: Event) => {
|
||||||
|
const element = evt.target as HTMLDivElement;
|
||||||
|
this.showDownIndicator =
|
||||||
|
element.scrollHeight - element.scrollTop - element.clientHeight >
|
||||||
|
200;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${items.length === 0
|
||||||
|
? html`<div class="chat-panel-messages-placeholder">
|
||||||
|
${AffineIcon(
|
||||||
|
isLoading
|
||||||
|
? 'var(--affine-icon-secondary)'
|
||||||
|
: 'var(--affine-primary-color)'
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
${this.isLoading
|
||||||
|
? 'AFFiNE AI is loading history...'
|
||||||
|
: 'What can I help you with?'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<chat-cards
|
||||||
|
.chatContextValue=${this.chatContextValue}
|
||||||
|
.updateContext=${this.updateContext}
|
||||||
|
.host=${this.host}
|
||||||
|
.selectionValue=${this._selectionValue}
|
||||||
|
></chat-cards> `
|
||||||
|
: repeat(filteredItems, (item, index) => {
|
||||||
|
const isLast = index === filteredItems.length - 1;
|
||||||
|
return html`<div class="message">
|
||||||
|
${this.renderAvatar(item)}
|
||||||
|
<div class="item-wrapper">${this.renderItem(item, isLast)}</div>
|
||||||
|
</div>`;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
${this.showDownIndicator
|
||||||
|
? html`<div class="down-indicator" @click=${() => this.scrollToDown()}>
|
||||||
|
${DownArrowIcon}
|
||||||
|
</div>`
|
||||||
|
: nothing} `;
|
||||||
|
}
|
||||||
|
|
||||||
|
override async connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
const res = await AIProvider.userInfo;
|
||||||
|
this.avatarUrl = res?.avatarUrl ?? '';
|
||||||
|
this.disposables.add(
|
||||||
|
AIProvider.slots.userInfo.on(userInfo => {
|
||||||
|
const { status, error } = this.chatContextValue;
|
||||||
|
this.avatarUrl = userInfo?.avatarUrl ?? '';
|
||||||
|
if (
|
||||||
|
status === 'error' &&
|
||||||
|
error instanceof UnauthorizedError &&
|
||||||
|
userInfo
|
||||||
|
) {
|
||||||
|
this.updateContext({ status: 'idle', error: null });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderError() {
|
||||||
|
const { error } = this.chatContextValue;
|
||||||
|
|
||||||
|
if (error instanceof PaymentRequiredError) {
|
||||||
|
return PaymentRequiredErrorRenderer(this.host);
|
||||||
|
} else if (error instanceof UnauthorizedError) {
|
||||||
|
return GeneralErrorRenderer(
|
||||||
|
html`You need to login to AFFiNE Cloud to continue using AFFiNE AI.`,
|
||||||
|
html`<div
|
||||||
|
style=${styleMap({
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid var(--affine-border-color)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: 'var(--affine-hover-color)',
|
||||||
|
})}
|
||||||
|
@click=${() =>
|
||||||
|
AIProvider.slots.requestLogin.emit({ host: this.host })}
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</div>`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return GeneralErrorRenderer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderItem(item: ChatItem, isLast: boolean) {
|
||||||
|
const { status, error } = this.chatContextValue;
|
||||||
|
|
||||||
|
if (isLast && status === 'loading') {
|
||||||
|
return this.renderLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isLast &&
|
||||||
|
status === 'error' &&
|
||||||
|
(error instanceof PaymentRequiredError ||
|
||||||
|
error instanceof UnauthorizedError)
|
||||||
|
) {
|
||||||
|
return this.renderError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('role' in item) {
|
||||||
|
const state = isLast
|
||||||
|
? status !== 'loading' && status !== 'transmitting'
|
||||||
|
? 'finished'
|
||||||
|
: 'generating'
|
||||||
|
: 'finished';
|
||||||
|
return html`<chat-text
|
||||||
|
.host=${this.host}
|
||||||
|
.attachments=${item.attachments}
|
||||||
|
.text=${item.content}
|
||||||
|
.state=${state}
|
||||||
|
></chat-text>
|
||||||
|
${isLast && status === 'error' ? this.renderError() : nothing}
|
||||||
|
${this.renderEditorActions(item, isLast)}`;
|
||||||
|
} else {
|
||||||
|
switch (item.action) {
|
||||||
|
case 'Create a presentation':
|
||||||
|
return html`<action-slides
|
||||||
|
.host=${this.host}
|
||||||
|
.item=${item}
|
||||||
|
></action-slides>`;
|
||||||
|
case 'Make it real':
|
||||||
|
return html`<action-make-real
|
||||||
|
.host=${this.host}
|
||||||
|
.item=${item}
|
||||||
|
></action-make-real>`;
|
||||||
|
case 'Brainstorm mindmap':
|
||||||
|
return html`<action-mindmap
|
||||||
|
.host=${this.host}
|
||||||
|
.item=${item}
|
||||||
|
></action-mindmap>`;
|
||||||
|
case 'Explain this image':
|
||||||
|
case 'Generate a caption':
|
||||||
|
return html`<action-image-to-text
|
||||||
|
.host=${this.host}
|
||||||
|
.item=${item}
|
||||||
|
></action-image-to-text>`;
|
||||||
|
default:
|
||||||
|
if (HISTORY_IMAGE_ACTIONS.includes(item.action)) {
|
||||||
|
return html`<action-image
|
||||||
|
.host=${this.host}
|
||||||
|
.item=${item}
|
||||||
|
></action-image>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`<action-text
|
||||||
|
.item=${item}
|
||||||
|
.host=${this.host}
|
||||||
|
.isCode=${item.action === 'Explain this code' ||
|
||||||
|
item.action === 'Check code error'}
|
||||||
|
></action-text>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAvatar(item: ChatItem) {
|
||||||
|
const isUser = 'role' in item && item.role === 'user';
|
||||||
|
|
||||||
|
return html`<div class="user-info">
|
||||||
|
${isUser
|
||||||
|
? html`<div class="avatar-container">
|
||||||
|
${this.avatarUrl
|
||||||
|
? html`<img .src=${this.avatarUrl} />`
|
||||||
|
: html`<div class="avatar"></div>`}
|
||||||
|
</div>`
|
||||||
|
: AffineAvatarIcon}
|
||||||
|
${isUser ? 'You' : 'AFFINE AI'}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLoading() {
|
||||||
|
return html` <ai-loading></ai-loading>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToDown() {
|
||||||
|
this.messagesContainer.scrollTo(0, this.messagesContainer.scrollHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderEditorActions(item: ChatMessage, isLast: boolean) {
|
||||||
|
const { status } = this.chatContextValue;
|
||||||
|
|
||||||
|
if (item.role !== 'assistant') return nothing;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isLast &&
|
||||||
|
status !== 'success' &&
|
||||||
|
status !== 'idle' &&
|
||||||
|
status !== 'error'
|
||||||
|
)
|
||||||
|
return nothing;
|
||||||
|
|
||||||
|
const { host } = this;
|
||||||
|
const { content } = item;
|
||||||
|
const actions = isInsidePageEditor(host)
|
||||||
|
? PageEditorActions
|
||||||
|
: EdgelessEditorActions;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<style>
|
||||||
|
.actions-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-container > div {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action {
|
||||||
|
width: fit-content;
|
||||||
|
height: 32px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--affine-border-color);
|
||||||
|
background-color: var(--affine-white-10);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--affine-text-primary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action svg {
|
||||||
|
color: var(--affine-icon-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<chat-copy-more
|
||||||
|
.host=${host}
|
||||||
|
.content=${content}
|
||||||
|
.isLast=${isLast}
|
||||||
|
.curTextSelection=${this._currentTextSelection}
|
||||||
|
.curBlockSelections=${this._currentBlockSelections}
|
||||||
|
.chatContextValue=${this.chatContextValue}
|
||||||
|
.updateContext=${this.updateContext}
|
||||||
|
></chat-copy-more>
|
||||||
|
${isLast
|
||||||
|
? html`<div class="actions-container">
|
||||||
|
${repeat(
|
||||||
|
actions.filter(action => {
|
||||||
|
if (!content) return false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
action.title === 'Replace selection' &&
|
||||||
|
(!this._currentTextSelection ||
|
||||||
|
this._currentTextSelection.from.length === 0) &&
|
||||||
|
this._currentBlockSelections?.length === 0
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
action => action.title,
|
||||||
|
action => {
|
||||||
|
return html`<div class="action">
|
||||||
|
${action.icon}
|
||||||
|
<div
|
||||||
|
@click=${async () => {
|
||||||
|
if (
|
||||||
|
action.title === 'Insert below' &&
|
||||||
|
this._selectionValue.length === 1 &&
|
||||||
|
this._selectionValue[0].type === 'database'
|
||||||
|
) {
|
||||||
|
const element = this.host.view.getBlock(
|
||||||
|
this._selectionValue[0].blockId
|
||||||
|
);
|
||||||
|
if (!element) return;
|
||||||
|
await insertBelow(host, content, element);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await action.handler(
|
||||||
|
host,
|
||||||
|
content,
|
||||||
|
this._currentTextSelection,
|
||||||
|
this._currentBlockSelections,
|
||||||
|
this._currentImageSelections
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${action.title}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'chat-panel-messages': ChatPanelMessages;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
import { html } from 'lit';
|
||||||
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
|
|
||||||
|
export const renderImages = (images: string[]) => {
|
||||||
|
return html`<style>
|
||||||
|
.images-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.image-container {
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 70%;
|
||||||
|
max-width: 320px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="images-container">
|
||||||
|
${repeat(
|
||||||
|
images,
|
||||||
|
image => image,
|
||||||
|
image => {
|
||||||
|
return html`<div class="image-container">
|
||||||
|
<img src="${image}" />
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</div>`;
|
||||||
|
};
|
@ -0,0 +1,10 @@
|
|||||||
|
export const HISTORY_IMAGE_ACTIONS = [
|
||||||
|
'image',
|
||||||
|
'AI image filter clay style',
|
||||||
|
'AI image filter sketch style',
|
||||||
|
'AI image filter anime style',
|
||||||
|
'AI image filter pixel style',
|
||||||
|
'Clearer',
|
||||||
|
'Remove background',
|
||||||
|
'Convert to sticker',
|
||||||
|
];
|
@ -0,0 +1,278 @@
|
|||||||
|
import './chat-panel-input.js';
|
||||||
|
import './chat-panel-messages.js';
|
||||||
|
|
||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import { ShadowlessElement, WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import { debounce } from '@blocksuite/global/utils';
|
||||||
|
import type { Doc } from '@blocksuite/store';
|
||||||
|
import { css, html, type PropertyValues } from 'lit';
|
||||||
|
import { customElement, property, state } from 'lit/decorators.js';
|
||||||
|
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
|
||||||
|
|
||||||
|
import { AIHelpIcon, SmallHintIcon } from '../_common/icons.js';
|
||||||
|
import { AIProvider } from '../provider.js';
|
||||||
|
import {
|
||||||
|
getSelectedImagesAsBlobs,
|
||||||
|
getSelectedTextContent,
|
||||||
|
} from '../utils/selection-utils.js';
|
||||||
|
import type { ChatAction, ChatContextValue, ChatItem } from './chat-context.js';
|
||||||
|
import type { ChatPanelMessages } from './chat-panel-messages.js';
|
||||||
|
|
||||||
|
@customElement('chat-panel')
|
||||||
|
export class ChatPanel extends WithDisposable(ShadowlessElement) {
|
||||||
|
static override styles = css`
|
||||||
|
chat-panel {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0 12px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-title {
|
||||||
|
padding: 8px 0px;
|
||||||
|
width: 100%;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
div:first-child {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--affine-text-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
div:last-child {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chat-panel-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-hints {
|
||||||
|
margin: 0 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--affine-border-color);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-hints :first-child {
|
||||||
|
color: var(--affine-text-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-hints :nth-child(2) {
|
||||||
|
color: var(--affine-text-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-footer {
|
||||||
|
margin: 8px 0px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--affine-text-secondary-color);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
private readonly _chatMessages: Ref<ChatPanelMessages> =
|
||||||
|
createRef<ChatPanelMessages>();
|
||||||
|
|
||||||
|
private _chatSessionId = '';
|
||||||
|
|
||||||
|
private _resettingCounter = 0;
|
||||||
|
|
||||||
|
private readonly _resetItems = debounce(() => {
|
||||||
|
const counter = ++this._resettingCounter;
|
||||||
|
this.isLoading = true;
|
||||||
|
(async () => {
|
||||||
|
const { doc } = this;
|
||||||
|
|
||||||
|
const [histories, actions] = await Promise.all([
|
||||||
|
AIProvider.histories?.chats(doc.collection.id, doc.id),
|
||||||
|
AIProvider.histories?.actions(doc.collection.id, doc.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (counter !== this._resettingCounter) return;
|
||||||
|
|
||||||
|
const items: ChatItem[] = actions ? [...actions] : [];
|
||||||
|
|
||||||
|
if (histories?.[0]) {
|
||||||
|
this._chatSessionId = histories[0].sessionId;
|
||||||
|
items.push(...histories[0].messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.chatContextValue = {
|
||||||
|
...this.chatContextValue,
|
||||||
|
items: items.sort((a, b) => {
|
||||||
|
return (
|
||||||
|
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.isLoading = false;
|
||||||
|
this.scrollToDown();
|
||||||
|
})().catch(console.error);
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor doc!: Doc;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor isLoading = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor chatContextValue: ChatContextValue = {
|
||||||
|
quote: '',
|
||||||
|
images: [],
|
||||||
|
abortController: null,
|
||||||
|
items: [],
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
markdown: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _cleanupHistories = async () => {
|
||||||
|
const notification =
|
||||||
|
this.host.std.spec.getService('affine:page').notificationService;
|
||||||
|
if (!notification) return;
|
||||||
|
|
||||||
|
if (
|
||||||
|
await notification.confirm({
|
||||||
|
title: 'Clear History',
|
||||||
|
message:
|
||||||
|
'Are you sure you want to clear all history? This action will permanently delete all content, including all chat logs and data, and cannot be undone.',
|
||||||
|
confirmText: 'Confirm',
|
||||||
|
cancelText: 'Cancel',
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
await AIProvider.histories?.cleanup(this.doc.collection.id, this.doc.id, [
|
||||||
|
this._chatSessionId,
|
||||||
|
...(
|
||||||
|
this.chatContextValue.items.filter(
|
||||||
|
item => 'sessionId' in item
|
||||||
|
) as ChatAction[]
|
||||||
|
).map(item => item.sessionId),
|
||||||
|
]);
|
||||||
|
notification.toast('History cleared');
|
||||||
|
this._resetItems();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
protected override updated(_changedProperties: PropertyValues) {
|
||||||
|
if (_changedProperties.has('doc')) {
|
||||||
|
this._resetItems();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
if (!this.doc) throw new Error('doc is required');
|
||||||
|
|
||||||
|
AIProvider.slots.actions.on(({ action, event }) => {
|
||||||
|
const { status } = this.chatContextValue;
|
||||||
|
if (
|
||||||
|
action !== 'chat' &&
|
||||||
|
event === 'finished' &&
|
||||||
|
(status === 'idle' || status === 'success')
|
||||||
|
) {
|
||||||
|
this._resetItems();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
AIProvider.slots.userInfo.on(userInfo => {
|
||||||
|
if (userInfo) {
|
||||||
|
this._resetItems();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
AIProvider.slots.requestContinueInChat.on(async ({ show }) => {
|
||||||
|
if (show) {
|
||||||
|
const text = await getSelectedTextContent(this.host, 'plain-text');
|
||||||
|
const markdown = await getSelectedTextContent(this.host, 'markdown');
|
||||||
|
const images = await getSelectedImagesAsBlobs(this.host);
|
||||||
|
this.updateContext({
|
||||||
|
quote: text,
|
||||||
|
markdown: markdown,
|
||||||
|
images: images,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateContext = (context: Partial<ChatContextValue>) => {
|
||||||
|
this.chatContextValue = { ...this.chatContextValue, ...context };
|
||||||
|
};
|
||||||
|
|
||||||
|
continueInChat = async () => {
|
||||||
|
const text = await getSelectedTextContent(this.host, 'plain-text');
|
||||||
|
const markdown = await getSelectedTextContent(this.host, 'markdown');
|
||||||
|
const images = await getSelectedImagesAsBlobs(this.host);
|
||||||
|
this.updateContext({
|
||||||
|
quote: text,
|
||||||
|
markdown,
|
||||||
|
images,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
scrollToDown() {
|
||||||
|
requestAnimationFrame(() => this._chatMessages.value?.scrollToDown());
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
return html` <div class="chat-panel-container">
|
||||||
|
<div class="chat-panel-title">
|
||||||
|
<div>AFFINE AI</div>
|
||||||
|
<div
|
||||||
|
@click=${() => {
|
||||||
|
AIProvider.toggleGeneralAIOnboarding?.(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
${AIHelpIcon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<chat-panel-messages
|
||||||
|
${ref(this._chatMessages)}
|
||||||
|
.chatContextValue=${this.chatContextValue}
|
||||||
|
.updateContext=${this.updateContext}
|
||||||
|
.host=${this.host}
|
||||||
|
.isLoading=${this.isLoading}
|
||||||
|
></chat-panel-messages>
|
||||||
|
<chat-panel-input
|
||||||
|
.chatContextValue=${this.chatContextValue}
|
||||||
|
.updateContext=${this.updateContext}
|
||||||
|
.host=${this.host}
|
||||||
|
.cleanupHistories=${this._cleanupHistories}
|
||||||
|
></chat-panel-input>
|
||||||
|
<div class="chat-panel-footer">
|
||||||
|
${SmallHintIcon}
|
||||||
|
<div>AI outputs can be misleading or wrong</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'chat-panel': ChatPanel;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,84 @@
|
|||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import type { EdgelessRootService } from '@blocksuite/blocks';
|
||||||
|
import type { BlockSnapshot } from '@blocksuite/store';
|
||||||
|
|
||||||
|
import { markdownToSnapshot } from '../_common/markdown-utils.js';
|
||||||
|
import { getSurfaceElementFromEditor } from '../_common/selection-utils.js';
|
||||||
|
import { basicTheme } from '../slides/template.js';
|
||||||
|
|
||||||
|
type PPTSection = {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
keywords: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PPTDoc = {
|
||||||
|
isCover: boolean;
|
||||||
|
title: string;
|
||||||
|
sections: PPTSection[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PPTBuilder = (host: EditorHost) => {
|
||||||
|
const service = host.spec.getService<EdgelessRootService>('affine:page');
|
||||||
|
const docs: PPTDoc[] = [];
|
||||||
|
let done = false;
|
||||||
|
const addDoc = async (block: BlockSnapshot) => {
|
||||||
|
const sections = block.children.map(v => {
|
||||||
|
const title = getText(v);
|
||||||
|
const keywords = getText(v.children[0]);
|
||||||
|
const content = getText(v.children[1]);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
keywords,
|
||||||
|
content,
|
||||||
|
} satisfies PPTSection;
|
||||||
|
});
|
||||||
|
const doc: PPTDoc = {
|
||||||
|
isCover: docs.length === 0,
|
||||||
|
title: getText(block),
|
||||||
|
sections,
|
||||||
|
};
|
||||||
|
docs.push(doc);
|
||||||
|
|
||||||
|
if (doc.sections.length !== 3 || doc.isCover) return;
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
const job = service.createTemplateJob('template');
|
||||||
|
const { images, content } = await basicTheme(doc);
|
||||||
|
|
||||||
|
if (images.length) {
|
||||||
|
await Promise.all(
|
||||||
|
images.map(({ id, url }) =>
|
||||||
|
fetch(url)
|
||||||
|
.then(res => res.blob())
|
||||||
|
.then(blob => job.job.assets.set(id, blob))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await job.insertTemplate(content);
|
||||||
|
getSurfaceElementFromEditor(host).refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
process: async (text: string) => {
|
||||||
|
const snapshot = await markdownToSnapshot(text, host);
|
||||||
|
|
||||||
|
const block = snapshot.snapshot.content[0];
|
||||||
|
for (const child of block.children) {
|
||||||
|
await addDoc(child);
|
||||||
|
const { centerX, centerY, zoom } = service.getFitToScreenData();
|
||||||
|
service.viewport.setViewport(zoom, [centerX, centerY]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
done: async (text: string) => {
|
||||||
|
const snapshot = await markdownToSnapshot(text, host);
|
||||||
|
const block = snapshot.snapshot.content[0];
|
||||||
|
await addDoc(block.children[block.children.length - 1]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getText = (block: BlockSnapshot) => {
|
||||||
|
// @ts-expect-error allow
|
||||||
|
return block.props.text?.delta?.[0]?.insert ?? '';
|
||||||
|
};
|
@ -0,0 +1,55 @@
|
|||||||
|
import '../../_common/components/ask-ai-button.js';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AffineCodeToolbarWidget,
|
||||||
|
CodeBlockComponent,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { html } from 'lit';
|
||||||
|
|
||||||
|
const AICodeItemGroups = buildAICodeItemGroups();
|
||||||
|
const buttonOptions: AskAIButtonOptions = {
|
||||||
|
size: 'small',
|
||||||
|
panelWidth: 240,
|
||||||
|
};
|
||||||
|
|
||||||
|
import type { AskAIButtonOptions } from '../../_common/components/ask-ai-button.js';
|
||||||
|
import { buildAICodeItemGroups } from '../../_common/config.js';
|
||||||
|
import { AIStarIcon } from '../../_common/icons.js';
|
||||||
|
|
||||||
|
export function setupCodeToolbarEntry(codeToolbar: AffineCodeToolbarWidget) {
|
||||||
|
const onAskAIClick = () => {
|
||||||
|
const { host } = codeToolbar;
|
||||||
|
const { selection } = host;
|
||||||
|
const imageBlock = codeToolbar.blockElement;
|
||||||
|
selection.setGroup('note', [
|
||||||
|
selection.create('block', { blockId: imageBlock.blockId }),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
codeToolbar.setupDefaultConfig();
|
||||||
|
codeToolbar.addItems(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'custom',
|
||||||
|
name: 'Ask AI',
|
||||||
|
tooltip: 'Ask AI',
|
||||||
|
icon: AIStarIcon,
|
||||||
|
showWhen: () => true,
|
||||||
|
render(codeBlock: CodeBlockComponent, onClick?: () => void) {
|
||||||
|
return html`<ask-ai-button
|
||||||
|
class="code-toolbar-button ask-ai"
|
||||||
|
.host=${codeBlock.host}
|
||||||
|
.actionGroups=${AICodeItemGroups}
|
||||||
|
.toggleType=${'click'}
|
||||||
|
.options=${buttonOptions}
|
||||||
|
@click=${(e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAskAIClick();
|
||||||
|
onClick?.();
|
||||||
|
}}
|
||||||
|
></ask-ai-button>`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,513 @@
|
|||||||
|
import {
|
||||||
|
type AIItemGroupConfig,
|
||||||
|
AIStarIconWithAnimation,
|
||||||
|
BlocksUtils,
|
||||||
|
MindmapElementModel,
|
||||||
|
ShapeElementModel,
|
||||||
|
TextElementModel,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AIExpandMindMapIcon,
|
||||||
|
AIImageIcon,
|
||||||
|
AIImageIconWithAnimation,
|
||||||
|
AIMindMapIcon,
|
||||||
|
AIMindMapIconWithAnimation,
|
||||||
|
AIPenIcon,
|
||||||
|
AIPenIconWithAnimation,
|
||||||
|
AIPresentationIcon,
|
||||||
|
AIPresentationIconWithAnimation,
|
||||||
|
AISearchIcon,
|
||||||
|
ChatWithAIIcon,
|
||||||
|
ExplainIcon,
|
||||||
|
ImproveWritingIcon,
|
||||||
|
LanguageIcon,
|
||||||
|
LongerIcon,
|
||||||
|
MakeItRealIcon,
|
||||||
|
MakeItRealIconWithAnimation,
|
||||||
|
SelectionIcon,
|
||||||
|
ShorterIcon,
|
||||||
|
ToneIcon,
|
||||||
|
} from '../../_common/icons.js';
|
||||||
|
import {
|
||||||
|
actionToHandler,
|
||||||
|
experimentalImageActionsShowWhen,
|
||||||
|
imageOnlyShowWhen,
|
||||||
|
mindmapChildShowWhen,
|
||||||
|
mindmapRootShowWhen,
|
||||||
|
noteBlockOrTextShowWhen,
|
||||||
|
noteWithCodeBlockShowWen,
|
||||||
|
} from '../../actions/edgeless-handler.js';
|
||||||
|
import {
|
||||||
|
imageFilterStyles,
|
||||||
|
imageProcessingTypes,
|
||||||
|
textTones,
|
||||||
|
translateLangs,
|
||||||
|
} from '../../actions/types.js';
|
||||||
|
import { getAIPanel } from '../../ai-panel.js';
|
||||||
|
import { AIProvider } from '../../provider.js';
|
||||||
|
import { mindMapToMarkdown } from '../../utils/edgeless.js';
|
||||||
|
import { canvasToBlob, randomSeed } from '../../utils/image.js';
|
||||||
|
import {
|
||||||
|
getCopilotSelectedElems,
|
||||||
|
getEdgelessRootFromEditor,
|
||||||
|
imageCustomInput,
|
||||||
|
} from '../../utils/selection-utils.js';
|
||||||
|
|
||||||
|
const translateSubItem = translateLangs.map(lang => {
|
||||||
|
return {
|
||||||
|
type: lang,
|
||||||
|
handler: actionToHandler('translate', AIStarIconWithAnimation, { lang }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const toneSubItem = textTones.map(tone => {
|
||||||
|
return {
|
||||||
|
type: tone,
|
||||||
|
handler: actionToHandler('changeTone', AIStarIconWithAnimation, { tone }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const imageFilterSubItem = imageFilterStyles.map(style => {
|
||||||
|
return {
|
||||||
|
type: style,
|
||||||
|
handler: actionToHandler(
|
||||||
|
'filterImage',
|
||||||
|
AIImageIconWithAnimation,
|
||||||
|
{
|
||||||
|
style,
|
||||||
|
},
|
||||||
|
imageCustomInput
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const imageProcessingSubItem = imageProcessingTypes.map(type => {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
handler: actionToHandler(
|
||||||
|
'processImage',
|
||||||
|
AIImageIconWithAnimation,
|
||||||
|
{
|
||||||
|
type,
|
||||||
|
},
|
||||||
|
imageCustomInput
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const othersGroup: AIItemGroupConfig = {
|
||||||
|
name: 'others',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Open AI Chat',
|
||||||
|
icon: ChatWithAIIcon,
|
||||||
|
showWhen: () => true,
|
||||||
|
handler: host => {
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
AIProvider.slots.requestContinueInChat.emit({
|
||||||
|
host: host,
|
||||||
|
show: true,
|
||||||
|
});
|
||||||
|
panel.hide();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const editGroup: AIItemGroupConfig = {
|
||||||
|
name: 'edit with ai',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Translate to',
|
||||||
|
icon: LanguageIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
subItem: translateSubItem,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Change tone to',
|
||||||
|
icon: ToneIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
subItem: toneSubItem,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Improve writing',
|
||||||
|
icon: ImproveWritingIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
handler: actionToHandler('improveWriting', AIStarIconWithAnimation),
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'Make it longer',
|
||||||
|
icon: LongerIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
handler: actionToHandler('makeLonger', AIStarIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Make it shorter',
|
||||||
|
icon: ShorterIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
handler: actionToHandler('makeShorter', AIStarIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Continue writing',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
handler: actionToHandler('continueWriting', AIPenIconWithAnimation),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const draftGroup: AIItemGroupConfig = {
|
||||||
|
name: 'draft with ai',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Write an article about this',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
handler: actionToHandler('writeArticle', AIPenIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Write a tweet about this',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
handler: actionToHandler('writeTwitterPost', AIPenIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Write a poem about this',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
handler: actionToHandler('writePoem', AIPenIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Write a blog post about this',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
handler: actionToHandler('writeBlogPost', AIPenIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Brainstorm ideas about this',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
handler: actionToHandler('brainstorm', AIPenIconWithAnimation),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const reviewGroup: AIItemGroupConfig = {
|
||||||
|
name: 'review with ai',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Fix spelling',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
handler: actionToHandler('fixSpelling', AIStarIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Fix grammar',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
handler: actionToHandler('improveGrammar', AIStarIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Explain this image',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: imageOnlyShowWhen,
|
||||||
|
handler: actionToHandler(
|
||||||
|
'explainImage',
|
||||||
|
AIStarIconWithAnimation,
|
||||||
|
undefined,
|
||||||
|
imageCustomInput
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Explain this code',
|
||||||
|
icon: ExplainIcon,
|
||||||
|
showWhen: noteWithCodeBlockShowWen,
|
||||||
|
handler: actionToHandler('explainCode', AIStarIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Check code error',
|
||||||
|
icon: ExplainIcon,
|
||||||
|
showWhen: noteWithCodeBlockShowWen,
|
||||||
|
handler: actionToHandler('checkCodeErrors', AIStarIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Explain selection',
|
||||||
|
icon: SelectionIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
handler: actionToHandler('explain', AIStarIconWithAnimation),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateGroup: AIItemGroupConfig = {
|
||||||
|
name: 'generate with ai',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'Summarize',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
handler: actionToHandler('summary', AIPenIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Generate headings',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
handler: actionToHandler('createHeadings', AIPenIconWithAnimation),
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Generate an image',
|
||||||
|
icon: AIImageIcon,
|
||||||
|
showWhen: () => true,
|
||||||
|
handler: actionToHandler(
|
||||||
|
'createImage',
|
||||||
|
AIImageIconWithAnimation,
|
||||||
|
undefined,
|
||||||
|
async (host, ctx) => {
|
||||||
|
const selectedElements = getCopilotSelectedElems(host);
|
||||||
|
const len = selectedElements.length;
|
||||||
|
|
||||||
|
const aiPanel = getAIPanel(host);
|
||||||
|
// text to image
|
||||||
|
// from user input
|
||||||
|
if (len === 0) {
|
||||||
|
const content = aiPanel.inputText?.trim();
|
||||||
|
if (!content) return;
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = (ctx.get()['content'] as string) || '';
|
||||||
|
|
||||||
|
// from user input
|
||||||
|
if (content.length === 0) {
|
||||||
|
content = aiPanel.inputText?.trim() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
images,
|
||||||
|
shapes,
|
||||||
|
notes: _,
|
||||||
|
frames: __,
|
||||||
|
} = BlocksUtils.splitElements(selectedElements);
|
||||||
|
|
||||||
|
const pureShapes = shapes.filter(
|
||||||
|
e =>
|
||||||
|
!(
|
||||||
|
e instanceof TextElementModel ||
|
||||||
|
(e instanceof ShapeElementModel && e.text?.length)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// text to image
|
||||||
|
if (content.length && images.length + pureShapes.length === 0) {
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// image to image
|
||||||
|
const edgelessRoot = getEdgelessRootFromEditor(host);
|
||||||
|
const canvas = await edgelessRoot.clipboardController.toCanvas(
|
||||||
|
images,
|
||||||
|
pureShapes,
|
||||||
|
{
|
||||||
|
dpr: 1,
|
||||||
|
padding: 0,
|
||||||
|
background: 'white',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const png = await canvasToBlob(canvas);
|
||||||
|
if (!png) return;
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
attachments: [png],
|
||||||
|
seed: String(randomSeed()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Generate outline',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
handler: actionToHandler('writeOutline', AIPenIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Expand from this mind map node',
|
||||||
|
icon: AIExpandMindMapIcon,
|
||||||
|
showWhen: mindmapChildShowWhen,
|
||||||
|
handler: actionToHandler(
|
||||||
|
'expandMindmap',
|
||||||
|
AIMindMapIconWithAnimation,
|
||||||
|
undefined,
|
||||||
|
function (host) {
|
||||||
|
const selected = getCopilotSelectedElems(host);
|
||||||
|
const firstSelected = selected[0] as ShapeElementModel;
|
||||||
|
const mindmap = firstSelected?.group;
|
||||||
|
|
||||||
|
if (!(mindmap instanceof MindmapElementModel)) {
|
||||||
|
return Promise.resolve({});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
input: firstSelected.text?.toString() ?? '',
|
||||||
|
mindmap: mindMapToMarkdown(mindmap),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
),
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Brainstorm ideas with mind map',
|
||||||
|
icon: AIMindMapIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
handler: actionToHandler('brainstormMindmap', AIMindMapIconWithAnimation),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Regenerate mind map',
|
||||||
|
icon: AIMindMapIcon,
|
||||||
|
showWhen: mindmapRootShowWhen,
|
||||||
|
handler: actionToHandler(
|
||||||
|
'brainstormMindmap',
|
||||||
|
AIMindMapIconWithAnimation,
|
||||||
|
{
|
||||||
|
regenerate: true,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Generate presentation',
|
||||||
|
icon: AIPresentationIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
handler: actionToHandler('createSlides', AIPresentationIconWithAnimation),
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Make it real',
|
||||||
|
icon: MakeItRealIcon,
|
||||||
|
beta: true,
|
||||||
|
showWhen: () => true,
|
||||||
|
handler: actionToHandler(
|
||||||
|
'makeItReal',
|
||||||
|
MakeItRealIconWithAnimation,
|
||||||
|
undefined,
|
||||||
|
async (host, ctx) => {
|
||||||
|
const selectedElements = getCopilotSelectedElems(host);
|
||||||
|
|
||||||
|
// from user input
|
||||||
|
if (selectedElements.length === 0) {
|
||||||
|
const aiPanel = getAIPanel(host);
|
||||||
|
const content = aiPanel.inputText?.trim();
|
||||||
|
if (!content) return;
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const { notes, frames, shapes, images, edgelessTexts } =
|
||||||
|
BlocksUtils.splitElements(selectedElements);
|
||||||
|
const f = frames.length;
|
||||||
|
const i = images.length;
|
||||||
|
const n = notes.length;
|
||||||
|
const s = shapes.length;
|
||||||
|
const e = edgelessTexts.length;
|
||||||
|
|
||||||
|
if (f + i + n + s + e === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let content = (ctx.get()['content'] as string) || '';
|
||||||
|
|
||||||
|
// single note, text
|
||||||
|
if (
|
||||||
|
i === 0 &&
|
||||||
|
n + s + e === 1 &&
|
||||||
|
(n === 1 ||
|
||||||
|
e === 1 ||
|
||||||
|
(s === 1 && shapes[0] instanceof TextElementModel))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// from user input
|
||||||
|
if (content.length === 0) {
|
||||||
|
const aiPanel = getAIPanel(host);
|
||||||
|
content = aiPanel.inputText?.trim() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const edgelessRoot = getEdgelessRootFromEditor(host);
|
||||||
|
const canvas = await edgelessRoot.clipboardController.toCanvas(
|
||||||
|
[...notes, ...frames, ...images],
|
||||||
|
shapes,
|
||||||
|
{
|
||||||
|
dpr: 1,
|
||||||
|
background: 'white',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!canvas) return;
|
||||||
|
const png = await canvasToBlob(canvas);
|
||||||
|
if (!png) return;
|
||||||
|
ctx.set({
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
attachments: [png],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'AI image filter',
|
||||||
|
icon: ImproveWritingIcon,
|
||||||
|
showWhen: experimentalImageActionsShowWhen,
|
||||||
|
subItem: imageFilterSubItem,
|
||||||
|
subItemOffset: [12, -4],
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Image processing',
|
||||||
|
icon: AIImageIcon,
|
||||||
|
showWhen: experimentalImageActionsShowWhen,
|
||||||
|
subItem: imageProcessingSubItem,
|
||||||
|
subItemOffset: [12, -6],
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Generate a caption',
|
||||||
|
icon: AIPenIcon,
|
||||||
|
showWhen: experimentalImageActionsShowWhen,
|
||||||
|
beta: true,
|
||||||
|
handler: actionToHandler(
|
||||||
|
'generateCaption',
|
||||||
|
AIStarIconWithAnimation,
|
||||||
|
undefined,
|
||||||
|
imageCustomInput
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Find actions',
|
||||||
|
icon: AISearchIcon,
|
||||||
|
showWhen: noteBlockOrTextShowWhen,
|
||||||
|
handler: actionToHandler('findActions', AIStarIconWithAnimation),
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const edgelessActionGroups = [
|
||||||
|
reviewGroup,
|
||||||
|
editGroup,
|
||||||
|
generateGroup,
|
||||||
|
draftGroup,
|
||||||
|
othersGroup,
|
||||||
|
];
|
@ -0,0 +1,47 @@
|
|||||||
|
import type {
|
||||||
|
AIItemGroupConfig,
|
||||||
|
EdgelessCopilotWidget,
|
||||||
|
EdgelessElementToolbarWidget,
|
||||||
|
EdgelessRootBlockComponent,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { EdgelessCopilotToolbarEntry } from '@blocksuite/blocks';
|
||||||
|
import { noop } from '@blocksuite/global/utils';
|
||||||
|
import { html } from 'lit';
|
||||||
|
|
||||||
|
import { edgelessActionGroups } from './actions-config.js';
|
||||||
|
|
||||||
|
noop(EdgelessCopilotToolbarEntry);
|
||||||
|
|
||||||
|
export function setupEdgelessCopilot(widget: EdgelessCopilotWidget) {
|
||||||
|
widget.groups = edgelessActionGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupEdgelessElementToolbarEntry(
|
||||||
|
widget: EdgelessElementToolbarWidget
|
||||||
|
) {
|
||||||
|
widget.registerEntry({
|
||||||
|
when: () => {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
render: (edgeless: EdgelessRootBlockComponent) => {
|
||||||
|
const chain = edgeless.service.std.command.chain();
|
||||||
|
const filteredGroups = edgelessActionGroups.reduce((pre, group) => {
|
||||||
|
const filtered = group.items.filter(item =>
|
||||||
|
item.showWhen?.(chain, 'edgeless', edgeless.host)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (filtered.length > 0) pre.push({ ...group, items: filtered });
|
||||||
|
|
||||||
|
return pre;
|
||||||
|
}, [] as AIItemGroupConfig[]);
|
||||||
|
|
||||||
|
if (filteredGroups.every(group => group.items.length === 0)) return null;
|
||||||
|
|
||||||
|
return html`<edgeless-copilot-toolbar-entry
|
||||||
|
.edgeless=${edgeless}
|
||||||
|
.host=${edgeless.host}
|
||||||
|
.groups=${edgelessActionGroups}
|
||||||
|
></edgeless-copilot-toolbar-entry>`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
import '../../_common/components/ask-ai-button.js';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AffineFormatBarWidget,
|
||||||
|
toolbarDefaultConfig,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { html, type TemplateResult } from 'lit';
|
||||||
|
|
||||||
|
import { AIItemGroups } from '../../_common/config.js';
|
||||||
|
|
||||||
|
export function setupFormatBarEntry(formatBar: AffineFormatBarWidget) {
|
||||||
|
toolbarDefaultConfig(formatBar);
|
||||||
|
formatBar.addRawConfigItems(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'custom' as const,
|
||||||
|
render(formatBar: AffineFormatBarWidget): TemplateResult | null {
|
||||||
|
return html` <ask-ai-button
|
||||||
|
.host=${formatBar.host}
|
||||||
|
.actionGroups=${AIItemGroups}
|
||||||
|
.toggleType=${'hover'}
|
||||||
|
></ask-ai-button>`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 'divider' },
|
||||||
|
],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,52 @@
|
|||||||
|
import '../../_common/components/ask-ai-button.js';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
AffineImageToolbarWidget,
|
||||||
|
ImageBlockComponent,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { html } from 'lit';
|
||||||
|
|
||||||
|
import type { AskAIButtonOptions } from '../../_common/components/ask-ai-button.js';
|
||||||
|
import { buildAIImageItemGroups } from '../../_common/config.js';
|
||||||
|
|
||||||
|
const AIImageItemGroups = buildAIImageItemGroups();
|
||||||
|
const buttonOptions: AskAIButtonOptions = {
|
||||||
|
size: 'small',
|
||||||
|
backgroundColor: 'var(--affine-white)',
|
||||||
|
panelWidth: 300,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function setupImageToolbarEntry(imageToolbar: AffineImageToolbarWidget) {
|
||||||
|
const onAskAIClick = () => {
|
||||||
|
const { host } = imageToolbar;
|
||||||
|
const { selection } = host;
|
||||||
|
const imageBlock = imageToolbar.blockElement;
|
||||||
|
selection.setGroup('note', [
|
||||||
|
selection.create('image', { blockId: imageBlock.blockId }),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
imageToolbar.buildDefaultConfig();
|
||||||
|
imageToolbar.addConfigItems(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: 'custom',
|
||||||
|
render(imageBlock: ImageBlockComponent, onClick?: () => void) {
|
||||||
|
return html`<ask-ai-button
|
||||||
|
class="image-toolbar-button ask-ai"
|
||||||
|
.host=${imageBlock.host}
|
||||||
|
.actionGroups=${AIImageItemGroups}
|
||||||
|
.toggleType=${'click'}
|
||||||
|
.options=${buttonOptions}
|
||||||
|
@click=${(e: MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onAskAIClick();
|
||||||
|
onClick?.();
|
||||||
|
}}
|
||||||
|
></ask-ai-button>`;
|
||||||
|
},
|
||||||
|
showWhen: () => true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,2 @@
|
|||||||
|
export * from './format-bar/setup-format-bar.js';
|
||||||
|
export * from './space/setup-space.js';
|
@ -0,0 +1,138 @@
|
|||||||
|
import type {
|
||||||
|
AffineAIPanelWidget,
|
||||||
|
AffineSlashMenuActionItem,
|
||||||
|
AffineSlashMenuContext,
|
||||||
|
AffineSlashMenuItem,
|
||||||
|
AffineSlashSubMenu,
|
||||||
|
AIItemConfig,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import {
|
||||||
|
AFFINE_AI_PANEL_WIDGET,
|
||||||
|
AffineSlashMenuWidget,
|
||||||
|
AIStarIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
|
import { html } from 'lit';
|
||||||
|
|
||||||
|
import { AIItemGroups } from '../../_common/config.js';
|
||||||
|
import { handleInlineAskAIAction } from '../../actions/doc-handler.js';
|
||||||
|
import { AIProvider } from '../../provider.js';
|
||||||
|
|
||||||
|
export function setupSlashMenuEntry(slashMenu: AffineSlashMenuWidget) {
|
||||||
|
const AIItems = AIItemGroups.map(group => group.items).flat();
|
||||||
|
|
||||||
|
const iconWrapper = (icon: AIItemConfig['icon']) => {
|
||||||
|
return html`<div style="color: var(--affine-primary-color)">
|
||||||
|
${typeof icon === 'function' ? html`${icon()}` : icon}
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showWhenWrapper =
|
||||||
|
(item?: AIItemConfig) =>
|
||||||
|
({ rootElement }: AffineSlashMenuContext) => {
|
||||||
|
const affineAIPanelWidget = rootElement.host.view.getWidget(
|
||||||
|
AFFINE_AI_PANEL_WIDGET,
|
||||||
|
rootElement.model.id
|
||||||
|
);
|
||||||
|
if (affineAIPanelWidget === null) return false;
|
||||||
|
|
||||||
|
const chain = rootElement.host.command.chain();
|
||||||
|
const editorMode = rootElement.service.docModeService.getMode(
|
||||||
|
rootElement.doc.id
|
||||||
|
);
|
||||||
|
|
||||||
|
return item?.showWhen?.(chain, editorMode, rootElement.host) ?? true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionItemWrapper = (
|
||||||
|
item: AIItemConfig
|
||||||
|
): AffineSlashMenuActionItem => ({
|
||||||
|
...basicItemConfig(item),
|
||||||
|
action: ({ rootElement }: AffineSlashMenuContext) => {
|
||||||
|
item?.handler?.(rootElement.host);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const subMenuWrapper = (item: AIItemConfig): AffineSlashSubMenu => {
|
||||||
|
assertExists(item.subItem);
|
||||||
|
return {
|
||||||
|
...basicItemConfig(item),
|
||||||
|
subMenu: item.subItem.map<AffineSlashMenuActionItem>(
|
||||||
|
({ type, handler }) => ({
|
||||||
|
name: type,
|
||||||
|
action: ({ rootElement }) => handler?.(rootElement.host),
|
||||||
|
})
|
||||||
|
),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const basicItemConfig = (item: AIItemConfig) => {
|
||||||
|
return {
|
||||||
|
name: item.name,
|
||||||
|
icon: iconWrapper(item.icon),
|
||||||
|
alias: ['ai'],
|
||||||
|
showWhen: showWhenWrapper(item),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const AIMenuItems: AffineSlashMenuItem[] = [
|
||||||
|
{ groupName: 'AFFiNE AI' },
|
||||||
|
{
|
||||||
|
name: 'Ask AI',
|
||||||
|
icon: AIStarIcon,
|
||||||
|
showWhen: showWhenWrapper(),
|
||||||
|
action: ({ rootElement }) => {
|
||||||
|
const view = rootElement.host.view;
|
||||||
|
const affineAIPanelWidget = view.getWidget(
|
||||||
|
AFFINE_AI_PANEL_WIDGET,
|
||||||
|
rootElement.model.id
|
||||||
|
) as AffineAIPanelWidget;
|
||||||
|
assertExists(affineAIPanelWidget);
|
||||||
|
assertExists(AIProvider.actions.chat);
|
||||||
|
assertExists(affineAIPanelWidget.host);
|
||||||
|
handleInlineAskAIAction(affineAIPanelWidget.host);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
...AIItems.filter(({ name }) =>
|
||||||
|
['Fix spelling', 'Fix grammar'].includes(name)
|
||||||
|
).map(item => ({
|
||||||
|
...actionItemWrapper(item),
|
||||||
|
name: `${item.name} from above`,
|
||||||
|
})),
|
||||||
|
|
||||||
|
...AIItems.filter(({ name }) =>
|
||||||
|
['Summarize', 'Continue writing'].includes(name)
|
||||||
|
).map(actionItemWrapper),
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'Action with above',
|
||||||
|
icon: iconWrapper(MoreHorizontalIcon),
|
||||||
|
subMenu: [
|
||||||
|
{ groupName: 'Action with above' },
|
||||||
|
...AIItems.filter(({ name }) =>
|
||||||
|
['Translate to', 'Change tone to'].includes(name)
|
||||||
|
).map(subMenuWrapper),
|
||||||
|
|
||||||
|
...AIItems.filter(({ name }) =>
|
||||||
|
[
|
||||||
|
'Improve writing',
|
||||||
|
'Make it longer',
|
||||||
|
'Make it shorter',
|
||||||
|
'Generate outline',
|
||||||
|
'Find actions',
|
||||||
|
].includes(name)
|
||||||
|
).map(actionItemWrapper),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const menu = slashMenu.config.items.slice();
|
||||||
|
menu.unshift(...AIMenuItems);
|
||||||
|
|
||||||
|
slashMenu.config = {
|
||||||
|
...AffineSlashMenuWidget.DEFAULT_CONFIG,
|
||||||
|
items: menu,
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
import type { AffineAIPanelWidget } from '@blocksuite/blocks';
|
||||||
|
|
||||||
|
import { handleInlineAskAIAction } from '../../actions/doc-handler.js';
|
||||||
|
import { AIProvider } from '../../provider.js';
|
||||||
|
|
||||||
|
export function setupSpaceEntry(panel: AffineAIPanelWidget) {
|
||||||
|
panel.handleEvent('keyDown', ctx => {
|
||||||
|
const host = panel.host;
|
||||||
|
const keyboardState = ctx.get('keyboardState');
|
||||||
|
if (
|
||||||
|
AIProvider.actions.chat &&
|
||||||
|
keyboardState.raw.key === ' ' &&
|
||||||
|
!keyboardState.raw.isComposing
|
||||||
|
) {
|
||||||
|
const selection = host.selection.find('text');
|
||||||
|
if (selection && selection.isCollapsed() && selection.from.index === 0) {
|
||||||
|
const block = host.view.getBlock(selection.blockId);
|
||||||
|
if (!block?.model?.text || block.model.text?.length > 0) return;
|
||||||
|
|
||||||
|
keyboardState.raw.preventDefault();
|
||||||
|
handleInlineAskAIAction(host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
@ -0,0 +1,7 @@
|
|||||||
|
export * from './actions/index.js';
|
||||||
|
export * from './ai-spec.js';
|
||||||
|
export { ChatPanel } from './chat-panel/index.js';
|
||||||
|
export * from './entries/edgeless/actions-config.js';
|
||||||
|
export * from './entries/index.js';
|
||||||
|
export * from './messages/index.js';
|
||||||
|
export * from './provider.js';
|
@ -0,0 +1,114 @@
|
|||||||
|
import { type EditorHost, WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import { html, LitElement, nothing, type TemplateResult } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
import { ErrorTipIcon } from '../_common/icons.js';
|
||||||
|
import { AIProvider } from '../provider.js';
|
||||||
|
|
||||||
|
@customElement('ai-error-wrapper')
|
||||||
|
class AIErrorWrapper extends WithDisposable(LitElement) {
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor text!: TemplateResult<1>;
|
||||||
|
|
||||||
|
protected override render() {
|
||||||
|
return html` <style>
|
||||||
|
.answer-tip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
align-self: stretch;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: var(--affine-background-error-color);
|
||||||
|
|
||||||
|
.bottom {
|
||||||
|
align-items: flex-start;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
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% */
|
||||||
|
margin-bottom: 4px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
div svg {
|
||||||
|
position: relative;
|
||||||
|
top: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="answer-tip">
|
||||||
|
<div class="bottom">
|
||||||
|
<div>${ErrorTipIcon}</div>
|
||||||
|
<div>${this.text}</div>
|
||||||
|
</div>
|
||||||
|
<slot></slot>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PaymentRequiredErrorRenderer = (host: EditorHost) => html`
|
||||||
|
<style>
|
||||||
|
.upgrade {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
padding: 4px 12px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
border: 1px solid var(--affine-border-color, #e3e2e4);
|
||||||
|
background: var(--affine-primary-color);
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
padding: 0px 4px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--affine-pure-white);
|
||||||
|
/* light/xsMedium */
|
||||||
|
font-size: var(--affine-font-xs);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 20px; /* 166.667% */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<ai-error-wrapper
|
||||||
|
.text=${html`You've reached the current usage cap for AFFiNE AI. You can
|
||||||
|
subscribe to AFFiNE AI to continue the AI experience!`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
@click=${() => AIProvider.slots.requestUpgradePlan.emit({ host: host })}
|
||||||
|
class="upgrade"
|
||||||
|
>
|
||||||
|
<div class="content">Upgrade</div>
|
||||||
|
</div></ai-error-wrapper
|
||||||
|
>
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const GeneralErrorRenderer = (
|
||||||
|
text: TemplateResult<1> = html`An error occurred, If this issue persists
|
||||||
|
please let us know.
|
||||||
|
<a href="mailto:support@toeverything.info"> support@toeverything.info </a>`,
|
||||||
|
template: TemplateResult<1> = html`${nothing}`
|
||||||
|
) => html` <ai-error-wrapper .text=${text}>${template}</ai-error-wrapper>`;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'ai-error-wrapper': AIErrorWrapper;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,2 @@
|
|||||||
|
export * from './text.js';
|
||||||
|
export * from './wrapper.js';
|
@ -0,0 +1,79 @@
|
|||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import type {
|
||||||
|
AffineAIPanelWidgetConfig,
|
||||||
|
MindmapStyle,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { markdownToMindmap, MiniMindmapPreview } from '@blocksuite/blocks';
|
||||||
|
import { noop } from '@blocksuite/global/utils';
|
||||||
|
import { html, nothing } from 'lit';
|
||||||
|
|
||||||
|
import { getAIPanel } from '../ai-panel.js';
|
||||||
|
|
||||||
|
noop(MiniMindmapPreview);
|
||||||
|
|
||||||
|
export const createMindmapRenderer: (
|
||||||
|
host: EditorHost,
|
||||||
|
/**
|
||||||
|
* Used to store data for later use during rendering.
|
||||||
|
*/
|
||||||
|
ctx: {
|
||||||
|
get: () => Record<string, unknown>;
|
||||||
|
set: (data: Record<string, unknown>) => void;
|
||||||
|
},
|
||||||
|
style?: MindmapStyle
|
||||||
|
) => AffineAIPanelWidgetConfig['answerRenderer'] = (host, ctx, style) => {
|
||||||
|
return (answer, state) => {
|
||||||
|
if (state === 'generating') {
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
panel.generatingElement?.updateLoadingProgress(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state !== 'finished' && state !== 'error') {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`<mini-mindmap-preview
|
||||||
|
.ctx=${ctx}
|
||||||
|
.host=${host}
|
||||||
|
.answer=${answer}
|
||||||
|
.mindmapStyle=${style}
|
||||||
|
.templateShow=${style === undefined}
|
||||||
|
.height=${300}
|
||||||
|
></mini-mindmap-preview>`;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a renderer for executing a handler.
|
||||||
|
* The ai panel will not display anything after the answer is generated.
|
||||||
|
*/
|
||||||
|
export const createMindmapExecuteRenderer: (
|
||||||
|
host: EditorHost,
|
||||||
|
/**
|
||||||
|
* Used to store data for later use during rendering.
|
||||||
|
*/
|
||||||
|
ctx: {
|
||||||
|
get: () => Record<string, unknown>;
|
||||||
|
set: (data: Record<string, unknown>) => void;
|
||||||
|
},
|
||||||
|
handler: (ctx: {
|
||||||
|
get: () => Record<string, unknown>;
|
||||||
|
set: (data: Record<string, unknown>) => void;
|
||||||
|
}) => void
|
||||||
|
) => AffineAIPanelWidgetConfig['answerRenderer'] = (host, ctx, handler) => {
|
||||||
|
return (answer, state) => {
|
||||||
|
if (state !== 'finished') {
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
panel.generatingElement?.updateLoadingProgress(2);
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.set({
|
||||||
|
node: markdownToMindmap(answer, host.doc),
|
||||||
|
});
|
||||||
|
|
||||||
|
handler(ctx);
|
||||||
|
|
||||||
|
return nothing;
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,234 @@
|
|||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import { WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import {
|
||||||
|
type AffineAIPanelWidgetConfig,
|
||||||
|
EdgelessEditorBlockSpecs,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { AffineSchemas } from '@blocksuite/blocks/schemas';
|
||||||
|
import type { Doc } from '@blocksuite/store';
|
||||||
|
import { DocCollection, Schema } from '@blocksuite/store';
|
||||||
|
import { css, html, LitElement, nothing } from 'lit';
|
||||||
|
import { customElement, property, query } from 'lit/decorators.js';
|
||||||
|
import { createRef, type Ref, ref } from 'lit/directives/ref.js';
|
||||||
|
|
||||||
|
import { getAIPanel } from '../ai-panel.js';
|
||||||
|
import { PPTBuilder } from '../slides/index.js';
|
||||||
|
|
||||||
|
export const createSlidesRenderer: (
|
||||||
|
host: EditorHost,
|
||||||
|
ctx: {
|
||||||
|
get: () => Record<string, unknown>;
|
||||||
|
set: (data: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
) => AffineAIPanelWidgetConfig['answerRenderer'] = (host, ctx) => {
|
||||||
|
return (answer, state) => {
|
||||||
|
if (state === 'generating') {
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
panel.generatingElement?.updateLoadingProgress(2);
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state !== 'finished' && state !== 'error') {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`<style>
|
||||||
|
.slides-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="slides-container">
|
||||||
|
<ai-slides-renderer
|
||||||
|
.host=${host}
|
||||||
|
.ctx=${ctx}
|
||||||
|
.text=${answer}
|
||||||
|
></ai-slides-renderer>
|
||||||
|
</div>`;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
@customElement('ai-slides-renderer')
|
||||||
|
export class AISlidesRenderer extends WithDisposable(LitElement) {
|
||||||
|
static override styles = css``;
|
||||||
|
|
||||||
|
private readonly _editorContainer: Ref<HTMLDivElement> =
|
||||||
|
createRef<HTMLDivElement>();
|
||||||
|
|
||||||
|
private _doc!: Doc;
|
||||||
|
|
||||||
|
@query('editor-host')
|
||||||
|
private accessor _editorHost!: EditorHost;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor text!: string;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor ctx:
|
||||||
|
| {
|
||||||
|
get(): Record<string, unknown>;
|
||||||
|
set(data: Record<string, unknown>): void;
|
||||||
|
}
|
||||||
|
| undefined = undefined;
|
||||||
|
|
||||||
|
protected override firstUpdated() {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!this._editorHost) return;
|
||||||
|
PPTBuilder(this._editorHost)
|
||||||
|
.process(this.text)
|
||||||
|
.then(res => {
|
||||||
|
if (this.ctx) {
|
||||||
|
this.ctx.set({
|
||||||
|
contents: res.contents,
|
||||||
|
images: res.images,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override render() {
|
||||||
|
return html`<style>
|
||||||
|
.slides-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edgeless-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--affine-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background-color: transparent;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edgeless-container affine-edgeless-zoom-toolbar-widget,
|
||||||
|
edgeless-toolbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affine-edgeless-viewport {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
container-name: viewport;
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affine-edgeless-surface-block-container {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affine-edgeless-surface-block-container canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeless-block-portal-container {
|
||||||
|
position: relative;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
font-family: var(--affine-font-family);
|
||||||
|
font-size: var(--affine-font-base);
|
||||||
|
line-height: var(--affine-line-height);
|
||||||
|
color: var(--affine-text-primary-color);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affine-block-children-container.edgeless {
|
||||||
|
padding-left: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
touch-action: none;
|
||||||
|
background-color: var(--affine-background-primary-color);
|
||||||
|
background-image: radial-gradient(
|
||||||
|
var(--affine-edgeless-grid-color) 1px,
|
||||||
|
var(--affine-background-primary-color) 1px
|
||||||
|
);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affine-edgeless-block-child {
|
||||||
|
position: absolute;
|
||||||
|
transform-origin: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 2px solid var(--affine-white-10);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--affine-shadow-3);
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
|
||||||
|
affine-edgeless-image .resizable-img,
|
||||||
|
affine-edgeless-image .resizable-img img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affine-edgeless-layer {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
contain: size layout style;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="slides-container">
|
||||||
|
<div
|
||||||
|
class="edgeless-container affine-edgeless-viewport"
|
||||||
|
${ref(this._editorContainer)}
|
||||||
|
>
|
||||||
|
${this.host.renderSpecPortal(this._doc, EdgelessEditorBlockSpecs)}
|
||||||
|
</div>
|
||||||
|
<div class="mask"></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
override connectedCallback(): void {
|
||||||
|
super.connectedCallback();
|
||||||
|
|
||||||
|
const schema = new Schema().register(AffineSchemas);
|
||||||
|
const collection = new DocCollection({ schema, id: 'SLIDES_PREVIEW' });
|
||||||
|
collection.start();
|
||||||
|
const doc = collection.createDoc();
|
||||||
|
|
||||||
|
doc.load(() => {
|
||||||
|
const pageBlockId = doc.addBlock('affine:page', {});
|
||||||
|
doc.addBlock('affine:surface', {}, pageBlockId);
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.resetHistory();
|
||||||
|
this._doc = doc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'ai-slides-renderer': AISlidesRenderer;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,298 @@
|
|||||||
|
import { type EditorHost, WithDisposable } from '@blocksuite/block-std';
|
||||||
|
import type {
|
||||||
|
AffineAIPanelState,
|
||||||
|
AffineAIPanelWidgetConfig,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import {
|
||||||
|
BlocksUtils,
|
||||||
|
CodeBlockComponent,
|
||||||
|
DividerBlockComponent,
|
||||||
|
ListBlockComponent,
|
||||||
|
ParagraphBlockComponent,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { type BlockSelector, BlockViewType, type Doc } from '@blocksuite/store';
|
||||||
|
import { css, html, LitElement, type PropertyValues } from 'lit';
|
||||||
|
import { customElement, property, query } from 'lit/decorators.js';
|
||||||
|
import { classMap } from 'lit/directives/class-map.js';
|
||||||
|
import { keyed } from 'lit/directives/keyed.js';
|
||||||
|
|
||||||
|
import { CustomPageEditorBlockSpecs } from '../utils/custom-specs.js';
|
||||||
|
import { markDownToDoc } from '../utils/markdown-utils.js';
|
||||||
|
|
||||||
|
const textBlockStyles = css`
|
||||||
|
${ParagraphBlockComponent.styles}
|
||||||
|
${ListBlockComponent.styles}
|
||||||
|
${DividerBlockComponent.styles}
|
||||||
|
${CodeBlockComponent.styles}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const customHeadingStyles = css`
|
||||||
|
.custom-heading {
|
||||||
|
.h1 {
|
||||||
|
font-size: calc(var(--affine-font-h-1) - 2px);
|
||||||
|
code {
|
||||||
|
font-size: calc(var(--affine-font-base) + 6px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.h2 {
|
||||||
|
font-size: calc(var(--affine-font-h-2) - 2px);
|
||||||
|
code {
|
||||||
|
font-size: calc(var(--affine-font-base) + 4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.h3 {
|
||||||
|
font-size: calc(var(--affine-font-h-3) - 2px);
|
||||||
|
code {
|
||||||
|
font-size: calc(var(--affine-font-base) + 2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.h4 {
|
||||||
|
font-size: calc(var(--affine-font-h-4) - 2px);
|
||||||
|
code {
|
||||||
|
font-size: var(--affine-font-base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.h5 {
|
||||||
|
font-size: calc(var(--affine-font-h-5) - 2px);
|
||||||
|
code {
|
||||||
|
font-size: calc(var(--affine-font-base) - 2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.h6 {
|
||||||
|
font-size: calc(var(--affine-font-h-6) - 2px);
|
||||||
|
code {
|
||||||
|
font-size: calc(var(--affine-font-base) - 4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
type TextRendererOptions = {
|
||||||
|
maxHeight?: number;
|
||||||
|
customHeading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
@customElement('ai-answer-text')
|
||||||
|
export class AIAnswerText extends WithDisposable(LitElement) {
|
||||||
|
static override styles = css`
|
||||||
|
.ai-answer-text-editor.affine-page-viewport {
|
||||||
|
background: transparent;
|
||||||
|
font-family: var(--affine-font-family);
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-answer-text-editor .affine-page-root-block-container {
|
||||||
|
padding: 0;
|
||||||
|
line-height: var(--affine-line-height);
|
||||||
|
color: var(--affine-text-primary-color);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.affine-paragraph-block-container {
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-answer-text-editor {
|
||||||
|
.affine-note-block-container {
|
||||||
|
> .affine-block-children-container {
|
||||||
|
> :first-child,
|
||||||
|
> :first-child * {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
> :last-child,
|
||||||
|
> :last-child * {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-answer-text-container {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 0;
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
}
|
||||||
|
.ai-answer-text-container.show-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 5px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
.ai-answer-text-container.show-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
.ai-answer-text-container.show-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--affine-black-30);
|
||||||
|
}
|
||||||
|
.ai-answer-text-container.show-scrollbar::-webkit-scrollbar-corner {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-answer-text-container {
|
||||||
|
rich-text .nowrap-lines v-text span,
|
||||||
|
rich-text .nowrap-lines v-element span {
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
editor-host:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
editor-host * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
${textBlockStyles}
|
||||||
|
${customHeadingStyles}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@query('.ai-answer-text-container')
|
||||||
|
private accessor _container!: HTMLDivElement;
|
||||||
|
|
||||||
|
private _doc!: Doc;
|
||||||
|
|
||||||
|
private _answers: string[] = [];
|
||||||
|
|
||||||
|
private _timer?: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor host!: EditorHost;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor answer!: string;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor options!: TextRendererOptions;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor state: AffineAIPanelState | undefined = undefined;
|
||||||
|
|
||||||
|
private _onWheel(e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (this.state === 'generating') {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly _clearTimer = () => {
|
||||||
|
if (this._timer) {
|
||||||
|
clearInterval(this._timer);
|
||||||
|
this._timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly _selector: BlockSelector = block =>
|
||||||
|
BlocksUtils.matchFlavours(block.model, [
|
||||||
|
'affine:page',
|
||||||
|
'affine:note',
|
||||||
|
'affine:surface',
|
||||||
|
'affine:paragraph',
|
||||||
|
'affine:code',
|
||||||
|
'affine:list',
|
||||||
|
'affine:divider',
|
||||||
|
])
|
||||||
|
? BlockViewType.Display
|
||||||
|
: BlockViewType.Hidden;
|
||||||
|
|
||||||
|
private readonly _updateDoc = () => {
|
||||||
|
if (this._answers.length > 0) {
|
||||||
|
const latestAnswer = this._answers.pop();
|
||||||
|
this._answers = [];
|
||||||
|
if (latestAnswer) {
|
||||||
|
markDownToDoc(this.host, latestAnswer)
|
||||||
|
.then(doc => {
|
||||||
|
this._doc = doc.blockCollection.getDoc({
|
||||||
|
selector: this._selector,
|
||||||
|
});
|
||||||
|
this.disposables.add(() => {
|
||||||
|
doc.blockCollection.clearSelector(this._selector);
|
||||||
|
});
|
||||||
|
this._doc.awarenessStore.setReadonly(
|
||||||
|
this._doc.blockCollection,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
this.requestUpdate();
|
||||||
|
if (this.state !== 'generating') {
|
||||||
|
this._clearTimer();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
override shouldUpdate(changedProperties: PropertyValues) {
|
||||||
|
if (changedProperties.has('answer')) {
|
||||||
|
this._answers.push(this.answer);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
override connectedCallback() {
|
||||||
|
super.connectedCallback();
|
||||||
|
this._answers.push(this.answer);
|
||||||
|
|
||||||
|
this._updateDoc();
|
||||||
|
if (this.state === 'generating') {
|
||||||
|
this._timer = setInterval(this._updateDoc, 600);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this._clearTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
override updated(changedProperties: PropertyValues) {
|
||||||
|
super.updated(changedProperties);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!this._container) return;
|
||||||
|
this._container.scrollTop = this._container.scrollHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
const { maxHeight, customHeading } = this.options;
|
||||||
|
const classes = classMap({
|
||||||
|
'ai-answer-text-container': true,
|
||||||
|
'show-scrollbar': !!maxHeight,
|
||||||
|
'custom-heading': !!customHeading,
|
||||||
|
});
|
||||||
|
return html`
|
||||||
|
<style>
|
||||||
|
.ai-answer-text-container {
|
||||||
|
max-height: ${maxHeight ? Math.max(maxHeight, 200) + 'px' : ''};
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class=${classes} @wheel=${this._onWheel}>
|
||||||
|
${keyed(
|
||||||
|
this._doc,
|
||||||
|
html`<div class="ai-answer-text-editor affine-page-viewport">
|
||||||
|
${this.host.renderSpecPortal(this._doc, CustomPageEditorBlockSpecs)}
|
||||||
|
</div>`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'ai-answer-text': AIAnswerText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createTextRenderer: (
|
||||||
|
host: EditorHost,
|
||||||
|
options: TextRendererOptions
|
||||||
|
) => AffineAIPanelWidgetConfig['answerRenderer'] = (host, options) => {
|
||||||
|
return (answer, state) => {
|
||||||
|
return html`<ai-answer-text
|
||||||
|
.host=${host}
|
||||||
|
.answer=${answer}
|
||||||
|
.state=${state}
|
||||||
|
.options=${options}
|
||||||
|
></ai-answer-text>`;
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,119 @@
|
|||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import type { AffineAIPanelWidgetConfig } from '@blocksuite/blocks';
|
||||||
|
import { css, html, LitElement, nothing } from 'lit';
|
||||||
|
import { customElement, property } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
import { getAIPanel } from '../ai-panel.js';
|
||||||
|
import { preprocessHtml } from '../utils/html.js';
|
||||||
|
|
||||||
|
type AIAnswerWrapperOptions = {
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@customElement('ai-answer-wrapper')
|
||||||
|
export class AIAnswerWrapper extends LitElement {
|
||||||
|
static override styles = css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--affine-border-color);
|
||||||
|
box-shadow: var(--affine-shadow-1);
|
||||||
|
background: var(--affine-background-secondary-color);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
::slotted(.ai-answer-iframe) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::slotted(.ai-answer-image) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor options: AIAnswerWrapperOptions | undefined = undefined;
|
||||||
|
|
||||||
|
protected override render() {
|
||||||
|
return html`<style>
|
||||||
|
:host {
|
||||||
|
height: ${this.options?.height
|
||||||
|
? this.options?.height + 'px'
|
||||||
|
: '100%'};
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<slot></slot> `;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
'ai-answer-wrapper': AIAnswerWrapper;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createIframeRenderer: (
|
||||||
|
host: EditorHost,
|
||||||
|
options?: AIAnswerWrapperOptions
|
||||||
|
) => AffineAIPanelWidgetConfig['answerRenderer'] = (host, options) => {
|
||||||
|
return (answer, state) => {
|
||||||
|
if (state === 'generating') {
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
panel.generatingElement?.updateLoadingProgress(2);
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state !== 'finished' && state !== 'error') {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = html`<iframe
|
||||||
|
class="ai-answer-iframe"
|
||||||
|
sandbox="allow-scripts"
|
||||||
|
scrolling="no"
|
||||||
|
allowfullscreen
|
||||||
|
.srcdoc=${preprocessHtml(answer)}
|
||||||
|
>
|
||||||
|
</iframe>`;
|
||||||
|
return html`<ai-answer-wrapper .options=${options}
|
||||||
|
>${template}</ai-answer-wrapper
|
||||||
|
>`;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createImageRenderer: (
|
||||||
|
host: EditorHost,
|
||||||
|
options?: AIAnswerWrapperOptions
|
||||||
|
) => AffineAIPanelWidgetConfig['answerRenderer'] = (host, options) => {
|
||||||
|
return (answer, state) => {
|
||||||
|
if (state === 'generating') {
|
||||||
|
const panel = getAIPanel(host);
|
||||||
|
panel.generatingElement?.updateLoadingProgress(2);
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state !== 'finished' && state !== 'error') {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = html`<style>
|
||||||
|
.ai-answer-image img{
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div class="ai-answer-image">
|
||||||
|
<img src=${answer}></img>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
return html`<ai-answer-wrapper .options=${options}
|
||||||
|
>${template}</ai-answer-wrapper
|
||||||
|
>`;
|
||||||
|
};
|
||||||
|
};
|
252
packages/frontend/core/src/blocksuite/presets/ai/provider.ts
Normal file
252
packages/frontend/core/src/blocksuite/presets/ai/provider.ts
Normal file
@ -0,0 +1,252 @@
|
|||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import { PaymentRequiredError, UnauthorizedError } from '@blocksuite/blocks';
|
||||||
|
import { Slot } from '@blocksuite/store';
|
||||||
|
|
||||||
|
export interface AIUserInfo {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActionEventType =
|
||||||
|
| 'started'
|
||||||
|
| 'finished'
|
||||||
|
| 'error'
|
||||||
|
| 'aborted:paywall'
|
||||||
|
| 'aborted:login-required'
|
||||||
|
| 'aborted:server-error'
|
||||||
|
| 'aborted:stop'
|
||||||
|
| 'result:insert'
|
||||||
|
| 'result:replace'
|
||||||
|
| 'result:use-as-caption'
|
||||||
|
| 'result:add-page'
|
||||||
|
| 'result:add-note'
|
||||||
|
| 'result:continue-in-chat'
|
||||||
|
| 'result:discard'
|
||||||
|
| 'result:retry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI provider for the block suite
|
||||||
|
*
|
||||||
|
* To use it, downstream (affine) has to provide AI actions implementation,
|
||||||
|
* user info etc
|
||||||
|
*
|
||||||
|
* todo: breakdown into different parts?
|
||||||
|
*/
|
||||||
|
export class AIProvider {
|
||||||
|
static get slots() {
|
||||||
|
return AIProvider.instance.slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get actions() {
|
||||||
|
return AIProvider.instance.actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get userInfo() {
|
||||||
|
return AIProvider.instance.userInfoFn();
|
||||||
|
}
|
||||||
|
|
||||||
|
static get photoEngine() {
|
||||||
|
return AIProvider.instance.photoEngine;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get histories() {
|
||||||
|
return AIProvider.instance.histories;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get actionHistory() {
|
||||||
|
return AIProvider.instance.actionHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
static get toggleGeneralAIOnboarding() {
|
||||||
|
return AIProvider.instance.toggleGeneralAIOnboarding;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static readonly instance = new AIProvider();
|
||||||
|
|
||||||
|
static LAST_ACTION_SESSIONID = '';
|
||||||
|
|
||||||
|
static MAX_LOCAL_HISTORY = 10;
|
||||||
|
|
||||||
|
private readonly actions: Partial<BlockSuitePresets.AIActions> = {};
|
||||||
|
|
||||||
|
private photoEngine: BlockSuitePresets.AIPhotoEngineService | null = null;
|
||||||
|
|
||||||
|
private histories: BlockSuitePresets.AIHistoryService | null = null;
|
||||||
|
|
||||||
|
private toggleGeneralAIOnboarding: ((value: boolean) => void) | null = null;
|
||||||
|
|
||||||
|
private readonly slots = {
|
||||||
|
// use case: when user selects "continue in chat" in an ask ai result panel
|
||||||
|
// do we need to pass the context to the chat panel?
|
||||||
|
requestContinueInChat: new Slot<{ host: EditorHost; show: boolean }>(),
|
||||||
|
requestLogin: new Slot<{ host: EditorHost }>(),
|
||||||
|
requestUpgradePlan: new Slot<{ host: EditorHost }>(),
|
||||||
|
// when an action is requested to run in edgeless mode (show a toast in affine)
|
||||||
|
requestRunInEdgeless: new Slot<{ host: EditorHost }>(),
|
||||||
|
// stream of AI actions triggered by users
|
||||||
|
actions: new Slot<{
|
||||||
|
action: keyof BlockSuitePresets.AIActions;
|
||||||
|
options: BlockSuitePresets.AITextActionOptions;
|
||||||
|
event: ActionEventType;
|
||||||
|
}>(),
|
||||||
|
// downstream can emit this slot to notify ai presets that user info has been updated
|
||||||
|
userInfo: new Slot<AIUserInfo | null>(),
|
||||||
|
// add more if needed
|
||||||
|
};
|
||||||
|
|
||||||
|
// track the history of triggered actions (in memory only)
|
||||||
|
private readonly actionHistory: {
|
||||||
|
action: keyof BlockSuitePresets.AIActions;
|
||||||
|
options: BlockSuitePresets.AITextActionOptions;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
private userInfoFn: () => AIUserInfo | Promise<AIUserInfo> | null = () =>
|
||||||
|
null;
|
||||||
|
|
||||||
|
private provideAction<T extends keyof BlockSuitePresets.AIActions>(
|
||||||
|
id: T,
|
||||||
|
action: (
|
||||||
|
...options: Parameters<BlockSuitePresets.AIActions[T]>
|
||||||
|
) => ReturnType<BlockSuitePresets.AIActions[T]>
|
||||||
|
): void {
|
||||||
|
if (this.actions[id]) {
|
||||||
|
console.warn(`AI action ${id} is already provided`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// @ts-expect-error todo: maybe fix this
|
||||||
|
this.actions[id] = (
|
||||||
|
...args: Parameters<BlockSuitePresets.AIActions[T]>
|
||||||
|
) => {
|
||||||
|
const options = args[0];
|
||||||
|
const slots = this.slots;
|
||||||
|
slots.actions.emit({
|
||||||
|
action: id,
|
||||||
|
options,
|
||||||
|
event: 'started',
|
||||||
|
});
|
||||||
|
this.actionHistory.push({ action: id, options });
|
||||||
|
if (this.actionHistory.length > AIProvider.MAX_LOCAL_HISTORY) {
|
||||||
|
this.actionHistory.shift();
|
||||||
|
}
|
||||||
|
// wrap the action with slot actions
|
||||||
|
const result: BlockSuitePresets.TextStream | Promise<string> = action(
|
||||||
|
...args
|
||||||
|
);
|
||||||
|
const isTextStream = (
|
||||||
|
m: BlockSuitePresets.TextStream | Promise<string>
|
||||||
|
): m is BlockSuitePresets.TextStream =>
|
||||||
|
Reflect.has(m, Symbol.asyncIterator);
|
||||||
|
if (isTextStream(result)) {
|
||||||
|
return {
|
||||||
|
[Symbol.asyncIterator]: async function* () {
|
||||||
|
try {
|
||||||
|
yield* result;
|
||||||
|
slots.actions.emit({
|
||||||
|
action: id,
|
||||||
|
options,
|
||||||
|
event: 'finished',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
slots.actions.emit({
|
||||||
|
action: id,
|
||||||
|
options,
|
||||||
|
event: 'error',
|
||||||
|
});
|
||||||
|
if (err instanceof PaymentRequiredError) {
|
||||||
|
slots.actions.emit({
|
||||||
|
action: id,
|
||||||
|
options,
|
||||||
|
event: 'aborted:paywall',
|
||||||
|
});
|
||||||
|
} else if (err instanceof UnauthorizedError) {
|
||||||
|
slots.actions.emit({
|
||||||
|
action: id,
|
||||||
|
options,
|
||||||
|
event: 'aborted:login-required',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
slots.actions.emit({
|
||||||
|
action: id,
|
||||||
|
options,
|
||||||
|
event: 'aborted:server-error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return result
|
||||||
|
.then(result => {
|
||||||
|
slots.actions.emit({
|
||||||
|
action: id,
|
||||||
|
options,
|
||||||
|
event: 'finished',
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
slots.actions.emit({
|
||||||
|
action: id,
|
||||||
|
options,
|
||||||
|
event: 'error',
|
||||||
|
});
|
||||||
|
if (err instanceof PaymentRequiredError) {
|
||||||
|
slots.actions.emit({
|
||||||
|
action: id,
|
||||||
|
options,
|
||||||
|
event: 'aborted:paywall',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static provide(
|
||||||
|
id: 'userInfo',
|
||||||
|
fn: () => AIUserInfo | Promise<AIUserInfo> | null
|
||||||
|
): void;
|
||||||
|
|
||||||
|
static provide(
|
||||||
|
id: 'histories',
|
||||||
|
service: BlockSuitePresets.AIHistoryService
|
||||||
|
): void;
|
||||||
|
|
||||||
|
static provide(
|
||||||
|
id: 'photoEngine',
|
||||||
|
engine: BlockSuitePresets.AIPhotoEngineService
|
||||||
|
): void;
|
||||||
|
|
||||||
|
static provide(id: 'onboarding', fn: (value: boolean) => void): void;
|
||||||
|
|
||||||
|
// actions:
|
||||||
|
static provide<T extends keyof BlockSuitePresets.AIActions>(
|
||||||
|
id: T,
|
||||||
|
action: (
|
||||||
|
...options: Parameters<BlockSuitePresets.AIActions[T]>
|
||||||
|
) => ReturnType<BlockSuitePresets.AIActions[T]>
|
||||||
|
): void;
|
||||||
|
|
||||||
|
static provide(id: unknown, action: unknown) {
|
||||||
|
if (id === 'userInfo') {
|
||||||
|
AIProvider.instance.userInfoFn = action as () => AIUserInfo;
|
||||||
|
} else if (id === 'histories') {
|
||||||
|
AIProvider.instance.histories =
|
||||||
|
action as BlockSuitePresets.AIHistoryService;
|
||||||
|
} else if (id === 'photoEngine') {
|
||||||
|
AIProvider.instance.photoEngine =
|
||||||
|
action as BlockSuitePresets.AIPhotoEngineService;
|
||||||
|
} else if (id === 'onboarding') {
|
||||||
|
AIProvider.instance.toggleGeneralAIOnboarding = action as (
|
||||||
|
value: boolean
|
||||||
|
) => void;
|
||||||
|
} else {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
AIProvider.instance.provideAction(id as any, action as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,84 @@
|
|||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import type { EdgelessRootService } from '@blocksuite/blocks';
|
||||||
|
import type { BlockSnapshot } from '@blocksuite/store';
|
||||||
|
|
||||||
|
import { markdownToSnapshot } from '../_common/markdown-utils.js';
|
||||||
|
import { getSurfaceElementFromEditor } from '../_common/selection-utils.js';
|
||||||
|
import {
|
||||||
|
basicTheme,
|
||||||
|
type PPTDoc,
|
||||||
|
type PPTSection,
|
||||||
|
type TemplateImage,
|
||||||
|
} from './template.js';
|
||||||
|
|
||||||
|
export const PPTBuilder = (host: EditorHost) => {
|
||||||
|
const service = host.spec.getService<EdgelessRootService>('affine:page');
|
||||||
|
const docs: PPTDoc[] = [];
|
||||||
|
const contents: unknown[] = [];
|
||||||
|
const allImages: TemplateImage[][] = [];
|
||||||
|
|
||||||
|
const addDoc = async (block: BlockSnapshot) => {
|
||||||
|
const sections = block.children.map(v => {
|
||||||
|
const title = getText(v);
|
||||||
|
const keywords = getText(v.children[0]);
|
||||||
|
const content = getText(v.children[1]);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
keywords,
|
||||||
|
content,
|
||||||
|
} satisfies PPTSection;
|
||||||
|
});
|
||||||
|
const doc: PPTDoc = {
|
||||||
|
isCover: docs.length === 0,
|
||||||
|
title: getText(block),
|
||||||
|
sections,
|
||||||
|
};
|
||||||
|
docs.push(doc);
|
||||||
|
|
||||||
|
if (doc.isCover) return;
|
||||||
|
const job = service.createTemplateJob('template');
|
||||||
|
const { images, content } = await basicTheme(doc);
|
||||||
|
contents.push(content);
|
||||||
|
allImages.push(images);
|
||||||
|
if (images.length) {
|
||||||
|
await Promise.all(
|
||||||
|
images.map(({ id, url }) =>
|
||||||
|
fetch(url)
|
||||||
|
.then(res => res.blob())
|
||||||
|
.then(blob => job.job.assets.set(id, blob))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await job.insertTemplate(content);
|
||||||
|
getSurfaceElementFromEditor(host).refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
process: async (text: string) => {
|
||||||
|
try {
|
||||||
|
const snapshot = await markdownToSnapshot(text, host);
|
||||||
|
|
||||||
|
const block = snapshot.snapshot.content[0];
|
||||||
|
for (const child of block.children) {
|
||||||
|
await addDoc(child);
|
||||||
|
const { centerX, centerY, zoom } = service.getFitToScreenData();
|
||||||
|
service.viewport.setViewport(zoom, [centerX, centerY]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { contents, images: allImages };
|
||||||
|
},
|
||||||
|
done: async (text: string) => {
|
||||||
|
const snapshot = await markdownToSnapshot(text, host);
|
||||||
|
const block = snapshot.snapshot.content[0];
|
||||||
|
await addDoc(block.children[block.children.length - 1]);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getText = (block: BlockSnapshot) => {
|
||||||
|
// @ts-expect-error allow
|
||||||
|
return block.props.text?.delta?.[0]?.insert ?? '';
|
||||||
|
};
|
@ -0,0 +1,321 @@
|
|||||||
|
import { Bound } from '@blocksuite/blocks';
|
||||||
|
import { nanoid } from '@blocksuite/store';
|
||||||
|
|
||||||
|
import { AIProvider } from '../provider.js';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const replaceText = (text: Record<string, string>, template: any) => {
|
||||||
|
if (template != null && typeof template === 'object') {
|
||||||
|
if (Array.isArray(template)) {
|
||||||
|
template.forEach(v => replaceText(text, v));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof template.insert === 'string') {
|
||||||
|
template.insert = text[template.insert] ?? template.insert;
|
||||||
|
}
|
||||||
|
Object.values(template).forEach(v => replaceText(text, v));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function seededRNG(seed: string) {
|
||||||
|
const a = 1664525;
|
||||||
|
const c = 1013904223;
|
||||||
|
const m = 2 ** 32;
|
||||||
|
const seededNumber = stringToNumber(seed);
|
||||||
|
return ((a * seededNumber + c) % m) / m;
|
||||||
|
|
||||||
|
function stringToNumber(str: string) {
|
||||||
|
let res = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const character = str.charCodeAt(i);
|
||||||
|
res += character;
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getImageUrlByKeyword =
|
||||||
|
(keyword: string) =>
|
||||||
|
async (w: number, h: number): Promise<string> => {
|
||||||
|
const photos = await AIProvider.photoEngine?.searchImages({
|
||||||
|
query: keyword,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (photos == null || photos.length === 0) {
|
||||||
|
return ''; // fixme: give a default image
|
||||||
|
}
|
||||||
|
|
||||||
|
// use a stable random seed
|
||||||
|
const rng = seededRNG(`${w}.${h}.${keyword}`);
|
||||||
|
return photos[Math.floor(rng * photos.length)];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getImages = async (
|
||||||
|
images: Record<string, (w: number, h: number) => Promise<string> | string>,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
template: any
|
||||||
|
): Promise<TemplateImage[]> => {
|
||||||
|
const imgs: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
> = {};
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const run = (data: any) => {
|
||||||
|
if (data != null && typeof data === 'object') {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
data.forEach(v => run(v));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof data.caption === 'string') {
|
||||||
|
const bound = Bound.deserialize(data.xywh);
|
||||||
|
const id = nanoid();
|
||||||
|
data.sourceId = id;
|
||||||
|
imgs[data.caption] = {
|
||||||
|
id: id,
|
||||||
|
width: bound.w,
|
||||||
|
height: bound.h,
|
||||||
|
};
|
||||||
|
delete data.caption;
|
||||||
|
}
|
||||||
|
Object.values(data).forEach(v => run(v));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
run(template);
|
||||||
|
const list = await Promise.all(
|
||||||
|
Object.entries(imgs).map(async ([name, data]) => {
|
||||||
|
const getImage = images[name];
|
||||||
|
if (!getImage) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = await getImage(data.width, data.height);
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
url,
|
||||||
|
} satisfies TemplateImage;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const notNull = (v?: TemplateImage): v is TemplateImage => {
|
||||||
|
return v != null;
|
||||||
|
};
|
||||||
|
return list.filter(notNull);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PPTSection = {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
keywords: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TemplateImage = {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DocTemplate = {
|
||||||
|
images: TemplateImage[];
|
||||||
|
content: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBasicCover = async (
|
||||||
|
title: string,
|
||||||
|
section1: PPTSection
|
||||||
|
): Promise<DocTemplate> => {
|
||||||
|
const templates = (await import('./templates/cover.json')).default;
|
||||||
|
const template = getRandomElement(templates);
|
||||||
|
replaceText(
|
||||||
|
{
|
||||||
|
title: title,
|
||||||
|
'section1.title': section1.title,
|
||||||
|
'section1.content': section1.content,
|
||||||
|
},
|
||||||
|
template
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
images: await getImages(
|
||||||
|
{
|
||||||
|
'section1.image': getImageUrlByKeyword(section1.keywords),
|
||||||
|
},
|
||||||
|
template
|
||||||
|
),
|
||||||
|
content: template,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRandomElement<T>(arr: T[]): T {
|
||||||
|
return arr[Math.floor(Math.random() * arr.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const basic1section = async (
|
||||||
|
title: string,
|
||||||
|
section1: PPTSection
|
||||||
|
): Promise<DocTemplate> => {
|
||||||
|
const templates = (await import('./templates/one.json')).default;
|
||||||
|
const template = getRandomElement(templates);
|
||||||
|
replaceText(
|
||||||
|
{
|
||||||
|
title: title,
|
||||||
|
'section1.title': section1.title,
|
||||||
|
'section1.content': section1.content,
|
||||||
|
},
|
||||||
|
template
|
||||||
|
);
|
||||||
|
const images = await getImages(
|
||||||
|
{
|
||||||
|
'section1.image': getImageUrlByKeyword(section1.keywords),
|
||||||
|
'section1.image2': getImageUrlByKeyword(section1.keywords),
|
||||||
|
'section1.image3': getImageUrlByKeyword(section1.keywords),
|
||||||
|
},
|
||||||
|
template
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
images: images,
|
||||||
|
content: template,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const basic2section = async (
|
||||||
|
title: string,
|
||||||
|
section1: PPTSection,
|
||||||
|
section2: PPTSection
|
||||||
|
): Promise<DocTemplate> => {
|
||||||
|
const templates = (await import('./templates/two.json')).default;
|
||||||
|
const template = getRandomElement(templates);
|
||||||
|
replaceText(
|
||||||
|
{
|
||||||
|
title: title,
|
||||||
|
'section1.title': section1.title,
|
||||||
|
'section1.content': section1.content,
|
||||||
|
'section2.title': section2.title,
|
||||||
|
'section2.content': section2.content,
|
||||||
|
},
|
||||||
|
template
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
images: await getImages(
|
||||||
|
{
|
||||||
|
'section1.image': getImageUrlByKeyword(section1.keywords),
|
||||||
|
'section2.image': getImageUrlByKeyword(section2.keywords),
|
||||||
|
background: () =>
|
||||||
|
'https://cdn.affine.pro/ppt-images/background/basic_2_selection_background.png',
|
||||||
|
},
|
||||||
|
template
|
||||||
|
),
|
||||||
|
content: template,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const basic3section = async (
|
||||||
|
title: string,
|
||||||
|
section1: PPTSection,
|
||||||
|
section2: PPTSection,
|
||||||
|
section3: PPTSection
|
||||||
|
): Promise<DocTemplate> => {
|
||||||
|
const templates = (await import('./templates/three.json')).default;
|
||||||
|
const template = getRandomElement(templates);
|
||||||
|
replaceText(
|
||||||
|
{
|
||||||
|
title: title,
|
||||||
|
'section1.title': section1.title,
|
||||||
|
'section1.content': section1.content,
|
||||||
|
'section2.title': section2.title,
|
||||||
|
'section2.content': section2.content,
|
||||||
|
'section3.title': section3.title,
|
||||||
|
'section3.content': section3.content,
|
||||||
|
},
|
||||||
|
template
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
images: await getImages(
|
||||||
|
{
|
||||||
|
'section1.image': getImageUrlByKeyword(section1.keywords),
|
||||||
|
'section2.image': getImageUrlByKeyword(section2.keywords),
|
||||||
|
'section3.image': getImageUrlByKeyword(section3.keywords),
|
||||||
|
background: () =>
|
||||||
|
'https://cdn.affine.pro/ppt-images/background/basic_3_selection_background.png',
|
||||||
|
},
|
||||||
|
template
|
||||||
|
),
|
||||||
|
content: template,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const basic4section = async (
|
||||||
|
title: string,
|
||||||
|
section1: PPTSection,
|
||||||
|
section2: PPTSection,
|
||||||
|
section3: PPTSection,
|
||||||
|
section4: PPTSection
|
||||||
|
): Promise<DocTemplate> => {
|
||||||
|
const templates = (await import('./templates/four.json')).default;
|
||||||
|
const template = getRandomElement(templates);
|
||||||
|
replaceText(
|
||||||
|
{
|
||||||
|
title: title,
|
||||||
|
'section1.title': section1.title,
|
||||||
|
'section1.content': section1.content,
|
||||||
|
'section2.title': section2.title,
|
||||||
|
'section2.content': section2.content,
|
||||||
|
'section3.title': section3.title,
|
||||||
|
'section3.content': section3.content,
|
||||||
|
'section4.title': section4.title,
|
||||||
|
'section4.content': section4.content,
|
||||||
|
},
|
||||||
|
template
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
images: await getImages(
|
||||||
|
{
|
||||||
|
'section1.image': getImageUrlByKeyword(section1.keywords),
|
||||||
|
'section2.image': getImageUrlByKeyword(section2.keywords),
|
||||||
|
'section3.image': getImageUrlByKeyword(section3.keywords),
|
||||||
|
'section4.image': getImageUrlByKeyword(section4.keywords),
|
||||||
|
background: () =>
|
||||||
|
'https://cdn.affine.pro/ppt-images/background/basic_4_selection_background.png',
|
||||||
|
},
|
||||||
|
template
|
||||||
|
),
|
||||||
|
content: template,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PPTDoc = {
|
||||||
|
isCover: boolean;
|
||||||
|
title: string;
|
||||||
|
sections: PPTSection[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const basicTheme = (doc: PPTDoc) => {
|
||||||
|
if (doc.isCover) {
|
||||||
|
return createBasicCover(doc.title, doc.sections[0]);
|
||||||
|
}
|
||||||
|
if (doc.sections.length === 1) {
|
||||||
|
return basic1section(doc.title, doc.sections[0]);
|
||||||
|
}
|
||||||
|
if (doc.sections.length === 2) {
|
||||||
|
return basic2section(doc.title, doc.sections[0], doc.sections[1]);
|
||||||
|
}
|
||||||
|
if (doc.sections.length === 3) {
|
||||||
|
return basic3section(
|
||||||
|
doc.title,
|
||||||
|
doc.sections[0],
|
||||||
|
doc.sections[1],
|
||||||
|
doc.sections[2]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return basic4section(
|
||||||
|
doc.title,
|
||||||
|
doc.sections[0],
|
||||||
|
doc.sections[1],
|
||||||
|
doc.sections[2],
|
||||||
|
doc.sections[3]
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,97 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "page",
|
||||||
|
"meta": {
|
||||||
|
"id": "doc:home",
|
||||||
|
"title": "",
|
||||||
|
"createDate": 1706086837052,
|
||||||
|
"tags": []
|
||||||
|
},
|
||||||
|
"blocks": {
|
||||||
|
"type": "block",
|
||||||
|
"id": "QiBH29dycZ",
|
||||||
|
"flavour": "affine:page",
|
||||||
|
"version": 2,
|
||||||
|
"props": {
|
||||||
|
"title": {
|
||||||
|
"$blocksuite:internal:text$": true,
|
||||||
|
"delta": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"id": "WZ9zUizVrs",
|
||||||
|
"flavour": "affine:surface",
|
||||||
|
"version": 5,
|
||||||
|
"props": {
|
||||||
|
"elements": {
|
||||||
|
"DcoFASHQKI": {
|
||||||
|
"index": "a1",
|
||||||
|
"seed": 1391437441,
|
||||||
|
"xywh": "[-83.54346701558063,-50.225603646128235,1600.17596435546875,179]",
|
||||||
|
"rotate": 0,
|
||||||
|
"text": {
|
||||||
|
"affine:surface:text": true,
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "section1.title"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"color": "--affine-palette-line-black",
|
||||||
|
"fontSize": 128,
|
||||||
|
"fontFamily": "blocksuite:surface:Poppins",
|
||||||
|
"fontWeight": "400",
|
||||||
|
"fontStyle": "normal",
|
||||||
|
"textAlign": "left",
|
||||||
|
"type": "text",
|
||||||
|
"id": "DcoFASHQKI",
|
||||||
|
"hasMaxWidth": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"id": "Rb6xTvGyzU",
|
||||||
|
"flavour": "affine:image",
|
||||||
|
"version": 1,
|
||||||
|
"props": {
|
||||||
|
"caption": "background",
|
||||||
|
"sourceId": "xeeMw2R2FtjRo2Q0H14rsCpImSR9-z54W_PDZUsFvJ8=",
|
||||||
|
"width": 1920,
|
||||||
|
"height": 1080,
|
||||||
|
"index": "a0",
|
||||||
|
"xywh": "[-300.7451171875,-355.744140625,1920,1080]",
|
||||||
|
"rotate": 0,
|
||||||
|
"size": 938620
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"id": "Pa1ZO7Udi-",
|
||||||
|
"flavour": "affine:frame",
|
||||||
|
"version": 1,
|
||||||
|
"props": {
|
||||||
|
"title": {
|
||||||
|
"$blocksuite:internal:text$": true,
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "title"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"background": "--affine-palette-transparent",
|
||||||
|
"xywh": "[-340.7451171875,-395.744140625,2000,1160]",
|
||||||
|
"index": "a0"
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,297 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "page",
|
||||||
|
"meta": {
|
||||||
|
"id": "doc:home",
|
||||||
|
"title": "",
|
||||||
|
"createDate": 1712567726055,
|
||||||
|
"tags": []
|
||||||
|
},
|
||||||
|
"blocks": {
|
||||||
|
"type": "block",
|
||||||
|
"id": "A4KjEzkDl0",
|
||||||
|
"flavour": "affine:page",
|
||||||
|
"version": 2,
|
||||||
|
"props": {
|
||||||
|
"title": {
|
||||||
|
"$blocksuite:internal:text$": true,
|
||||||
|
"delta": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"id": "OpVjGBTYuZ",
|
||||||
|
"flavour": "affine:surface",
|
||||||
|
"version": 5,
|
||||||
|
"props": {
|
||||||
|
"elements": {
|
||||||
|
"TJhFxmm-4S": {
|
||||||
|
"index": "Zz",
|
||||||
|
"seed": 1730805220,
|
||||||
|
"xywh": "[-509.7051213118268,-469.55707155770506,1924.0508676236536,1089.1141431154101]",
|
||||||
|
"rotate": 0,
|
||||||
|
"shapeType": "rect",
|
||||||
|
"radius": 0,
|
||||||
|
"filled": true,
|
||||||
|
"fillColor": "--affine-palette-shape-white",
|
||||||
|
"strokeWidth": 2,
|
||||||
|
"strokeColor": "--affine-palette-line-white",
|
||||||
|
"strokeStyle": "solid",
|
||||||
|
"shapeStyle": "General",
|
||||||
|
"roughness": 1.4,
|
||||||
|
"textResizing": 1,
|
||||||
|
"maxWidth": false,
|
||||||
|
"type": "shape",
|
||||||
|
"id": "TJhFxmm-4S",
|
||||||
|
"color": "--affine-palette-line-black"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"id": "yJ1n0ocpfH",
|
||||||
|
"flavour": "affine:frame",
|
||||||
|
"version": 1,
|
||||||
|
"props": {
|
||||||
|
"title": {
|
||||||
|
"$blocksuite:internal:text$": true,
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "title"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"background": "--affine-palette-transparent",
|
||||||
|
"xywh": "[-509.7051213118268,-469.55707155770506,1924.0508676236536,1089.1141431154101]",
|
||||||
|
"index": "a0"
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"id": "9t3Ah0wozZ",
|
||||||
|
"flavour": "affine:note",
|
||||||
|
"version": 1,
|
||||||
|
"props": {
|
||||||
|
"xywh": "[-461.8167639829678,-479.74267557148,1390.0495489396078,383.28507166552515]",
|
||||||
|
"background": "--affine-palette-transparent",
|
||||||
|
"index": "a1",
|
||||||
|
"hidden": false,
|
||||||
|
"displayMode": "edgeless",
|
||||||
|
"edgeless": {
|
||||||
|
"style": {
|
||||||
|
"borderRadius": 8,
|
||||||
|
"borderSize": 4,
|
||||||
|
"borderStyle": "none",
|
||||||
|
"shadowType": ""
|
||||||
|
},
|
||||||
|
"scale": 2.3939460292889514,
|
||||||
|
"collapse": true,
|
||||||
|
"collapsedHeight": 160.10597857102414
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"id": "NHDwZTS59L",
|
||||||
|
"flavour": "affine:paragraph",
|
||||||
|
"version": 1,
|
||||||
|
"props": {
|
||||||
|
"type": "h1",
|
||||||
|
"text": {
|
||||||
|
"$blocksuite:internal:text$": true,
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "title"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"id": "LzI7P4hb4X",
|
||||||
|
"flavour": "affine:note",
|
||||||
|
"version": 1,
|
||||||
|
"props": {
|
||||||
|
"xywh": "[-452.57487563328925,-183.9800061178195,871.3963546611783,235.3688641606808]",
|
||||||
|
"background": "--affine-palette-transparent",
|
||||||
|
"index": "a3",
|
||||||
|
"hidden": false,
|
||||||
|
"displayMode": "edgeless",
|
||||||
|
"edgeless": {
|
||||||
|
"style": {
|
||||||
|
"borderRadius": 8,
|
||||||
|
"borderSize": 4,
|
||||||
|
"borderStyle": "none",
|
||||||
|
"shadowType": ""
|
||||||
|
},
|
||||||
|
"scale": 2.3939460292889514,
|
||||||
|
"collapse": true,
|
||||||
|
"collapsedHeight": 98.3183669477252
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"id": "pMlpAfbEoh",
|
||||||
|
"flavour": "affine:paragraph",
|
||||||
|
"version": 1,
|
||||||
|
"props": {
|
||||||
|
"type": "h5",
|
||||||
|
"text": {
|
||||||
|
"$blocksuite:internal:text$": true,
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "section1.title"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"id": "aHToM_8I3t",
|
||||||
|
"flavour": "affine:note",
|
||||||
|
"version": 1,
|
||||||
|
"props": {
|
||||||
|
"xywh": "[452.1184517179579,-183.9800061178195,871.3963546611783,235.3688641606808]",
|
||||||
|
"background": "--affine-palette-transparent",
|
||||||
|
"index": "a5",
|
||||||
|
"hidden": false,
|
||||||
|
"displayMode": "edgeless",
|
||||||
|
"edgeless": {
|
||||||
|
"style": {
|
||||||
|
"borderRadius": 8,
|
||||||
|
"borderSize": 4,
|
||||||
|
"borderStyle": "none",
|
||||||
|
"shadowType": ""
|
||||||
|
},
|
||||||
|
"scale": 2.3939460292889514,
|
||||||
|
"collapse": true,
|
||||||
|
"collapsedHeight": 98.3183669477252
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"id": "xIuYaOWD8L",
|
||||||
|
"flavour": "affine:paragraph",
|
||||||
|
"version": 1,
|
||||||
|
"props": {
|
||||||
|
"type": "h5",
|
||||||
|
"text": {
|
||||||
|
"$blocksuite:internal:text$": true,
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "section2.title"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"id": "mZBqMOupoM",
|
||||||
|
"flavour": "affine:note",
|
||||||
|
"version": 1,
|
||||||
|
"props": {
|
||||||
|
"xywh": "[-428.74023932678745,-63.577752506957125,849.0831963889726,551.8234792085989]",
|
||||||
|
"background": "--affine-palette-transparent",
|
||||||
|
"index": "a6",
|
||||||
|
"hidden": false,
|
||||||
|
"displayMode": "edgeless",
|
||||||
|
"edgeless": {
|
||||||
|
"style": {
|
||||||
|
"borderRadius": 8,
|
||||||
|
"borderSize": 4,
|
||||||
|
"borderStyle": "none",
|
||||||
|
"shadowType": ""
|
||||||
|
},
|
||||||
|
"scale": 1.4981539090092388,
|
||||||
|
"collapse": true,
|
||||||
|
"collapsedHeight": 368.33564021037836
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"id": "MbDU4z8Y4c",
|
||||||
|
"flavour": "affine:paragraph",
|
||||||
|
"version": 1,
|
||||||
|
"props": {
|
||||||
|
"type": "h6",
|
||||||
|
"text": {
|
||||||
|
"$blocksuite:internal:text$": true,
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "section1.content"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"id": "DPQCANC2uz",
|
||||||
|
"flavour": "affine:note",
|
||||||
|
"version": 1,
|
||||||
|
"props": {
|
||||||
|
"xywh": "[474.43160999016345,-63.577752506957125,849.0831963889726,551.8234792085989]",
|
||||||
|
"background": "--affine-palette-transparent",
|
||||||
|
"index": "a8",
|
||||||
|
"hidden": false,
|
||||||
|
"displayMode": "edgeless",
|
||||||
|
"edgeless": {
|
||||||
|
"style": {
|
||||||
|
"borderRadius": 8,
|
||||||
|
"borderSize": 4,
|
||||||
|
"borderStyle": "none",
|
||||||
|
"shadowType": ""
|
||||||
|
},
|
||||||
|
"scale": 1.4981539090092388,
|
||||||
|
"collapse": true,
|
||||||
|
"collapsedHeight": 368.33564021037836
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "block",
|
||||||
|
"id": "8jKa1dvzeC",
|
||||||
|
"flavour": "affine:paragraph",
|
||||||
|
"version": 1,
|
||||||
|
"props": {
|
||||||
|
"type": "h6",
|
||||||
|
"text": {
|
||||||
|
"$blocksuite:internal:text$": true,
|
||||||
|
"delta": [
|
||||||
|
{
|
||||||
|
"insert": "section2.content"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"children": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
@ -0,0 +1,12 @@
|
|||||||
|
import { type ActionEventType, AIProvider } from '../provider.js';
|
||||||
|
|
||||||
|
export function reportResponse(event: ActionEventType) {
|
||||||
|
const lastAction = AIProvider.actionHistory.at(-1);
|
||||||
|
if (!lastAction) return;
|
||||||
|
|
||||||
|
AIProvider.slots.actions.emit({
|
||||||
|
action: lastAction.action,
|
||||||
|
options: lastAction.options,
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
}
|
@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
type ConnectorElementModel,
|
||||||
|
type EdgelessRootService,
|
||||||
|
SurfaceBlockComponent,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
|
|
||||||
|
export const getConnectorFromId = (
|
||||||
|
id: string,
|
||||||
|
surface: EdgelessRootService
|
||||||
|
) => {
|
||||||
|
return surface.elements.filter(
|
||||||
|
v => SurfaceBlockComponent.isConnector(v) && v.source.id === id
|
||||||
|
) as ConnectorElementModel[];
|
||||||
|
};
|
||||||
|
export const getConnectorToId = (id: string, surface: EdgelessRootService) => {
|
||||||
|
return surface.elements.filter(
|
||||||
|
v => SurfaceBlockComponent.isConnector(v) && v.target.id === id
|
||||||
|
) as ConnectorElementModel[];
|
||||||
|
};
|
||||||
|
export const getConnectorPath = (id: string, surface: EdgelessRootService) => {
|
||||||
|
let current: string | undefined = id;
|
||||||
|
const set = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
while (current) {
|
||||||
|
if (set.has(current)) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
set.add(current);
|
||||||
|
const connector = getConnectorToId(current, surface);
|
||||||
|
if (connector.length !== 1) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
current = connector[0].source.id;
|
||||||
|
if (current) {
|
||||||
|
result.unshift(current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
type ElementTree = {
|
||||||
|
id: string;
|
||||||
|
children: ElementTree[];
|
||||||
|
};
|
||||||
|
export const findTree = (
|
||||||
|
rootId: string,
|
||||||
|
surface: EdgelessRootService
|
||||||
|
): ElementTree => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
const run = (id: string): ElementTree | undefined => {
|
||||||
|
if (set.has(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set.add(id);
|
||||||
|
const children = getConnectorFromId(id, surface);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
children: children.flatMap(model => {
|
||||||
|
const childId = model.target.id;
|
||||||
|
if (childId) {
|
||||||
|
const elementTree = run(childId);
|
||||||
|
if (elementTree) {
|
||||||
|
return [elementTree];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const tree = run(rootId);
|
||||||
|
assertExists(tree);
|
||||||
|
return tree;
|
||||||
|
};
|
||||||
|
export const findLeaf = (
|
||||||
|
tree: ElementTree,
|
||||||
|
id: string
|
||||||
|
): ElementTree | undefined => {
|
||||||
|
if (tree.id === id) {
|
||||||
|
return tree;
|
||||||
|
}
|
||||||
|
for (const child of tree.children) {
|
||||||
|
const result = findLeaf(child, id);
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
@ -0,0 +1,22 @@
|
|||||||
|
import { PageEditorBlockSpecs, PageRootService } from '@blocksuite/blocks';
|
||||||
|
import { literal } from 'lit/static-html.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom PageRootService that does not load fonts
|
||||||
|
*/
|
||||||
|
class CustomPageRootService extends PageRootService {
|
||||||
|
override loadFonts() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomPageEditorBlockSpecs = PageEditorBlockSpecs.map(spec => {
|
||||||
|
if (spec.schema.model.flavour === 'affine:page') {
|
||||||
|
return {
|
||||||
|
...spec,
|
||||||
|
service: CustomPageRootService,
|
||||||
|
view: {
|
||||||
|
component: literal`affine-page-root`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return spec;
|
||||||
|
});
|
@ -0,0 +1,74 @@
|
|||||||
|
import type { BlockElement, EditorHost } from '@blocksuite/block-std';
|
||||||
|
import {
|
||||||
|
AFFINE_EDGELESS_COPILOT_WIDGET,
|
||||||
|
type EdgelessCopilotWidget,
|
||||||
|
type EdgelessRootService,
|
||||||
|
matchFlavours,
|
||||||
|
MindmapElementModel,
|
||||||
|
type ShapeElementModel,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
|
||||||
|
export function mindMapToMarkdown(mindmap: MindmapElementModel) {
|
||||||
|
let markdownStr = '';
|
||||||
|
|
||||||
|
const traverse = (
|
||||||
|
node: MindmapElementModel['tree']['children'][number],
|
||||||
|
indent: number = 0
|
||||||
|
) => {
|
||||||
|
const text = (node.element as ShapeElementModel).text?.toString() ?? '';
|
||||||
|
|
||||||
|
markdownStr += `${' '.repeat(indent)}- ${text}\n`;
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
node.children.forEach(node => traverse(node, indent + 2));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
traverse(mindmap.tree, 0);
|
||||||
|
|
||||||
|
return markdownStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMindMapRoot(ele: BlockSuite.EdgelessModelType) {
|
||||||
|
const group = ele?.group;
|
||||||
|
|
||||||
|
return group instanceof MindmapElementModel && group.tree.element === ele;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMindmapChild(ele: BlockSuite.EdgelessModelType) {
|
||||||
|
return ele?.group instanceof MindmapElementModel && !isMindMapRoot(ele);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getService(host: EditorHost) {
|
||||||
|
const edgelessService = host.spec.getService(
|
||||||
|
'affine:page'
|
||||||
|
) as EdgelessRootService;
|
||||||
|
|
||||||
|
return edgelessService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEdgelessCopilotWidget(
|
||||||
|
host: EditorHost
|
||||||
|
): EdgelessCopilotWidget {
|
||||||
|
const rootBlockId = host.doc.root?.id as string;
|
||||||
|
const copilotWidget = host.view.getWidget(
|
||||||
|
AFFINE_EDGELESS_COPILOT_WIDGET,
|
||||||
|
rootBlockId
|
||||||
|
) as EdgelessCopilotWidget;
|
||||||
|
|
||||||
|
return copilotWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findNoteBlockModel(blockElement: BlockElement) {
|
||||||
|
let curBlock = blockElement;
|
||||||
|
while (curBlock) {
|
||||||
|
if (matchFlavours(curBlock.model, ['affine:note'])) {
|
||||||
|
return curBlock.model;
|
||||||
|
}
|
||||||
|
if (matchFlavours(curBlock.model, ['affine:page', 'affine:surface'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
curBlock = curBlock.parentBlockElement;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
@ -0,0 +1,148 @@
|
|||||||
|
import type {
|
||||||
|
BlockElement,
|
||||||
|
EditorHost,
|
||||||
|
TextSelection,
|
||||||
|
} from '@blocksuite/block-std';
|
||||||
|
import type { AffineAIPanelWidget } from '@blocksuite/blocks';
|
||||||
|
import { isInsideEdgelessEditor } from '@blocksuite/blocks';
|
||||||
|
import { type BlockModel, Slice } from '@blocksuite/store';
|
||||||
|
|
||||||
|
import {
|
||||||
|
insertFromMarkdown,
|
||||||
|
markDownToDoc,
|
||||||
|
markdownToSnapshot,
|
||||||
|
} from './markdown-utils.js';
|
||||||
|
|
||||||
|
const getNoteId = (blockElement: BlockElement) => {
|
||||||
|
let element = blockElement;
|
||||||
|
while (element && element.flavour !== 'affine:note') {
|
||||||
|
element = element.parentBlockElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return element.model.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setBlockSelection = (
|
||||||
|
host: EditorHost,
|
||||||
|
parent: BlockElement,
|
||||||
|
models: BlockModel[]
|
||||||
|
) => {
|
||||||
|
const selections = models
|
||||||
|
.map(model => model.id)
|
||||||
|
.map(blockId => host.selection.create('block', { blockId }));
|
||||||
|
|
||||||
|
if (isInsideEdgelessEditor(host)) {
|
||||||
|
const surfaceElementId = getNoteId(parent);
|
||||||
|
const surfaceSelection = host.selection.create(
|
||||||
|
'surface',
|
||||||
|
selections[0].blockId,
|
||||||
|
[surfaceElementId],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
selections.push(surfaceSelection);
|
||||||
|
host.selection.set(selections);
|
||||||
|
} else {
|
||||||
|
host.selection.setGroup('note', selections);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insert = async (
|
||||||
|
host: EditorHost,
|
||||||
|
content: string,
|
||||||
|
selectBlock: BlockElement,
|
||||||
|
below: boolean = true
|
||||||
|
) => {
|
||||||
|
const blockParent = selectBlock.parentBlockElement;
|
||||||
|
const index = blockParent.model.children.findIndex(
|
||||||
|
model => model.id === selectBlock.model.id
|
||||||
|
);
|
||||||
|
const insertIndex = below ? index + 1 : index;
|
||||||
|
|
||||||
|
const models = await insertFromMarkdown(
|
||||||
|
host,
|
||||||
|
content,
|
||||||
|
blockParent.model.id,
|
||||||
|
insertIndex
|
||||||
|
);
|
||||||
|
await host.updateComplete;
|
||||||
|
requestAnimationFrame(() => setBlockSelection(host, blockParent, models));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertBelow = async (
|
||||||
|
host: EditorHost,
|
||||||
|
content: string,
|
||||||
|
selectBlock: BlockElement
|
||||||
|
) => {
|
||||||
|
await insert(host, content, selectBlock, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const insertAbove = async (
|
||||||
|
host: EditorHost,
|
||||||
|
content: string,
|
||||||
|
selectBlock: BlockElement
|
||||||
|
) => {
|
||||||
|
await insert(host, content, selectBlock, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const replace = async (
|
||||||
|
host: EditorHost,
|
||||||
|
content: string,
|
||||||
|
firstBlock: BlockElement,
|
||||||
|
selectedModels: BlockModel[],
|
||||||
|
textSelection?: TextSelection
|
||||||
|
) => {
|
||||||
|
const firstBlockParent = firstBlock.parentBlockElement;
|
||||||
|
const firstIndex = firstBlockParent.model.children.findIndex(
|
||||||
|
model => model.id === firstBlock.model.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (textSelection) {
|
||||||
|
const { snapshot, job } = await markdownToSnapshot(content, host);
|
||||||
|
await job.snapshotToSlice(
|
||||||
|
snapshot,
|
||||||
|
host.doc,
|
||||||
|
firstBlockParent.model.id,
|
||||||
|
firstIndex + 1
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
selectedModels.forEach(model => {
|
||||||
|
host.doc.deleteBlock(model);
|
||||||
|
});
|
||||||
|
|
||||||
|
const models = await insertFromMarkdown(
|
||||||
|
host,
|
||||||
|
content,
|
||||||
|
firstBlockParent.model.id,
|
||||||
|
firstIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
await host.updateComplete;
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
setBlockSelection(host, firstBlockParent, models)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const copyTextAnswer = async (panel: AffineAIPanelWidget) => {
|
||||||
|
const host = panel.host;
|
||||||
|
if (!panel.answer) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return copyText(host, panel.answer);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const copyText = async (host: EditorHost, text: string) => {
|
||||||
|
const previewDoc = await markDownToDoc(host, text);
|
||||||
|
const models = previewDoc
|
||||||
|
.getBlocksByFlavour('affine:note')
|
||||||
|
.map(b => b.model)
|
||||||
|
.flatMap(model => model.children);
|
||||||
|
const slice = Slice.fromModels(previewDoc, models);
|
||||||
|
await host.std.clipboard.copySlice(slice);
|
||||||
|
const { notificationService } = host.std.spec.getService('affine:page');
|
||||||
|
if (notificationService) {
|
||||||
|
notificationService.toast('Copied to clipboard');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
@ -0,0 +1,5 @@
|
|||||||
|
export function preprocessHtml(answer: string) {
|
||||||
|
const start = answer.indexOf('<!DOCTYPE html>');
|
||||||
|
const end = answer.indexOf('</html>');
|
||||||
|
return answer.slice(start, end + '</html>'.length);
|
||||||
|
}
|
104
packages/frontend/core/src/blocksuite/presets/ai/utils/image.ts
Normal file
104
packages/frontend/core/src/blocksuite/presets/ai/utils/image.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { fetchImage } from '@blocksuite/blocks';
|
||||||
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
|
|
||||||
|
export async function fetchImageToFile(
|
||||||
|
url: string,
|
||||||
|
filename: string,
|
||||||
|
imageProxy?: string
|
||||||
|
): Promise<File | void> {
|
||||||
|
try {
|
||||||
|
const res = await fetchImage(url, undefined, imageProxy);
|
||||||
|
if (res.ok) {
|
||||||
|
let blob = await res.blob();
|
||||||
|
if (!blob.type || !blob.type.startsWith('image/')) {
|
||||||
|
blob = await convertToPng(blob).then(tmp => tmp || blob);
|
||||||
|
}
|
||||||
|
return new File([blob], filename, { type: blob.type || 'image/png' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetchImageFallback(url, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchImageFallback(
|
||||||
|
url: string,
|
||||||
|
filename: string
|
||||||
|
): Promise<File | void> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = img.width;
|
||||||
|
c.height = img.height;
|
||||||
|
const ctx = c.getContext('2d');
|
||||||
|
assertExists(ctx);
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
c.toBlob(blob => {
|
||||||
|
if (blob) {
|
||||||
|
return resolve(new File([blob], filename, { type: blob.type }));
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
}, 'image/png');
|
||||||
|
};
|
||||||
|
img.onerror = () => resolve();
|
||||||
|
img.crossOrigin = 'anonymous';
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToPng(blob: Blob): Promise<Blob | null> {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.addEventListener('load', _ => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const c = document.createElement('canvas');
|
||||||
|
c.width = img.width;
|
||||||
|
c.height = img.height;
|
||||||
|
const ctx = c.getContext('2d');
|
||||||
|
assertExists(ctx);
|
||||||
|
ctx.imageSmoothingEnabled = true;
|
||||||
|
ctx.imageSmoothingQuality = 'high';
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
c.toBlob(resolve, 'image/png');
|
||||||
|
};
|
||||||
|
img.onerror = () => resolve(null);
|
||||||
|
img.src = reader.result as string;
|
||||||
|
});
|
||||||
|
reader.addEventListener('error', () => resolve(null));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readBlobAsURL(blob: Blob | File) {
|
||||||
|
return new Promise<string>((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = e => {
|
||||||
|
if (typeof e.target?.result === 'string') {
|
||||||
|
resolve(e.target.result);
|
||||||
|
} else {
|
||||||
|
reject();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.onerror = reject;
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canvasToBlob(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
type = 'image/png',
|
||||||
|
quality?: number
|
||||||
|
) {
|
||||||
|
return new Promise<Blob | null>(resolve =>
|
||||||
|
canvas.toBlob(resolve, type, quality)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomSeed(min = 0, max = Date.now()) {
|
||||||
|
return Math.round(Math.random() * (max - min)) + min;
|
||||||
|
}
|
@ -0,0 +1,195 @@
|
|||||||
|
import type {
|
||||||
|
EditorHost,
|
||||||
|
TextRangePoint,
|
||||||
|
TextSelection,
|
||||||
|
} from '@blocksuite/block-std';
|
||||||
|
import {
|
||||||
|
defaultImageProxyMiddleware,
|
||||||
|
MarkdownAdapter,
|
||||||
|
MixTextAdapter,
|
||||||
|
pasteMiddleware,
|
||||||
|
PlainTextAdapter,
|
||||||
|
titleMiddleware,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
|
import type {
|
||||||
|
BlockModel,
|
||||||
|
BlockSnapshot,
|
||||||
|
Doc,
|
||||||
|
DraftModel,
|
||||||
|
Slice,
|
||||||
|
SliceSnapshot,
|
||||||
|
} from '@blocksuite/store';
|
||||||
|
import { DocCollection, Job } from '@blocksuite/store';
|
||||||
|
|
||||||
|
const updateSnapshotText = (
|
||||||
|
point: TextRangePoint,
|
||||||
|
snapshot: BlockSnapshot,
|
||||||
|
model: DraftModel
|
||||||
|
) => {
|
||||||
|
const { index, length } = point;
|
||||||
|
if (!snapshot.props.text || length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
(snapshot.props.text as Record<string, unknown>).delta =
|
||||||
|
model.text?.sliceToDelta(index, length + index);
|
||||||
|
};
|
||||||
|
|
||||||
|
function processSnapshot(
|
||||||
|
snapshot: BlockSnapshot,
|
||||||
|
text: TextSelection,
|
||||||
|
host: EditorHost
|
||||||
|
) {
|
||||||
|
const model = host.doc.getBlockById(snapshot.id);
|
||||||
|
assertExists(model);
|
||||||
|
|
||||||
|
const modelId = model.id;
|
||||||
|
if (text.from.blockId === modelId) {
|
||||||
|
updateSnapshotText(text.from, snapshot, model);
|
||||||
|
}
|
||||||
|
if (text.to && text.to.blockId === modelId) {
|
||||||
|
updateSnapshotText(text.to, snapshot, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the snapshot has children, handle them recursively
|
||||||
|
snapshot.children.forEach(childSnapshot =>
|
||||||
|
processSnapshot(childSnapshot, text, host)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes the text in the given snapshot if there is a text selection.
|
||||||
|
* Only the selected portion of the snapshot will be processed.
|
||||||
|
*/
|
||||||
|
function processTextInSnapshot(snapshot: SliceSnapshot, host: EditorHost) {
|
||||||
|
const { content } = snapshot;
|
||||||
|
const text = host.selection.find('text');
|
||||||
|
if (!content.length || !text) return;
|
||||||
|
|
||||||
|
content.forEach(snapshot => processSnapshot(snapshot, text, host));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContentFromSlice(
|
||||||
|
host: EditorHost,
|
||||||
|
slice: Slice,
|
||||||
|
type: 'markdown' | 'plain-text' = 'markdown'
|
||||||
|
) {
|
||||||
|
const job = new Job({
|
||||||
|
collection: host.std.doc.collection,
|
||||||
|
middlewares: [titleMiddleware],
|
||||||
|
});
|
||||||
|
const snapshot = await job.sliceToSnapshot(slice);
|
||||||
|
processTextInSnapshot(snapshot, host);
|
||||||
|
const adapter =
|
||||||
|
type === 'markdown' ? new MarkdownAdapter(job) : new PlainTextAdapter(job);
|
||||||
|
const content = await adapter.fromSliceSnapshot({
|
||||||
|
snapshot,
|
||||||
|
assets: job.assetsManager,
|
||||||
|
});
|
||||||
|
return content.file;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPlainTextFromSlice(host: EditorHost, slice: Slice) {
|
||||||
|
const job = new Job({
|
||||||
|
collection: host.std.doc.collection,
|
||||||
|
middlewares: [titleMiddleware],
|
||||||
|
});
|
||||||
|
const snapshot = await job.sliceToSnapshot(slice);
|
||||||
|
processTextInSnapshot(snapshot, host);
|
||||||
|
const plainTextAdapter = new PlainTextAdapter(job);
|
||||||
|
const plainText = await plainTextAdapter.fromSliceSnapshot({
|
||||||
|
snapshot,
|
||||||
|
assets: job.assetsManager,
|
||||||
|
});
|
||||||
|
return plainText.file;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const markdownToSnapshot = async (
|
||||||
|
markdown: string,
|
||||||
|
host: EditorHost
|
||||||
|
) => {
|
||||||
|
const job = new Job({
|
||||||
|
collection: host.std.doc.collection,
|
||||||
|
middlewares: [defaultImageProxyMiddleware, pasteMiddleware(host.std)],
|
||||||
|
});
|
||||||
|
const markdownAdapter = new MixTextAdapter(job);
|
||||||
|
const { blockVersions, workspaceVersion, pageVersion } =
|
||||||
|
host.std.doc.collection.meta;
|
||||||
|
if (!blockVersions || !workspaceVersion || !pageVersion)
|
||||||
|
throw new Error(
|
||||||
|
'Need blockVersions, workspaceVersion, pageVersion meta information to get slice'
|
||||||
|
);
|
||||||
|
const payload = {
|
||||||
|
file: markdown,
|
||||||
|
assets: job.assetsManager,
|
||||||
|
blockVersions,
|
||||||
|
pageVersion,
|
||||||
|
workspaceVersion,
|
||||||
|
workspaceId: host.std.doc.collection.id,
|
||||||
|
pageId: host.std.doc.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const snapshot = await markdownAdapter.toSliceSnapshot(payload);
|
||||||
|
assertExists(snapshot, 'import markdown failed, expected to get a snapshot');
|
||||||
|
|
||||||
|
return {
|
||||||
|
snapshot,
|
||||||
|
job,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function insertFromMarkdown(
|
||||||
|
host: EditorHost,
|
||||||
|
markdown: string,
|
||||||
|
parent?: string,
|
||||||
|
index?: number
|
||||||
|
) {
|
||||||
|
const { snapshot, job } = await markdownToSnapshot(markdown, host);
|
||||||
|
|
||||||
|
const snapshots = snapshot.content.flatMap(x => x.children);
|
||||||
|
|
||||||
|
const models: BlockModel[] = [];
|
||||||
|
for (let i = 0; i < snapshots.length; i++) {
|
||||||
|
const blockSnapshot = snapshots[i];
|
||||||
|
const model = await job.snapshotToBlock(
|
||||||
|
blockSnapshot,
|
||||||
|
host.std.doc,
|
||||||
|
parent,
|
||||||
|
(index ?? 0) + i
|
||||||
|
);
|
||||||
|
models.push(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
return models;
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME: replace when selection is block is buggy right not
|
||||||
|
export async function replaceFromMarkdown(
|
||||||
|
host: EditorHost,
|
||||||
|
markdown: string,
|
||||||
|
parent?: string,
|
||||||
|
index?: number
|
||||||
|
) {
|
||||||
|
const { snapshot, job } = await markdownToSnapshot(markdown, host);
|
||||||
|
await job.snapshotToSlice(snapshot, host.doc, parent, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markDownToDoc(host: EditorHost, answer: string) {
|
||||||
|
const schema = host.std.doc.collection.schema;
|
||||||
|
// Should not create a new doc in the original collection
|
||||||
|
const collection = new DocCollection({ schema });
|
||||||
|
collection.meta.initialize();
|
||||||
|
const job = new Job({
|
||||||
|
collection,
|
||||||
|
middlewares: [defaultImageProxyMiddleware],
|
||||||
|
});
|
||||||
|
const mdAdapter = new MarkdownAdapter(job);
|
||||||
|
const doc = await mdAdapter.toDoc({
|
||||||
|
file: answer,
|
||||||
|
assets: job.assetsManager,
|
||||||
|
});
|
||||||
|
if (!doc) {
|
||||||
|
console.error('Failed to convert markdown to doc');
|
||||||
|
}
|
||||||
|
return doc as Doc;
|
||||||
|
}
|
@ -0,0 +1,300 @@
|
|||||||
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
|
import {
|
||||||
|
type CopilotSelectionController,
|
||||||
|
type FrameBlockModel,
|
||||||
|
ImageBlockModel,
|
||||||
|
type SurfaceBlockComponent,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
import { BlocksUtils, EdgelessRootService } from '@blocksuite/blocks';
|
||||||
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
|
import {
|
||||||
|
type BlockModel,
|
||||||
|
type DraftModel,
|
||||||
|
Slice,
|
||||||
|
toDraftModel,
|
||||||
|
} from '@blocksuite/store';
|
||||||
|
|
||||||
|
import { getEdgelessCopilotWidget, getService } from './edgeless.js';
|
||||||
|
import { getContentFromSlice } from './markdown-utils.js';
|
||||||
|
|
||||||
|
export const getRootService = (host: EditorHost) => {
|
||||||
|
return host.std.spec.getService('affine:page');
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getEdgelessRootFromEditor(editor: EditorHost) {
|
||||||
|
const edgelessRoot = editor.getElementsByTagName('affine-edgeless-root')[0];
|
||||||
|
if (!edgelessRoot) {
|
||||||
|
alert('Please switch to edgeless mode');
|
||||||
|
throw new Error('Please open switch to edgeless mode');
|
||||||
|
}
|
||||||
|
return edgelessRoot;
|
||||||
|
}
|
||||||
|
export function getEdgelessService(editor: EditorHost) {
|
||||||
|
const rootService = editor.std.spec.getService('affine:page');
|
||||||
|
if (rootService instanceof EdgelessRootService) {
|
||||||
|
return rootService;
|
||||||
|
}
|
||||||
|
alert('Please switch to edgeless mode');
|
||||||
|
throw new Error('Please open switch to edgeless mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectedToCanvas(editor: EditorHost) {
|
||||||
|
const edgelessRoot = getEdgelessRootFromEditor(editor);
|
||||||
|
const { notes, frames, shapes, images } = BlocksUtils.splitElements(
|
||||||
|
edgelessRoot.service.selection.selectedElements
|
||||||
|
);
|
||||||
|
if (notes.length + frames.length + images.length + shapes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canvas = await edgelessRoot.clipboardController.toCanvas(
|
||||||
|
[...notes, ...frames, ...images],
|
||||||
|
shapes
|
||||||
|
);
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function frameToCanvas(
|
||||||
|
frame: FrameBlockModel,
|
||||||
|
editor: EditorHost
|
||||||
|
) {
|
||||||
|
const edgelessRoot = getEdgelessRootFromEditor(editor);
|
||||||
|
const { notes, frames, shapes, images } = BlocksUtils.splitElements(
|
||||||
|
edgelessRoot.service.frame.getElementsInFrame(frame, true)
|
||||||
|
);
|
||||||
|
if (notes.length + frames.length + images.length + shapes.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const canvas = await edgelessRoot.clipboardController.toCanvas(
|
||||||
|
[...notes, ...frames, ...images],
|
||||||
|
shapes
|
||||||
|
);
|
||||||
|
if (!canvas) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectedToPng(editor: EditorHost) {
|
||||||
|
return (await selectedToCanvas(editor))?.toDataURL('image/png');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSelectedModels(editorHost: EditorHost) {
|
||||||
|
const chain = editorHost.std.command.chain();
|
||||||
|
const [_, ctx] = chain
|
||||||
|
.getSelectedModels({
|
||||||
|
types: ['block', 'text'],
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
const { selectedModels } = ctx;
|
||||||
|
return selectedModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
function traverse(model: DraftModel, drafts: DraftModel[]) {
|
||||||
|
const isDatabase = model.flavour === 'affine:database';
|
||||||
|
const children = isDatabase
|
||||||
|
? model.children
|
||||||
|
: model.children.filter(child => {
|
||||||
|
const idx = drafts.findIndex(m => m.id === child.id);
|
||||||
|
return idx >= 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
children.forEach(child => {
|
||||||
|
const idx = drafts.findIndex(m => m.id === child.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
drafts.splice(idx, 1);
|
||||||
|
}
|
||||||
|
traverse(child, drafts);
|
||||||
|
});
|
||||||
|
model.children = children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTextContentFromBlockModels(
|
||||||
|
editorHost: EditorHost,
|
||||||
|
models: BlockModel[],
|
||||||
|
type: 'markdown' | 'plain-text' = 'markdown'
|
||||||
|
) {
|
||||||
|
// Currently only filter out images and databases
|
||||||
|
const selectedTextModels = models.filter(
|
||||||
|
model =>
|
||||||
|
!BlocksUtils.matchFlavours(model, ['affine:image', 'affine:database'])
|
||||||
|
);
|
||||||
|
const drafts = selectedTextModels.map(toDraftModel);
|
||||||
|
drafts.forEach(draft => traverse(draft, drafts));
|
||||||
|
const slice = Slice.fromModels(editorHost.std.doc, drafts);
|
||||||
|
return getContentFromSlice(editorHost, slice, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSelectedTextContent(
|
||||||
|
editorHost: EditorHost,
|
||||||
|
type: 'markdown' | 'plain-text' = 'markdown'
|
||||||
|
) {
|
||||||
|
const selectedModels = getSelectedModels(editorHost);
|
||||||
|
assertExists(selectedModels);
|
||||||
|
return getTextContentFromBlockModels(editorHost, selectedModels, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function selectAboveBlocks(editorHost: EditorHost, num = 10) {
|
||||||
|
let selectedModels = getSelectedModels(editorHost);
|
||||||
|
assertExists(selectedModels);
|
||||||
|
|
||||||
|
const lastLeafModel = selectedModels[selectedModels.length - 1];
|
||||||
|
|
||||||
|
let noteModel: BlockModel | null = lastLeafModel;
|
||||||
|
let lastRootModel: BlockModel | null = null;
|
||||||
|
while (noteModel && noteModel.flavour !== 'affine:note') {
|
||||||
|
lastRootModel = noteModel;
|
||||||
|
noteModel = editorHost.doc.getParent(noteModel);
|
||||||
|
}
|
||||||
|
assertExists(noteModel);
|
||||||
|
assertExists(lastRootModel);
|
||||||
|
|
||||||
|
const endIndex = noteModel.children.indexOf(lastRootModel) + 1;
|
||||||
|
const startIndex = Math.max(0, endIndex - num);
|
||||||
|
const startBlock = noteModel.children[startIndex];
|
||||||
|
|
||||||
|
selectedModels = [];
|
||||||
|
let stop = false;
|
||||||
|
const traverse = (model: BlockModel): void => {
|
||||||
|
if (stop) return;
|
||||||
|
|
||||||
|
selectedModels.push(model);
|
||||||
|
|
||||||
|
if (model === lastLeafModel) {
|
||||||
|
stop = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
model.children.forEach(child => traverse(child));
|
||||||
|
};
|
||||||
|
noteModel.children.slice(startIndex, endIndex).forEach(traverse);
|
||||||
|
|
||||||
|
const { selection } = editorHost;
|
||||||
|
selection.set([
|
||||||
|
selection.create('text', {
|
||||||
|
from: {
|
||||||
|
blockId: startBlock.id,
|
||||||
|
index: 0,
|
||||||
|
length: startBlock.text?.length ?? 0,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
blockId: lastLeafModel.id,
|
||||||
|
index: 0,
|
||||||
|
length: selection.find('text')?.from.index ?? 0,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return getTextContentFromBlockModels(editorHost, selectedModels);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stopPropagation = (e: Event) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSurfaceElementFromEditor(editor: EditorHost) {
|
||||||
|
const { doc } = editor;
|
||||||
|
const surfaceModel = doc.getBlockByFlavour('affine:surface')[0];
|
||||||
|
assertExists(surfaceModel);
|
||||||
|
|
||||||
|
const surfaceId = surfaceModel.id;
|
||||||
|
const surfaceElement = editor.querySelector(
|
||||||
|
`affine-surface[data-block-id="${surfaceId}"]`
|
||||||
|
) as SurfaceBlockComponent;
|
||||||
|
assertExists(surfaceElement);
|
||||||
|
|
||||||
|
return surfaceElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getFirstImageInFrame = (
|
||||||
|
frame: FrameBlockModel,
|
||||||
|
editor: EditorHost
|
||||||
|
) => {
|
||||||
|
const edgelessRoot = getEdgelessRootFromEditor(editor);
|
||||||
|
const elements = edgelessRoot.service.frame.getElementsInFrame(frame, false);
|
||||||
|
const image = elements.find(ele => {
|
||||||
|
if (!BlocksUtils.isCanvasElement(ele)) {
|
||||||
|
return ele.flavour === 'affine:image';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}) as ImageBlockModel | undefined;
|
||||||
|
return image?.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSelections = (
|
||||||
|
host: EditorHost,
|
||||||
|
mode: 'flat' | 'highest' = 'flat'
|
||||||
|
) => {
|
||||||
|
const [_, data] = host.command
|
||||||
|
.chain()
|
||||||
|
.tryAll(chain => [
|
||||||
|
chain.getTextSelection(),
|
||||||
|
chain.getBlockSelections(),
|
||||||
|
chain.getImageSelections(),
|
||||||
|
])
|
||||||
|
.getSelectedBlocks({ types: ['text', 'block', 'image'], mode })
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSelectedImagesAsBlobs = async (host: EditorHost) => {
|
||||||
|
const [_, data] = host.command
|
||||||
|
.chain()
|
||||||
|
.tryAll(chain => [
|
||||||
|
chain.getTextSelection(),
|
||||||
|
chain.getBlockSelections(),
|
||||||
|
chain.getImageSelections(),
|
||||||
|
])
|
||||||
|
.getSelectedBlocks({
|
||||||
|
types: ['image'],
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const blobs = await Promise.all(
|
||||||
|
data.selectedBlocks?.map(async b => {
|
||||||
|
const sourceId = (b.model as ImageBlockModel).sourceId;
|
||||||
|
if (!sourceId) return null;
|
||||||
|
const blob = await host.doc.blobSync.get(sourceId);
|
||||||
|
if (!blob) return null;
|
||||||
|
return new File([blob], sourceId);
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
|
return blobs.filter((blob): blob is File => !!blob);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSelectedNoteAnchor = (host: EditorHost, id: string) => {
|
||||||
|
return host.querySelector(`[data-portal-block-id="${id}"] .note-background`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getCopilotSelectedElems(
|
||||||
|
host: EditorHost
|
||||||
|
): BlockSuite.EdgelessModelType[] {
|
||||||
|
const service = getService(host);
|
||||||
|
const copilotWidget = getEdgelessCopilotWidget(host);
|
||||||
|
|
||||||
|
if (copilotWidget.visible) {
|
||||||
|
return (service.tool.controllers['copilot'] as CopilotSelectionController)
|
||||||
|
.selectedElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
return service.selection.selectedElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const imageCustomInput = async (host: EditorHost) => {
|
||||||
|
const selectedElements = getCopilotSelectedElems(host);
|
||||||
|
if (selectedElements.length !== 1) return;
|
||||||
|
|
||||||
|
const imageBlock = selectedElements[0];
|
||||||
|
if (!(imageBlock instanceof ImageBlockModel)) return;
|
||||||
|
if (!imageBlock.sourceId) return;
|
||||||
|
|
||||||
|
const blob = await host.doc.blobSync.get(imageBlock.sourceId);
|
||||||
|
if (!blob) return;
|
||||||
|
|
||||||
|
return {
|
||||||
|
attachments: [blob],
|
||||||
|
};
|
||||||
|
};
|
@ -1,5 +1,5 @@
|
|||||||
|
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
||||||
import { assertExists } from '@blocksuite/global/utils';
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
import { AIProvider } from '@blocksuite/presets/ai';
|
|
||||||
import { partition } from 'lodash-es';
|
import { partition } from 'lodash-es';
|
||||||
|
|
||||||
import { CopilotClient } from './copilot-client';
|
import { CopilotClient } from './copilot-client';
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { notify } from '@affine/component';
|
import { notify } from '@affine/component';
|
||||||
import { authAtom, openSettingModalAtom } from '@affine/core/atoms';
|
import { authAtom, openSettingModalAtom } from '@affine/core/atoms';
|
||||||
|
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
||||||
import { mixpanel } from '@affine/core/utils';
|
import { mixpanel } from '@affine/core/utils';
|
||||||
import { getBaseUrl } from '@affine/graphql';
|
import { getBaseUrl } from '@affine/graphql';
|
||||||
import { Trans } from '@affine/i18n';
|
import { Trans } from '@affine/i18n';
|
||||||
import { UnauthorizedError } from '@blocksuite/blocks';
|
import { UnauthorizedError } from '@blocksuite/blocks';
|
||||||
import { assertExists } from '@blocksuite/global/utils';
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
import { AIProvider } from '@blocksuite/presets/ai';
|
|
||||||
import { getCurrentStore } from '@toeverything/infra';
|
import { getCurrentStore } from '@toeverything/infra';
|
||||||
|
|
||||||
import type { PromptKey } from './prompt';
|
import type { PromptKey } from './prompt';
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
|
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
||||||
import { mixpanel } from '@affine/core/utils';
|
import { mixpanel } from '@affine/core/utils';
|
||||||
import type { EditorHost } from '@blocksuite/block-std';
|
import type { EditorHost } from '@blocksuite/block-std';
|
||||||
import { AIProvider } from '@blocksuite/presets/ai';
|
|
||||||
import type { BlockModel } from '@blocksuite/store';
|
import type { BlockModel } from '@blocksuite/store';
|
||||||
import { lowerCase, omit } from 'lodash-es';
|
import { lowerCase, omit } from 'lodash-es';
|
||||||
|
|
||||||
|
@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
AICodeBlockSpec,
|
||||||
|
AIImageBlockSpec,
|
||||||
|
AIParagraphBlockSpec,
|
||||||
|
} from '@affine/core/blocksuite/presets/ai';
|
||||||
import type { BlockSpec } from '@blocksuite/block-std';
|
import type { BlockSpec } from '@blocksuite/block-std';
|
||||||
import {
|
import {
|
||||||
BookmarkBlockSpec,
|
BookmarkBlockSpec,
|
||||||
@ -14,11 +19,6 @@ import {
|
|||||||
ListBlockSpec,
|
ListBlockSpec,
|
||||||
NoteBlockSpec,
|
NoteBlockSpec,
|
||||||
} from '@blocksuite/blocks';
|
} from '@blocksuite/blocks';
|
||||||
import {
|
|
||||||
AICodeBlockSpec,
|
|
||||||
AIImageBlockSpec,
|
|
||||||
AIParagraphBlockSpec,
|
|
||||||
} from '@blocksuite/presets/ai';
|
|
||||||
|
|
||||||
import { CustomAttachmentBlockSpec } from './custom/attachment-block';
|
import { CustomAttachmentBlockSpec } from './custom/attachment-block';
|
||||||
|
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
AIEdgelessRootBlockSpec,
|
||||||
|
AIPageRootBlockSpec,
|
||||||
|
} from '@affine/core/blocksuite/presets/ai';
|
||||||
import { mixpanel } from '@affine/core/utils';
|
import { mixpanel } from '@affine/core/utils';
|
||||||
import type { BlockSpec } from '@blocksuite/block-std';
|
import type { BlockSpec } from '@blocksuite/block-std';
|
||||||
import type { RootService, TelemetryEventMap } from '@blocksuite/blocks';
|
import type { RootService, TelemetryEventMap } from '@blocksuite/blocks';
|
||||||
@ -6,10 +10,6 @@ import {
|
|||||||
EdgelessRootService,
|
EdgelessRootService,
|
||||||
PageRootService,
|
PageRootService,
|
||||||
} from '@blocksuite/blocks';
|
} from '@blocksuite/blocks';
|
||||||
import {
|
|
||||||
AIEdgelessRootBlockSpec,
|
|
||||||
AIPageRootBlockSpec,
|
|
||||||
} from '@blocksuite/presets/ai';
|
|
||||||
|
|
||||||
function customLoadFonts(service: RootService): void {
|
function customLoadFonts(service: RootService): void {
|
||||||
if (runtimeConfig.isSelfHosted) {
|
if (runtimeConfig.isSelfHosted) {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
|
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
||||||
import { apis } from '@affine/electron-api';
|
import { apis } from '@affine/electron-api';
|
||||||
import type { OAuthProviderType } from '@affine/graphql';
|
import type { OAuthProviderType } from '@affine/graphql';
|
||||||
import { AIProvider } from '@blocksuite/presets/ai';
|
|
||||||
import {
|
import {
|
||||||
ApplicationFocused,
|
ApplicationFocused,
|
||||||
ApplicationStarted,
|
ApplicationStarted,
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
|
import { ChatPanel } from '@affine/core/blocksuite/presets/ai';
|
||||||
import { assertExists } from '@blocksuite/global/utils';
|
import { assertExists } from '@blocksuite/global/utils';
|
||||||
import { AiIcon } from '@blocksuite/icons';
|
import { AiIcon } from '@blocksuite/icons';
|
||||||
import { ChatPanel } from '@blocksuite/presets/ai';
|
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import type { SidebarTab, SidebarTabProps } from '../sidebar-tab';
|
import type { SidebarTab, SidebarTabProps } from '../sidebar-tab';
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { Scrollable } from '@affine/component';
|
import { Scrollable } from '@affine/component';
|
||||||
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
|
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
|
||||||
|
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
||||||
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
|
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
|
||||||
import { BlockSuiteEditor } from '@affine/core/components/blocksuite/block-suite-editor';
|
import { BlockSuiteEditor } from '@affine/core/components/blocksuite/block-suite-editor';
|
||||||
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
|
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
|
||||||
import { PageNotFound } from '@affine/core/pages/404';
|
import { PageNotFound } from '@affine/core/pages/404';
|
||||||
import { Bound, type EdgelessRootService } from '@blocksuite/blocks';
|
import { Bound, type EdgelessRootService } from '@blocksuite/blocks';
|
||||||
import { DisposableGroup } from '@blocksuite/global/utils';
|
import { DisposableGroup } from '@blocksuite/global/utils';
|
||||||
import { type AffineEditorContainer } from '@blocksuite/presets';
|
import type { AffineEditorContainer } from '@blocksuite/presets';
|
||||||
import { AIProvider } from '@blocksuite/presets/ai';
|
|
||||||
import type { DocMode } from '@toeverything/infra';
|
import type { DocMode } from '@toeverything/infra';
|
||||||
import { DocsService, FrameworkScope, useService } from '@toeverything/infra';
|
import { DocsService, FrameworkScope, useService } from '@toeverything/infra';
|
||||||
import {
|
import {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { Scrollable } from '@affine/component';
|
import { Scrollable } from '@affine/component';
|
||||||
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
|
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
|
||||||
|
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
||||||
import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
|
import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
|
||||||
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
||||||
import { RecentPagesService } from '@affine/core/modules/cmdk';
|
import { RecentPagesService } from '@affine/core/modules/cmdk';
|
||||||
@ -14,7 +15,6 @@ import {
|
|||||||
} from '@blocksuite/blocks';
|
} from '@blocksuite/blocks';
|
||||||
import { DisposableGroup } from '@blocksuite/global/utils';
|
import { DisposableGroup } from '@blocksuite/global/utils';
|
||||||
import { type AffineEditorContainer } from '@blocksuite/presets';
|
import { type AffineEditorContainer } from '@blocksuite/presets';
|
||||||
import { AIProvider } from '@blocksuite/presets/ai';
|
|
||||||
import type { Doc as BlockSuiteDoc } from '@blocksuite/store';
|
import type { Doc as BlockSuiteDoc } from '@blocksuite/store';
|
||||||
import type { Doc } from '@toeverything/infra';
|
import type { Doc } from '@toeverything/infra';
|
||||||
import {
|
import {
|
||||||
|
@ -95,4 +95,4 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"ts-node": "*"
|
"ts-node": "*"
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -46,4 +46,4 @@
|
|||||||
"dev": "node --loader ts-node/esm/transpile-only.mjs ./src/bin/dev.ts"
|
"dev": "node --loader ts-node/esm/transpile-only.mjs ./src/bin/dev.ts"
|
||||||
},
|
},
|
||||||
"version": "0.14.0"
|
"version": "0.14.0"
|
||||||
}
|
}
|
@ -152,6 +152,7 @@ export const createConfiguration: (
|
|||||||
},
|
},
|
||||||
alias: {
|
alias: {
|
||||||
yjs: require.resolve('yjs'),
|
yjs: require.resolve('yjs'),
|
||||||
|
lit: join(workspaceRoot, 'node_modules', 'lit'),
|
||||||
'@blocksuite/block-std': blocksuiteBaseDir
|
'@blocksuite/block-std': blocksuiteBaseDir
|
||||||
? join(blocksuiteBaseDir, 'packages', 'framework', 'block-std', 'src')
|
? join(blocksuiteBaseDir, 'packages', 'framework', 'block-std', 'src')
|
||||||
: join(
|
: join(
|
||||||
|
@ -2,18 +2,38 @@ import { resolve } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
|
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
|
||||||
import react from '@vitejs/plugin-react-swc';
|
|
||||||
import * as fg from 'fast-glob';
|
import * as fg from 'fast-glob';
|
||||||
|
import swc from 'unplugin-swc';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
const rootDir = fileURLToPath(new URL('.', import.meta.url));
|
const rootDir = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
react({
|
|
||||||
tsDecorators: true,
|
|
||||||
}),
|
|
||||||
vanillaExtractPlugin(),
|
vanillaExtractPlugin(),
|
||||||
|
// https://github.com/vitejs/vite-plugin-react-swc/issues/85#issuecomment-2003922124
|
||||||
|
swc.vite({
|
||||||
|
jsc: {
|
||||||
|
preserveAllComments: true,
|
||||||
|
parser: {
|
||||||
|
syntax: 'typescript',
|
||||||
|
dynamicImport: true,
|
||||||
|
tsx: true,
|
||||||
|
decorators: true,
|
||||||
|
},
|
||||||
|
target: 'es2022',
|
||||||
|
externalHelpers: false,
|
||||||
|
transform: {
|
||||||
|
react: {
|
||||||
|
runtime: 'automatic',
|
||||||
|
},
|
||||||
|
useDefineForClassFields: false,
|
||||||
|
decoratorVersion: '2022-03',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sourceMaps: true,
|
||||||
|
inlineSourcesContent: true,
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
assetsInclude: ['**/*.md', '**/*.zip'],
|
assetsInclude: ['**/*.md', '**/*.zip'],
|
||||||
resolve: {
|
resolve: {
|
||||||
|
27
yarn.lock
27
yarn.lock
@ -394,10 +394,12 @@ __metadata:
|
|||||||
"@dnd-kit/modifiers": "npm:^7.0.0"
|
"@dnd-kit/modifiers": "npm:^7.0.0"
|
||||||
"@dnd-kit/sortable": "npm:^8.0.0"
|
"@dnd-kit/sortable": "npm:^8.0.0"
|
||||||
"@dnd-kit/utilities": "npm:^3.2.2"
|
"@dnd-kit/utilities": "npm:^3.2.2"
|
||||||
|
"@dotlottie/player-component": "npm:^2.7.12"
|
||||||
"@emotion/cache": "npm:^11.11.0"
|
"@emotion/cache": "npm:^11.11.0"
|
||||||
"@emotion/react": "npm:^11.11.4"
|
"@emotion/react": "npm:^11.11.4"
|
||||||
"@emotion/server": "npm:^11.11.0"
|
"@emotion/server": "npm:^11.11.0"
|
||||||
"@emotion/styled": "npm:^11.11.5"
|
"@emotion/styled": "npm:^11.11.5"
|
||||||
|
"@floating-ui/dom": "npm:^1.6.5"
|
||||||
"@juggle/resize-observer": "npm:^3.4.0"
|
"@juggle/resize-observer": "npm:^3.4.0"
|
||||||
"@marsidev/react-turnstile": "npm:^0.7.0"
|
"@marsidev/react-turnstile": "npm:^0.7.0"
|
||||||
"@perfsee/webpack": "npm:^1.12.2"
|
"@perfsee/webpack": "npm:^1.12.2"
|
||||||
@ -444,7 +446,7 @@ __metadata:
|
|||||||
jotai-devtools: "npm:^0.10.0"
|
jotai-devtools: "npm:^0.10.0"
|
||||||
jotai-effect: "npm:^1.0.0"
|
jotai-effect: "npm:^1.0.0"
|
||||||
jotai-scope: "npm:^0.6.0"
|
jotai-scope: "npm:^0.6.0"
|
||||||
lit: "npm:^3.1.2"
|
lit: "npm:^3.1.3"
|
||||||
lodash-es: "npm:^4.17.21"
|
lodash-es: "npm:^4.17.21"
|
||||||
lottie-react: "npm:^2.4.0"
|
lottie-react: "npm:^2.4.0"
|
||||||
lottie-web: "npm:^5.12.2"
|
lottie-web: "npm:^5.12.2"
|
||||||
@ -672,6 +674,7 @@ __metadata:
|
|||||||
string-width: "npm:^7.1.0"
|
string-width: "npm:^7.1.0"
|
||||||
ts-node: "npm:^10.9.2"
|
ts-node: "npm:^10.9.2"
|
||||||
typescript: "npm:^5.4.5"
|
typescript: "npm:^5.4.5"
|
||||||
|
unplugin-swc: "npm:^1.4.5"
|
||||||
vite: "npm:^5.2.8"
|
vite: "npm:^5.2.8"
|
||||||
vite-plugin-istanbul: "npm:^6.0.0"
|
vite-plugin-istanbul: "npm:^6.0.0"
|
||||||
vite-plugin-static-copy: "npm:^1.0.2"
|
vite-plugin-static-copy: "npm:^1.0.2"
|
||||||
@ -27508,6 +27511,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"load-tsconfig@npm:^0.2.5":
|
||||||
|
version: 0.2.5
|
||||||
|
resolution: "load-tsconfig@npm:0.2.5"
|
||||||
|
checksum: 10/b3176f6f0c86dbdbbc7e337440a803b0b4407c55e2e1cfc53bd3db68e0211448f36428a6075ecf5e286db5d1bf791da756fc0ac4d2447717140fb6a5218ecfb4
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"loader-runner@npm:^4.1.0, loader-runner@npm:^4.2.0":
|
"loader-runner@npm:^4.1.0, loader-runner@npm:^4.2.0":
|
||||||
version: 4.3.0
|
version: 4.3.0
|
||||||
resolution: "loader-runner@npm:4.3.0"
|
resolution: "loader-runner@npm:4.3.0"
|
||||||
@ -37043,6 +37053,19 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"unplugin-swc@npm:^1.4.5":
|
||||||
|
version: 1.4.5
|
||||||
|
resolution: "unplugin-swc@npm:1.4.5"
|
||||||
|
dependencies:
|
||||||
|
"@rollup/pluginutils": "npm:^5.1.0"
|
||||||
|
load-tsconfig: "npm:^0.2.5"
|
||||||
|
unplugin: "npm:^1.10.1"
|
||||||
|
peerDependencies:
|
||||||
|
"@swc/core": ^1.2.108
|
||||||
|
checksum: 10/93ea6ef83f131730a156d41d7180741b18203a3c815200040b77a4395d13f591206a23a01001e561e445846e5f361b8bffd50c78962f9349d10f24fa4905fe13
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"unplugin@npm:1.0.1":
|
"unplugin@npm:1.0.1":
|
||||||
version: 1.0.1
|
version: 1.0.1
|
||||||
resolution: "unplugin@npm:1.0.1"
|
resolution: "unplugin@npm:1.0.1"
|
||||||
@ -37055,7 +37078,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"unplugin@npm:^1.3.1":
|
"unplugin@npm:^1.10.1, unplugin@npm:^1.3.1":
|
||||||
version: 1.10.1
|
version: 1.10.1
|
||||||
resolution: "unplugin@npm:1.10.1"
|
resolution: "unplugin@npm:1.10.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
Loading…
Reference in New Issue
Block a user