mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-08-16 23:21:08 +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",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.4.5",
|
||||
"unplugin-swc": "^1.4.5",
|
||||
"vite": "^5.2.8",
|
||||
"vite-plugin-istanbul": "^6.0.0",
|
||||
"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"
|
||||
},
|
||||
"version": "0.14.0"
|
||||
}
|
||||
}
|
@ -68,4 +68,4 @@
|
||||
}
|
||||
},
|
||||
"version": "0.14.0"
|
||||
}
|
||||
}
|
@ -109,4 +109,4 @@
|
||||
"yjs": "^13.6.14"
|
||||
},
|
||||
"version": "0.14.0"
|
||||
}
|
||||
}
|
@ -33,10 +33,12 @@
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@dotlottie/player-component": "^2.7.12",
|
||||
"@emotion/cache": "^11.11.0",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/server": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.5",
|
||||
"@floating-ui/dom": "^1.6.5",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@marsidev/react-turnstile": "^0.7.0",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
@ -69,7 +71,7 @@
|
||||
"jotai-devtools": "^0.10.0",
|
||||
"jotai-effect": "^1.0.0",
|
||||
"jotai-scope": "^0.6.0",
|
||||
"lit": "^3.1.2",
|
||||
"lit": "^3.1.3",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lottie-react": "^2.4.0",
|
||||
"lottie-web": "^5.12.2",
|
||||
@ -113,4 +115,4 @@
|
||||
"mime-types": "^2.1.35",
|
||||
"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 { AIProvider } from '@blocksuite/presets/ai';
|
||||
import { partition } from 'lodash-es';
|
||||
|
||||
import { CopilotClient } from './copilot-client';
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { notify } from '@affine/component';
|
||||
import { authAtom, openSettingModalAtom } from '@affine/core/atoms';
|
||||
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
||||
import { mixpanel } from '@affine/core/utils';
|
||||
import { getBaseUrl } from '@affine/graphql';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { UnauthorizedError } from '@blocksuite/blocks';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { AIProvider } from '@blocksuite/presets/ai';
|
||||
import { getCurrentStore } from '@toeverything/infra';
|
||||
|
||||
import type { PromptKey } from './prompt';
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
||||
import { mixpanel } from '@affine/core/utils';
|
||||
import type { EditorHost } from '@blocksuite/block-std';
|
||||
import { AIProvider } from '@blocksuite/presets/ai';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
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 {
|
||||
BookmarkBlockSpec,
|
||||
@ -14,11 +19,6 @@ import {
|
||||
ListBlockSpec,
|
||||
NoteBlockSpec,
|
||||
} from '@blocksuite/blocks';
|
||||
import {
|
||||
AICodeBlockSpec,
|
||||
AIImageBlockSpec,
|
||||
AIParagraphBlockSpec,
|
||||
} from '@blocksuite/presets/ai';
|
||||
|
||||
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 type { BlockSpec } from '@blocksuite/block-std';
|
||||
import type { RootService, TelemetryEventMap } from '@blocksuite/blocks';
|
||||
@ -6,10 +10,6 @@ import {
|
||||
EdgelessRootService,
|
||||
PageRootService,
|
||||
} from '@blocksuite/blocks';
|
||||
import {
|
||||
AIEdgelessRootBlockSpec,
|
||||
AIPageRootBlockSpec,
|
||||
} from '@blocksuite/presets/ai';
|
||||
|
||||
function customLoadFonts(service: RootService): void {
|
||||
if (runtimeConfig.isSelfHosted) {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
||||
import { apis } from '@affine/electron-api';
|
||||
import type { OAuthProviderType } from '@affine/graphql';
|
||||
import { AIProvider } from '@blocksuite/presets/ai';
|
||||
import {
|
||||
ApplicationFocused,
|
||||
ApplicationStarted,
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { ChatPanel } from '@affine/core/blocksuite/presets/ai';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { AiIcon } from '@blocksuite/icons';
|
||||
import { ChatPanel } from '@blocksuite/presets/ai';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import type { SidebarTab, SidebarTabProps } from '../sidebar-tab';
|
||||
|
@ -1,13 +1,13 @@
|
||||
import { Scrollable } from '@affine/component';
|
||||
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 { BlockSuiteEditor } from '@affine/core/components/blocksuite/block-suite-editor';
|
||||
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
|
||||
import { PageNotFound } from '@affine/core/pages/404';
|
||||
import { Bound, type EdgelessRootService } from '@blocksuite/blocks';
|
||||
import { DisposableGroup } from '@blocksuite/global/utils';
|
||||
import { type AffineEditorContainer } from '@blocksuite/presets';
|
||||
import { AIProvider } from '@blocksuite/presets/ai';
|
||||
import type { AffineEditorContainer } from '@blocksuite/presets';
|
||||
import type { DocMode } from '@toeverything/infra';
|
||||
import { DocsService, FrameworkScope, useService } from '@toeverything/infra';
|
||||
import {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { Scrollable } from '@affine/component';
|
||||
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 { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
||||
import { RecentPagesService } from '@affine/core/modules/cmdk';
|
||||
@ -14,7 +15,6 @@ import {
|
||||
} from '@blocksuite/blocks';
|
||||
import { DisposableGroup } from '@blocksuite/global/utils';
|
||||
import { type AffineEditorContainer } from '@blocksuite/presets';
|
||||
import { AIProvider } from '@blocksuite/presets/ai';
|
||||
import type { Doc as BlockSuiteDoc } from '@blocksuite/store';
|
||||
import type { Doc } from '@toeverything/infra';
|
||||
import {
|
||||
|
@ -95,4 +95,4 @@
|
||||
"peerDependencies": {
|
||||
"ts-node": "*"
|
||||
}
|
||||
}
|
||||
}
|
@ -46,4 +46,4 @@
|
||||
"dev": "node --loader ts-node/esm/transpile-only.mjs ./src/bin/dev.ts"
|
||||
},
|
||||
"version": "0.14.0"
|
||||
}
|
||||
}
|
@ -152,6 +152,7 @@ export const createConfiguration: (
|
||||
},
|
||||
alias: {
|
||||
yjs: require.resolve('yjs'),
|
||||
lit: join(workspaceRoot, 'node_modules', 'lit'),
|
||||
'@blocksuite/block-std': blocksuiteBaseDir
|
||||
? join(blocksuiteBaseDir, 'packages', 'framework', 'block-std', 'src')
|
||||
: join(
|
||||
|
@ -2,18 +2,38 @@ import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
|
||||
import react from '@vitejs/plugin-react-swc';
|
||||
import * as fg from 'fast-glob';
|
||||
import swc from 'unplugin-swc';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
const rootDir = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react({
|
||||
tsDecorators: true,
|
||||
}),
|
||||
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'],
|
||||
resolve: {
|
||||
|
27
yarn.lock
27
yarn.lock
@ -394,10 +394,12 @@ __metadata:
|
||||
"@dnd-kit/modifiers": "npm:^7.0.0"
|
||||
"@dnd-kit/sortable": "npm:^8.0.0"
|
||||
"@dnd-kit/utilities": "npm:^3.2.2"
|
||||
"@dotlottie/player-component": "npm:^2.7.12"
|
||||
"@emotion/cache": "npm:^11.11.0"
|
||||
"@emotion/react": "npm:^11.11.4"
|
||||
"@emotion/server": "npm:^11.11.0"
|
||||
"@emotion/styled": "npm:^11.11.5"
|
||||
"@floating-ui/dom": "npm:^1.6.5"
|
||||
"@juggle/resize-observer": "npm:^3.4.0"
|
||||
"@marsidev/react-turnstile": "npm:^0.7.0"
|
||||
"@perfsee/webpack": "npm:^1.12.2"
|
||||
@ -444,7 +446,7 @@ __metadata:
|
||||
jotai-devtools: "npm:^0.10.0"
|
||||
jotai-effect: "npm:^1.0.0"
|
||||
jotai-scope: "npm:^0.6.0"
|
||||
lit: "npm:^3.1.2"
|
||||
lit: "npm:^3.1.3"
|
||||
lodash-es: "npm:^4.17.21"
|
||||
lottie-react: "npm:^2.4.0"
|
||||
lottie-web: "npm:^5.12.2"
|
||||
@ -672,6 +674,7 @@ __metadata:
|
||||
string-width: "npm:^7.1.0"
|
||||
ts-node: "npm:^10.9.2"
|
||||
typescript: "npm:^5.4.5"
|
||||
unplugin-swc: "npm:^1.4.5"
|
||||
vite: "npm:^5.2.8"
|
||||
vite-plugin-istanbul: "npm:^6.0.0"
|
||||
vite-plugin-static-copy: "npm:^1.0.2"
|
||||
@ -27508,6 +27511,13 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 4.3.0
|
||||
resolution: "loader-runner@npm:4.3.0"
|
||||
@ -37043,6 +37053,19 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 1.0.1
|
||||
resolution: "unplugin@npm:1.0.1"
|
||||
@ -37055,7 +37078,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"unplugin@npm:^1.3.1":
|
||||
"unplugin@npm:^1.10.1, unplugin@npm:^1.3.1":
|
||||
version: 1.10.1
|
||||
resolution: "unplugin@npm:1.10.1"
|
||||
dependencies:
|
||||
|
Loading…
Reference in New Issue
Block a user