mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 21:38:44 +08:00
feat(editor): remove chat-cards and Open AI Chat menu item (#9626)
Support issue [BS-2353](https://linear.app/affine-design/issue/BS-2353). ### What changed? - Remove `Open AI Chat` menu item. - Remove `chat-cards` web component. - Add `extractAll` function for page and edgeless doc full content extract.
This commit is contained in:
+2
-3
@@ -15,7 +15,7 @@ import { property } from 'lit/decorators.js';
|
||||
|
||||
import { AIProvider } from '../../provider';
|
||||
import { getAIPanelWidget } from '../../utils/ai-widgets';
|
||||
import { extractContext } from '../../utils/extract';
|
||||
import { extractSelectedContent } from '../../utils/extract';
|
||||
|
||||
export class AskAIToolbarButton extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
@@ -75,8 +75,7 @@ export class AskAIToolbarButton extends WithDisposable(LitElement) {
|
||||
finish('success');
|
||||
const aiPanel = getAIPanelWidget(this.host);
|
||||
aiPanel.discard();
|
||||
AIProvider.slots.requestOpenWithChat.emit({ host: this.host });
|
||||
extractContext(this.host)
|
||||
extractSelectedContent(this.host)
|
||||
.then(context => {
|
||||
AIProvider.slots.requestSendWithChat.emit({
|
||||
input,
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
AIPresentationIconWithAnimation,
|
||||
AISearchIcon,
|
||||
AIStarIconWithAnimation,
|
||||
ChatWithAIIcon,
|
||||
CommentIcon,
|
||||
ExplainIcon,
|
||||
ImproveWritingIcon,
|
||||
@@ -336,19 +335,6 @@ const OthersAIGroup: AIItemGroupConfig = {
|
||||
AIProvider.slots.requestOpenWithChat.emit({
|
||||
host,
|
||||
autoSelect: true,
|
||||
appendCard: true,
|
||||
});
|
||||
panel.hide();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Open AI Chat',
|
||||
icon: ChatWithAIIcon,
|
||||
handler: host => {
|
||||
const panel = getAIPanelWidget(host);
|
||||
AIProvider.slots.requestOpenWithChat.emit({
|
||||
host,
|
||||
appendCard: true,
|
||||
});
|
||||
panel.hide();
|
||||
},
|
||||
|
||||
@@ -566,10 +566,7 @@ export function actionToResponse<T extends keyof BlockSuitePresets.AIActions>(
|
||||
handler: () => {
|
||||
reportResponse('result:continue-in-chat');
|
||||
const panel = getAIPanelWidget(host);
|
||||
AIProvider.slots.requestOpenWithChat.emit({
|
||||
host,
|
||||
appendCard: true,
|
||||
});
|
||||
AIProvider.slots.requestOpenWithChat.emit({ host });
|
||||
panel.hide();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -191,10 +191,7 @@ function buildPageResponseConfig<T extends keyof BlockSuitePresets.AIActions>(
|
||||
icon: ChatWithAIIcon,
|
||||
handler: () => {
|
||||
reportResponse('result:continue-in-chat');
|
||||
AIProvider.slots.requestOpenWithChat.emit({
|
||||
host,
|
||||
appendCard: true,
|
||||
});
|
||||
AIProvider.slots.requestOpenWithChat.emit({ host });
|
||||
panel.hide();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,560 +0,0 @@
|
||||
import type { EditorHost } from '@blocksuite/affine/block-std';
|
||||
import {
|
||||
type ImageBlockModel,
|
||||
isInsideEdgelessEditor,
|
||||
type NoteBlockModel,
|
||||
NoteDisplayMode,
|
||||
} from '@blocksuite/affine/blocks';
|
||||
import { WithDisposable } from '@blocksuite/affine/global/utils';
|
||||
import type { BlockModel } from '@blocksuite/affine/store';
|
||||
import { captureException } from '@sentry/react';
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
LitElement,
|
||||
nothing,
|
||||
type PropertyValues,
|
||||
type TemplateResult,
|
||||
} from 'lit';
|
||||
import { 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';
|
||||
import { type AIChatParams, AIProvider } from '../provider';
|
||||
import {
|
||||
getSelectedImagesAsBlobs,
|
||||
getSelectedTextContent,
|
||||
getTextContentFromBlockModels,
|
||||
selectedToCanvas,
|
||||
} from '../utils/selection-utils';
|
||||
import type { ChatContextValue } from './chat-context';
|
||||
|
||||
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);
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
enum CardType {
|
||||
Text,
|
||||
Image,
|
||||
Block,
|
||||
Doc,
|
||||
}
|
||||
|
||||
type CardBase = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
type CardText = CardBase & {
|
||||
type: CardType.Text;
|
||||
text: string;
|
||||
markdown: string;
|
||||
};
|
||||
|
||||
type CardImage = CardBase & {
|
||||
type: CardType.Image;
|
||||
image: File;
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
type CardBlock = CardBase & {
|
||||
type: CardType.Block | CardType.Doc;
|
||||
text?: string;
|
||||
markdown?: string;
|
||||
images?: File[];
|
||||
};
|
||||
|
||||
type Card = CardText | CardImage | CardBlock;
|
||||
|
||||
const MAX_CARDS = 3;
|
||||
|
||||
export class ChatCards extends WithDisposable(LitElement) {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
:host([data-show]) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
${cardsStyles}
|
||||
`;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor host!: EditorHost;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor updateContext!: (context: Partial<ChatContextValue>) => void;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor temporaryParams: AIChatParams | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
accessor isEmpty!: boolean;
|
||||
|
||||
@state()
|
||||
accessor cards: Card[] = [];
|
||||
|
||||
private _currentDocId: string | null = null;
|
||||
private _selectedCardId: number = 0;
|
||||
|
||||
static renderText({ text }: CardText) {
|
||||
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 => html`
|
||||
<div
|
||||
style=${styleMap({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
${line}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static renderImage({ caption, image }: CardImage) {
|
||||
return html`
|
||||
<div
|
||||
class="card-wrapper"
|
||||
style=${styleMap({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style=${styleMap({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxWidth: 'calc(100% - 72px)',
|
||||
})}
|
||||
>
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
static renderDoc({ text, images }: CardBlock) {
|
||||
let textTpl = html`you've chosen within the doc`;
|
||||
let imageTpl: TemplateResult<1> | typeof nothing = nothing;
|
||||
let hasImage = false;
|
||||
|
||||
if (text?.length) {
|
||||
const lines = text.split('\n');
|
||||
textTpl = html`${repeat(
|
||||
lines.slice(0, 2),
|
||||
line => line,
|
||||
line => html`
|
||||
<div
|
||||
style=${styleMap({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
${line}
|
||||
</div>
|
||||
`
|
||||
)}`;
|
||||
}
|
||||
|
||||
if (images?.length) {
|
||||
hasImage = true;
|
||||
imageTpl = html`
|
||||
<img
|
||||
style=${styleMap({
|
||||
maxWidth: '72px',
|
||||
maxHeight: '46px',
|
||||
})}
|
||||
src="${URL.createObjectURL(images[0])}"
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="card-wrapper"
|
||||
style=${styleMap({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
style=${styleMap({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxWidth: hasImage ? 'calc(100% - 72px)' : '100%',
|
||||
})}
|
||||
>
|
||||
<div class="card-title">
|
||||
${DocIcon}
|
||||
<div>Start with this doc</div>
|
||||
</div>
|
||||
<div class="second-text">${textTpl}</div>
|
||||
</div>
|
||||
${imageTpl}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderCard(card: Card) {
|
||||
if (card.type === CardType.Text) {
|
||||
return ChatCards.renderText(card);
|
||||
}
|
||||
|
||||
if (card.type === CardType.Image) {
|
||||
return ChatCards.renderImage(card);
|
||||
}
|
||||
|
||||
if (card.type === CardType.Doc) {
|
||||
return ChatCards.renderDoc(card);
|
||||
}
|
||||
|
||||
return nothing;
|
||||
}
|
||||
|
||||
private _updateCards(card: Card) {
|
||||
this.cards.unshift(card);
|
||||
|
||||
if (this.cards.length > MAX_CARDS) {
|
||||
this.cards.pop();
|
||||
}
|
||||
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private async _handleDocSelection(card: CardBlock) {
|
||||
const { text, markdown, images } = await this._extractAll();
|
||||
|
||||
card.text = text;
|
||||
card.markdown = markdown;
|
||||
card.images = images;
|
||||
}
|
||||
|
||||
private async _selectCard(card: Card) {
|
||||
AIProvider.slots.toggleChatCards.emit({ visible: false });
|
||||
|
||||
this._selectedCardId = card.id;
|
||||
|
||||
switch (card.type) {
|
||||
case CardType.Text: {
|
||||
this.updateContext({
|
||||
quote: card.text,
|
||||
markdown: card.markdown,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case CardType.Image: {
|
||||
this.updateContext({
|
||||
images: [card.image],
|
||||
});
|
||||
break;
|
||||
}
|
||||
case CardType.Block: {
|
||||
this.updateContext({
|
||||
quote: card.text,
|
||||
markdown: card.markdown,
|
||||
images: card.images,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case CardType.Doc: {
|
||||
await this._handleDocSelection(card);
|
||||
this.updateContext({
|
||||
quote: card.text,
|
||||
markdown: card.markdown,
|
||||
images: card.images,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _extract() {
|
||||
const text = await getSelectedTextContent(this.host, 'plain-text');
|
||||
const images = await getSelectedImagesAsBlobs(this.host);
|
||||
const hasText = text.length > 0;
|
||||
const hasImages = images.length > 0;
|
||||
|
||||
if (hasText && !hasImages) {
|
||||
const markdown = await getSelectedTextContent(this.host, 'markdown');
|
||||
|
||||
this._updateCards({
|
||||
id: Date.now(),
|
||||
type: CardType.Text,
|
||||
text,
|
||||
markdown,
|
||||
});
|
||||
} else if (!hasText && hasImages && images.length === 1) {
|
||||
const [_, data] = this.host.command
|
||||
.chain()
|
||||
.tryAll(chain => [chain.getImageSelections()])
|
||||
.getSelectedBlocks({
|
||||
types: ['image'],
|
||||
})
|
||||
.run();
|
||||
let caption = '';
|
||||
|
||||
if (data.currentImageSelections?.[0]) {
|
||||
caption =
|
||||
(
|
||||
this.host.doc.getBlock(data.currentImageSelections[0].blockId)
|
||||
?.model as ImageBlockModel
|
||||
)?.caption ?? '';
|
||||
}
|
||||
|
||||
this._updateCards({
|
||||
id: Date.now(),
|
||||
type: CardType.Image,
|
||||
image: images[0],
|
||||
caption,
|
||||
});
|
||||
} else {
|
||||
const markdown =
|
||||
(await getSelectedTextContent(this.host, 'markdown')).trim() || '';
|
||||
this._updateCards({
|
||||
id: Date.now(),
|
||||
type: CardType.Block,
|
||||
text,
|
||||
markdown,
|
||||
images,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async _extractOnEdgeless(host: EditorHost) {
|
||||
if (!isInsideEdgelessEditor(host)) return;
|
||||
|
||||
const canvas = await selectedToCanvas(host);
|
||||
if (!canvas) return;
|
||||
|
||||
const blob: Blob | null = await new Promise(resolve =>
|
||||
canvas.toBlob(resolve)
|
||||
);
|
||||
if (!blob) return;
|
||||
|
||||
this._updateCards({
|
||||
id: Date.now(),
|
||||
type: CardType.Image,
|
||||
image: new File([blob], 'selected.png'),
|
||||
});
|
||||
}
|
||||
|
||||
private async _extractAll() {
|
||||
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);
|
||||
|
||||
return {
|
||||
text,
|
||||
markdown,
|
||||
images,
|
||||
};
|
||||
}
|
||||
|
||||
private async _appendCardWithParams({
|
||||
host,
|
||||
mode,
|
||||
autoSelect,
|
||||
}: AIChatParams) {
|
||||
if (mode === 'edgeless') {
|
||||
await this._extractOnEdgeless(host);
|
||||
} else {
|
||||
await this._extract();
|
||||
}
|
||||
|
||||
if (!autoSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.cards.length > 0) {
|
||||
const card = this.cards[0];
|
||||
if (card.type === CardType.Doc) return;
|
||||
|
||||
await this._selectCard(card);
|
||||
}
|
||||
}
|
||||
|
||||
protected override willUpdate(changedProperties: PropertyValues) {
|
||||
Promise.resolve()
|
||||
.then(async () => {
|
||||
if (changedProperties.has('temporaryParams') && this.temporaryParams) {
|
||||
const params = this.temporaryParams;
|
||||
await this._appendCardWithParams(params);
|
||||
this.temporaryParams = null;
|
||||
}
|
||||
|
||||
if (changedProperties.has('host')) {
|
||||
if (this._currentDocId === this.host.doc.id) return;
|
||||
this._currentDocId = this.host.doc.id;
|
||||
this.cards = [];
|
||||
|
||||
const { text, images } = await this._extractAll();
|
||||
const hasText = text.length > 0;
|
||||
const hasImages = images.length > 0;
|
||||
|
||||
// Currently only supports checking on first load
|
||||
if (hasText || hasImages) {
|
||||
const card: CardBlock = {
|
||||
id: Date.now(),
|
||||
type: CardType.Doc,
|
||||
};
|
||||
if (hasText) {
|
||||
card.text = text;
|
||||
}
|
||||
if (hasImages) {
|
||||
card.images = images;
|
||||
}
|
||||
|
||||
this.cards.push(card);
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
captureException(err, {
|
||||
level: 'fatal',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this._disposables.add(
|
||||
AIProvider.slots.requestOpenWithChat.on(async params => {
|
||||
if (params.appendCard) {
|
||||
await this._appendCardWithParams(params);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
this._disposables.add(
|
||||
AIProvider.slots.toggleChatCards.on(({ visible, ok }) => {
|
||||
if (visible && ok && this._selectedCardId > 0) {
|
||||
this.cards = this.cards.filter(
|
||||
card => card.id !== this._selectedCardId
|
||||
);
|
||||
this._selectedCardId = 0;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
if (!this.isEmpty) return nothing;
|
||||
|
||||
return repeat(
|
||||
this.cards,
|
||||
card => card.id,
|
||||
card => html`
|
||||
<div @click=${() => this._selectCard(card)}>
|
||||
${this._renderCard(card)}
|
||||
</div>
|
||||
`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'chat-cards': ChatCards;
|
||||
}
|
||||
}
|
||||
@@ -313,8 +313,6 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
<div
|
||||
class="close-wrapper"
|
||||
@click=${() => {
|
||||
AIProvider.slots.toggleChatCards.emit({ visible: true });
|
||||
|
||||
if (this.curIndex >= 0 && this.curIndex < images.length) {
|
||||
const newImages = [...images];
|
||||
newImages.splice(this.curIndex, 1);
|
||||
@@ -356,7 +354,7 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
if (this.host === host) {
|
||||
context && this.updateContext(context);
|
||||
await this.updateComplete;
|
||||
input && (await this.send(input));
|
||||
await this.send(input);
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -392,7 +390,6 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
|
||||
<div
|
||||
class="chat-quote-close"
|
||||
@click=${() => {
|
||||
AIProvider.slots.toggleChatCards.emit({ visible: true });
|
||||
this.updateContext({ quote: '', markdown: '' });
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -40,12 +40,6 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow-y: auto;
|
||||
|
||||
chat-cards {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-panel-messages-placeholder {
|
||||
@@ -138,9 +132,6 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
||||
@query('.chat-panel-messages')
|
||||
accessor messagesContainer: HTMLDivElement | null = null;
|
||||
|
||||
@state()
|
||||
accessor showChatCards = true;
|
||||
|
||||
private _renderAIOnboarding() {
|
||||
return this.isLoading ||
|
||||
!this.host?.doc.get(FeatureFlagService).getFlag('enable_ai_onboarding')
|
||||
@@ -251,12 +242,6 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
||||
</div>`;
|
||||
}
|
||||
)}
|
||||
<chat-cards
|
||||
.updateContext=${this.updateContext}
|
||||
.host=${this.host}
|
||||
.isEmpty=${items.length === 0}
|
||||
?data-show=${this.showChatCards}
|
||||
></chat-cards>
|
||||
</div>
|
||||
${this.showDownIndicator
|
||||
? html`<div class="down-indicator" @click=${this.scrollToEnd}>
|
||||
@@ -289,11 +274,6 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) {
|
||||
}
|
||||
})
|
||||
);
|
||||
disposables.add(
|
||||
AIProvider.slots.toggleChatCards.on(({ visible }) => {
|
||||
this.showChatCards = visible;
|
||||
})
|
||||
);
|
||||
disposables.add(
|
||||
this.host.selection.slots.changed.on(() => {
|
||||
this._selectionValue = this.host.selection.value;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createRef, type Ref, ref } from 'lit/directives/ref.js';
|
||||
|
||||
import { AIHelpIcon, SmallHintIcon } from '../_common/icons';
|
||||
import { AIProvider } from '../provider';
|
||||
import { extractSelectedContent } from '../utils/extract';
|
||||
import {
|
||||
getSelectedImagesAsBlobs,
|
||||
getSelectedTextContent,
|
||||
@@ -222,30 +223,34 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) {
|
||||
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();
|
||||
}
|
||||
|
||||
if (action === 'chat' && event === 'finished') {
|
||||
AIProvider.slots.toggleChatCards.emit({
|
||||
visible: true,
|
||||
ok: status === 'success',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
AIProvider.slots.userInfo.on(userInfo => {
|
||||
if (userInfo) {
|
||||
this._resetItems();
|
||||
}
|
||||
});
|
||||
this._disposables.add(
|
||||
AIProvider.slots.actions.on(({ action, event }) => {
|
||||
const { status } = this.chatContextValue;
|
||||
if (
|
||||
action !== 'chat' &&
|
||||
event === 'finished' &&
|
||||
(status === 'idle' || status === 'success')
|
||||
) {
|
||||
this._resetItems();
|
||||
}
|
||||
})
|
||||
);
|
||||
this._disposables.add(
|
||||
AIProvider.slots.userInfo.on(userInfo => {
|
||||
if (userInfo) {
|
||||
this._resetItems();
|
||||
}
|
||||
})
|
||||
);
|
||||
this._disposables.add(
|
||||
AIProvider.slots.requestOpenWithChat.on(async ({ host }) => {
|
||||
if (this.host === host) {
|
||||
const context = await extractSelectedContent(host);
|
||||
if (!context) return;
|
||||
this.updateContext(context);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
updateContext = (context: Partial<ChatContextValue>) => {
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
AIPresentationIcon,
|
||||
AIPresentationIconWithAnimation,
|
||||
AISearchIcon,
|
||||
ChatWithAIIcon,
|
||||
CommentIcon,
|
||||
ExplainIcon,
|
||||
ImproveWritingIcon,
|
||||
@@ -110,21 +109,6 @@ const othersGroup: AIItemGroupConfig = {
|
||||
host,
|
||||
mode: 'edgeless',
|
||||
autoSelect: true,
|
||||
appendCard: true,
|
||||
});
|
||||
panel.hide();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Open AI Chat',
|
||||
icon: ChatWithAIIcon,
|
||||
showWhen: () => true,
|
||||
handler: host => {
|
||||
const panel = getAIPanelWidget(host);
|
||||
AIProvider.slots.requestOpenWithChat.emit({
|
||||
host,
|
||||
mode: 'edgeless',
|
||||
appendCard: true,
|
||||
});
|
||||
panel.hide();
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@ import { html } from 'lit';
|
||||
import { AIProvider } from '../../provider';
|
||||
import { getAIPanelWidget } from '../../utils/ai-widgets';
|
||||
import { getEdgelessCopilotWidget } from '../../utils/edgeless';
|
||||
import { extractContext } from '../../utils/extract';
|
||||
import { extractSelectedContent } from '../../utils/extract';
|
||||
import { edgelessAIGroups } from './actions-config';
|
||||
|
||||
noop(EdgelessCopilotToolbarEntry);
|
||||
@@ -48,8 +48,7 @@ export function setupEdgelessElementToolbarAIEntry(
|
||||
aiPanel.config.generateAnswer = ({ finish, input }) => {
|
||||
finish('success');
|
||||
aiPanel.discard();
|
||||
AIProvider.slots.requestOpenWithChat.emit({ host: edgeless.host });
|
||||
extractContext(edgeless.host)
|
||||
extractSelectedContent(edgeless.host)
|
||||
.then(context => {
|
||||
AIProvider.slots.requestSendWithChat.emit({
|
||||
input,
|
||||
|
||||
@@ -20,12 +20,11 @@ export interface AIChatParams {
|
||||
mode?: 'page' | 'edgeless';
|
||||
// Auto select and append selection to input via `Continue with AI` action.
|
||||
autoSelect?: boolean;
|
||||
appendCard?: boolean;
|
||||
}
|
||||
|
||||
export interface AISendParams {
|
||||
host: EditorHost;
|
||||
input?: string;
|
||||
input: string;
|
||||
context?: Partial<ChatContextValue | null>;
|
||||
}
|
||||
|
||||
@@ -128,11 +127,6 @@ export class AIProvider {
|
||||
}>(),
|
||||
// downstream can emit this slot to notify ai presets that user info has been updated
|
||||
userInfo: new Slot<AIUserInfo | null>(),
|
||||
// add more if needed
|
||||
toggleChatCards: new Slot<{
|
||||
visible: boolean;
|
||||
ok?: boolean;
|
||||
}>(),
|
||||
};
|
||||
|
||||
// track the history of triggered actions (in memory only)
|
||||
|
||||
@@ -1,29 +1,35 @@
|
||||
import type { EditorHost } from '@blocksuite/affine/block-std';
|
||||
import {
|
||||
DocModeProvider,
|
||||
type ImageBlockModel,
|
||||
isInsideEdgelessEditor,
|
||||
type NoteBlockModel,
|
||||
NoteDisplayMode,
|
||||
} from '@blocksuite/affine/blocks';
|
||||
import type { BlockModel } from '@blocksuite/affine/store';
|
||||
|
||||
import type { ChatContextValue } from '../chat-panel/chat-context';
|
||||
import {
|
||||
allToCanvas,
|
||||
getSelectedImagesAsBlobs,
|
||||
getSelectedTextContent,
|
||||
getTextContentFromBlockModels,
|
||||
selectedToCanvas,
|
||||
} from './selection-utils';
|
||||
|
||||
export async function extractContext(
|
||||
export async function extractSelectedContent(
|
||||
host: EditorHost
|
||||
): Promise<Partial<ChatContextValue> | null> {
|
||||
const docModeService = host.std.get(DocModeProvider);
|
||||
const mode = docModeService.getEditorMode() || 'page';
|
||||
if (mode === 'edgeless') {
|
||||
return await extractOnEdgeless(host);
|
||||
return await extractEdgelessSelected(host);
|
||||
} else {
|
||||
return await extractOnPage(host);
|
||||
return await extractPageSelected(host);
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractOnEdgeless(
|
||||
async function extractEdgelessSelected(
|
||||
host: EditorHost
|
||||
): Promise<Partial<ChatContextValue> | null> {
|
||||
if (!isInsideEdgelessEditor(host)) return null;
|
||||
@@ -41,7 +47,7 @@ export async function extractOnEdgeless(
|
||||
};
|
||||
}
|
||||
|
||||
export async function extractOnPage(
|
||||
async function extractPageSelected(
|
||||
host: EditorHost
|
||||
): Promise<Partial<ChatContextValue> | null> {
|
||||
const text = await getSelectedTextContent(host, 'plain-text');
|
||||
@@ -76,3 +82,77 @@ export async function extractOnPage(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractAllContent(
|
||||
host: EditorHost
|
||||
): Promise<Partial<ChatContextValue> | null> {
|
||||
const docModeService = host.std.get(DocModeProvider);
|
||||
const mode = docModeService.getEditorMode() || 'page';
|
||||
if (mode === 'edgeless') {
|
||||
return await extractEdgelessAll(host);
|
||||
} else {
|
||||
return await extractPageAll(host);
|
||||
}
|
||||
}
|
||||
|
||||
async function extractEdgelessAll(
|
||||
host: EditorHost
|
||||
): Promise<Partial<ChatContextValue> | null> {
|
||||
if (!isInsideEdgelessEditor(host)) return null;
|
||||
|
||||
const canvas = await allToCanvas(host);
|
||||
if (!canvas) return null;
|
||||
|
||||
const blob: Blob | null = await new Promise(resolve =>
|
||||
canvas.toBlob(resolve)
|
||||
);
|
||||
if (!blob) return null;
|
||||
|
||||
return {
|
||||
images: [new File([blob], `${host.doc.id}.png`)],
|
||||
};
|
||||
}
|
||||
|
||||
async function extractPageAll(
|
||||
host: EditorHost
|
||||
): Promise<Partial<ChatContextValue> | null> {
|
||||
const notes = host.doc
|
||||
.getBlocksByFlavour('affine:note')
|
||||
.filter(
|
||||
note =>
|
||||
(note.model as NoteBlockModel).displayMode !==
|
||||
NoteDisplayMode.EdgelessOnly
|
||||
)
|
||||
.map(note => note.model as NoteBlockModel);
|
||||
const blockModels = notes.reduce((acc, note) => {
|
||||
acc.push(...note.children);
|
||||
return acc;
|
||||
}, [] as BlockModel[]);
|
||||
const text = await getTextContentFromBlockModels(
|
||||
host,
|
||||
blockModels,
|
||||
'plain-text'
|
||||
);
|
||||
const markdown = await getTextContentFromBlockModels(
|
||||
host,
|
||||
blockModels,
|
||||
'markdown'
|
||||
);
|
||||
const blobs = await Promise.all(
|
||||
blockModels.map(async s => {
|
||||
if (s.flavour !== 'affine:image') return null;
|
||||
const sourceId = (s as ImageBlockModel)?.sourceId;
|
||||
if (!sourceId) return null;
|
||||
const blob = await (sourceId ? host.doc.blobSync.get(sourceId) : null);
|
||||
if (!blob) return null;
|
||||
return new File([blob], sourceId);
|
||||
}) ?? []
|
||||
);
|
||||
const images = blobs.filter((blob): blob is File => !!blob);
|
||||
|
||||
return {
|
||||
quote: text,
|
||||
markdown,
|
||||
images,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Slice,
|
||||
toDraftModel,
|
||||
} from '@blocksuite/affine/store';
|
||||
import type { GfxModel } from '@blocksuite/block-std/gfx';
|
||||
|
||||
import { getContentFromSlice } from '../../_common';
|
||||
import { getEdgelessCopilotWidget, getService } from './edgeless';
|
||||
@@ -39,11 +40,22 @@ export function getEdgelessService(editor: EditorHost) {
|
||||
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(
|
||||
export async function selectedToCanvas(host: EditorHost) {
|
||||
const edgelessRoot = getEdgelessRootFromEditor(host);
|
||||
return elementsToCanvas(
|
||||
host,
|
||||
edgelessRoot.service.selection.selectedElements
|
||||
);
|
||||
}
|
||||
|
||||
export async function allToCanvas(host: EditorHost) {
|
||||
const edgelessRoot = getEdgelessRootFromEditor(host);
|
||||
return elementsToCanvas(host, edgelessRoot.gfx.gfxElements);
|
||||
}
|
||||
|
||||
export async function elementsToCanvas(host: EditorHost, elements: GfxModel[]) {
|
||||
const edgelessRoot = getEdgelessRootFromEditor(host);
|
||||
const { notes, frames, shapes, images } = BlocksUtils.splitElements(elements);
|
||||
if (notes.length + frames.length + images.length + shapes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { ActionMindmap } from './ai/chat-panel/actions/mindmap';
|
||||
import { ActionSlides } from './ai/chat-panel/actions/slides';
|
||||
import { ActionText } from './ai/chat-panel/actions/text';
|
||||
import { AILoading } from './ai/chat-panel/ai-loading';
|
||||
import { ChatCards } from './ai/chat-panel/chat-cards';
|
||||
import { ChatPanelInput } from './ai/chat-panel/chat-panel-input';
|
||||
import { ChatPanelMessages } from './ai/chat-panel/chat-panel-messages';
|
||||
import { AIErrorWrapper } from './ai/messages/error';
|
||||
@@ -55,7 +54,6 @@ export function registerBlocksuitePresetsCustomComponents() {
|
||||
customElements.define('action-slides', ActionSlides);
|
||||
customElements.define('action-text', ActionText);
|
||||
customElements.define('ai-loading', AILoading);
|
||||
customElements.define('chat-cards', ChatCards);
|
||||
customElements.define('chat-panel-input', ChatPanelInput);
|
||||
customElements.define('chat-panel-messages', ChatPanelMessages);
|
||||
customElements.define('chat-panel', ChatPanel);
|
||||
|
||||
@@ -17,7 +17,10 @@ import { ViewService } from '@affine/core/modules/workbench';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { isNewTabTrigger } from '@affine/core/utils';
|
||||
import { RefNodeSlotsProvider } from '@blocksuite/affine/blocks';
|
||||
import { DisposableGroup } from '@blocksuite/affine/global/utils';
|
||||
import {
|
||||
type Disposable,
|
||||
DisposableGroup,
|
||||
} from '@blocksuite/affine/global/utils';
|
||||
import { type AffineEditorContainer } from '@blocksuite/affine/presets';
|
||||
import {
|
||||
AiIcon,
|
||||
@@ -111,16 +114,14 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
||||
}, [editorContainer, isActiveView, setActiveBlockSuiteEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
const disposable = AIProvider.slots.requestOpenWithChat.on(params => {
|
||||
const disposables: Disposable[] = [];
|
||||
const openHandler = () => {
|
||||
workbench.openSidebar();
|
||||
view.activeSidebarTab('chat');
|
||||
|
||||
if (chatPanelRef.current) {
|
||||
const chatCards = chatPanelRef.current.querySelector('chat-cards');
|
||||
if (chatCards) chatCards.temporaryParams = params;
|
||||
}
|
||||
});
|
||||
return () => disposable.dispose();
|
||||
};
|
||||
disposables.push(AIProvider.slots.requestOpenWithChat.on(openHandler));
|
||||
disposables.push(AIProvider.slots.requestSendWithChat.on(openHandler));
|
||||
return () => disposables.forEach(d => d.dispose());
|
||||
}, [activeSidebarTab, view, workbench]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
type EdgelessRootService,
|
||||
RefNodeSlotsProvider,
|
||||
} from '@blocksuite/affine/blocks';
|
||||
import { Bound, DisposableGroup } from '@blocksuite/affine/global/utils';
|
||||
import {
|
||||
Bound,
|
||||
type Disposable,
|
||||
DisposableGroup,
|
||||
} from '@blocksuite/affine/global/utils';
|
||||
import type { AffineEditorContainer } from '@blocksuite/affine/presets';
|
||||
import {
|
||||
FrameworkScope,
|
||||
@@ -123,17 +127,17 @@ function DocPeekPreviewEditor({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const disposable = AIProvider.slots.requestOpenWithChat.on(() => {
|
||||
const disposables: Disposable[] = [];
|
||||
const openHandler = () => {
|
||||
if (doc) {
|
||||
workbench.openDoc(doc.id);
|
||||
peekView.close();
|
||||
// chat panel open is already handled in <DetailPageImpl />
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposable.dispose();
|
||||
};
|
||||
disposables.push(AIProvider.slots.requestOpenWithChat.on(openHandler));
|
||||
disposables.push(AIProvider.slots.requestSendWithChat.on(openHandler));
|
||||
return () => disposables.forEach(d => d.dispose());
|
||||
}, [doc, peekView, workbench, workspace.id]);
|
||||
|
||||
const openOutlinePanel = useCallback(() => {
|
||||
|
||||
@@ -599,20 +599,6 @@ test.describe('chat with block', () => {
|
||||
.waitForSelector('chat-panel-input .chat-panel-images')
|
||||
.then(el => el.waitForElementState('visible'));
|
||||
});
|
||||
|
||||
test('open ai chat', async ({ page }) => {
|
||||
await page
|
||||
.waitForSelector('.ai-item-open-ai-chat')
|
||||
.then(i => i.click());
|
||||
const cards = await page.waitForSelector('chat-panel chat-cards');
|
||||
await cards.waitForElementState('visible');
|
||||
const cardTitles = await Promise.all(
|
||||
await cards
|
||||
.$$('.card-wrapper .card-title')
|
||||
.then(els => els.map(async el => await el.innerText()))
|
||||
);
|
||||
expect(cardTitles).toContain('Start with this Image');
|
||||
});
|
||||
});
|
||||
|
||||
// TODO(@darkskygit): block by BS-1709, enable this after bug fix
|
||||
|
||||
Reference in New Issue
Block a user