diff --git a/packages/blocks/src/_common/components/ai-item/ai-sub-item-list.ts b/packages/blocks/src/_common/components/ai-item/ai-sub-item-list.ts index 8375fa135501..330acd42651a 100644 --- a/packages/blocks/src/_common/components/ai-item/ai-sub-item-list.ts +++ b/packages/blocks/src/_common/components/ai-item/ai-sub-item-list.ts @@ -15,7 +15,6 @@ export class AISubItemList extends WithDisposable(LitElement) { display: flex; flex-direction: column; box-sizing: border-box; - position: absolute; padding: 8px; min-width: 240px; max-height: 320px; diff --git a/packages/blocks/src/_common/components/ai-item/types.ts b/packages/blocks/src/_common/components/ai-item/types.ts index f849d71e5b03..da53dfb49f7e 100644 --- a/packages/blocks/src/_common/components/ai-item/types.ts +++ b/packages/blocks/src/_common/components/ai-item/types.ts @@ -30,11 +30,18 @@ abstract class BaseAIError extends Error { } export enum AIErrorType { + Unauthorized = 'Unauthorized', PaymentRequired = 'PaymentRequired', GeneralNetworkError = 'GeneralNetworkError', } -// todo: move to presets +export class UnauthorizedError extends BaseAIError { + readonly type = AIErrorType.Unauthorized; + constructor() { + super('Unauthorized'); + } +} + // user has used up the quota export class PaymentRequiredError extends BaseAIError { readonly type = AIErrorType.PaymentRequired; @@ -51,4 +58,7 @@ export class GeneralNetworkError extends BaseAIError { } } -export type AIError = PaymentRequiredError | GeneralNetworkError; +export type AIError = + | UnauthorizedError + | PaymentRequiredError + | GeneralNetworkError; diff --git a/packages/blocks/src/attachment-block/utils.ts b/packages/blocks/src/attachment-block/utils.ts index 274086eaf124..455f941b1c38 100644 --- a/packages/blocks/src/attachment-block/utils.ts +++ b/packages/blocks/src/attachment-block/utils.ts @@ -84,7 +84,12 @@ async function getAttachmentBlob(model: AttachmentBlockModel) { } const doc = model.doc; - const blob = await doc.blob.get(sourceId); + let blob = await doc.blob.get(sourceId); + + if (blob) { + blob = new Blob([blob], { type: model.type }); + } + return blob; } diff --git a/packages/blocks/src/root-block/widgets/ai-panel/components/state/answer.ts b/packages/blocks/src/root-block/widgets/ai-panel/components/state/answer.ts index 940f9dc4eccc..014e81503fe6 100644 --- a/packages/blocks/src/root-block/widgets/ai-panel/components/state/answer.ts +++ b/packages/blocks/src/root-block/widgets/ai-panel/components/state/answer.ts @@ -28,7 +28,7 @@ export class AIPanelAnswer extends WithDisposable(LitElement) { box-sizing: border-box; flex-direction: column; gap: 8px; - padding: 12px 8px; + padding: 12px 0; } .answer { @@ -39,7 +39,7 @@ export class AIPanelAnswer extends WithDisposable(LitElement) { gap: 4px; align-self: stretch; font-family: ${unsafeCSS(baseTheme.fontSansFamily)}; - padding: 0 8px; + padding: 0 12px; } .answer-head { @@ -76,15 +76,14 @@ export class AIPanelAnswer extends WithDisposable(LitElement) { height: 22px; align-items: center; justify-content: space-between; - gap: 8px; - padding: 0 8px; + padding: 0 12px; + gap: 4px; color: var(--affine-text-secondary-color); .text { display: flex; align-items: flex-start; - gap: 10px; flex: 1 0 0; /* light/xs */ @@ -97,6 +96,7 @@ export class AIPanelAnswer extends WithDisposable(LitElement) { .right { display: flex; align-items: center; + padding-right: 8px; .copy, .copied { @@ -124,9 +124,18 @@ export class AIPanelAnswer extends WithDisposable(LitElement) { gap: 4px; } - .response-list-container ai-item-list { - /* set item style outside ai-item */ + .response-list-container, + .action-list-container { + padding: 0 8px; + } + + /* set item style outside ai-item */ + .response-list-container ai-item-list, + .action-list-container ai-item-list { --item-padding: 4px; + } + + .response-list-container ai-item-list { --item-icon-color: var(--affine-icon-secondary); --item-icon-hover-color: var(--affine-icon-color); } @@ -172,16 +181,16 @@ export class AIPanelAnswer extends WithDisposable(LitElement) { ${this.config.responses.length > 0 ? html` -
- ${this.config.responses.map( - (group, index) => html` - ${index !== 0 - ? html`` - : nothing} + ${this.config.responses.map( + (group, index) => html` + ${index !== 0 + ? html`` + : nothing} +
- ` - )} -
+
+ ` + )} ` : nothing} ${this.config.responses.length > 0 && this.config.actions.length > 0 diff --git a/packages/blocks/src/root-block/widgets/ai-panel/components/state/error.ts b/packages/blocks/src/root-block/widgets/ai-panel/components/state/error.ts index 24214314dc0b..8f29758027ba 100644 --- a/packages/blocks/src/root-block/widgets/ai-panel/components/state/error.ts +++ b/packages/blocks/src/root-block/widgets/ai-panel/components/state/error.ts @@ -12,6 +12,7 @@ import { } from '../../../../../_common/components/index.js'; export interface AIPanelErrorConfig { + login: () => void; upgrade: () => void; responses: AIItemConfig[]; error?: AIError; @@ -70,7 +71,7 @@ export class AIPanelError extends WithDisposable(LitElement) { } } } - .upgrade { + .action-button { display: flex; padding: 4px 12px; justify-content: center; @@ -92,7 +93,7 @@ export class AIPanelError extends WithDisposable(LitElement) { line-height: 20px; /* 166.667% */ } } - .upgrade:hover { + .action-button:hover { background: var(--affine-hover-color, rgba(0, 0, 0, 0.04)); } } @@ -106,6 +107,19 @@ export class AIPanelError extends WithDisposable(LitElement) { const errorTemplate = choose( this.config.error?.type, [ + [ + AIErrorType.Unauthorized, + () => + html`
+
Answer
+
+ You need to login to AFFiNE Cloud to continue using AFFiNE AI. +
+
+
Login
+
+
`, + ], [ AIErrorType.PaymentRequired, () => html` @@ -115,7 +129,7 @@ export class AIPanelError extends WithDisposable(LitElement) { You’ve reached the current usage cap for GPT-4. You can subscribe AFFiNE AI to continue AI experience! -
+
Upgrade
diff --git a/packages/presets/src/ai/_common/icons.ts b/packages/presets/src/ai/_common/icons.ts index b7c25871d639..b66c0a690f87 100644 --- a/packages/presets/src/ai/_common/icons.ts +++ b/packages/presets/src/ai/_common/icons.ts @@ -361,3 +361,18 @@ export const ArrowUpIcon = html` `; + +export const ErrorTipIcon = html` + +`; diff --git a/packages/presets/src/ai/ai-panel.ts b/packages/presets/src/ai/ai-panel.ts index 22172197594d..15c1ad1c07b1 100644 --- a/packages/presets/src/ai/ai-panel.ts +++ b/packages/presets/src/ai/ai-panel.ts @@ -92,7 +92,7 @@ export function buildTextResponseConfig(panel: AffineAIPanelWidget) { name: '', items: [ { - name: 'Continue in chat', + name: 'Continue with AI', icon: ChatWithAIIcon, handler: () => { AIProvider.slots.requestContinueInChat.emit({ @@ -139,6 +139,11 @@ export function buildAIPanelConfig( errorStateConfig: { upgrade: () => { AIProvider.slots.requestUpgradePlan.emit({ host: panel.host }); + panel.hide(); + }, + login: () => { + AIProvider.slots.requestLogin.emit({ host: panel.host }); + panel.hide(); }, responses: [], }, diff --git a/packages/presets/src/ai/chat-panel/actions/action-wrapper.ts b/packages/presets/src/ai/chat-panel/actions/action-wrapper.ts index 02cda6ff6e91..14667f70c14b 100644 --- a/packages/presets/src/ai/chat-panel/actions/action-wrapper.ts +++ b/packages/presets/src/ai/chat-panel/actions/action-wrapper.ts @@ -16,11 +16,17 @@ export class ActionWrapper extends WithDisposable(ShadowlessElement) { gap: 18px; height: 22px; margin-bottom: 12px; - } - .action-name div:last-child { - margin-left: auto; - cursor: pointer; + div:last-child { + cursor: pointer; + display: flex; + align-items: center; + flex: 1; + + div:last-child svg { + margin-left: auto; + } + } } .answer-prompt { @@ -56,11 +62,14 @@ export class ActionWrapper extends WithDisposable(ShadowlessElement) { const originalText = item.messages[1].content; return html` -
+
(this.promptShow = !this.promptShow)} + > ${ActionIcon} -
${item.action}
-
(this.promptShow = !this.promptShow)}> - ${this.promptShow ? ArrowUpIcon : ArrowDownIcon} +
+
${item.action}
+
${this.promptShow ? ArrowUpIcon : ArrowDownIcon}
${this.promptShow diff --git a/packages/presets/src/ai/chat-panel/actions/chat-text.ts b/packages/presets/src/ai/chat-panel/actions/chat-text.ts new file mode 100644 index 000000000000..ebea43811adc --- /dev/null +++ b/packages/presets/src/ai/chat-panel/actions/chat-text.ts @@ -0,0 +1,66 @@ +import './action-wrapper.js'; + +import type { EditorHost } from '@blocksuite/block-std'; +import { ShadowlessElement, WithDisposable } from '@blocksuite/block-std'; +import { css, html, nothing } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { repeat } from 'lit/directives/repeat.js'; + +import { createTextRenderer } from '../../messages/text.js'; +@customElement('chat-text') +export class ChatText extends WithDisposable(ShadowlessElement) { + static override styles = css` + .images-container { + display: flex; + gap: 12px; + } + .image-container { + border-radius: 4px; + width: 155px; + height: 129px; + overflow: hidden; + position: relative; + display: flex; + justify-content: center; + align-items: center; + + img { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + } + } + `; + @property({ attribute: false }) + host!: EditorHost; + + @property({ attribute: false }) + blobs?: Blob[]; + + @property({ attribute: false }) + text!: string; + + protected override render() { + const { blobs, text } = this; + return html`${blobs + ? html`
+ ${repeat( + blobs, + blob => blob, + blob => { + return html`
+ +
`; + } + )} +
` + : nothing}${createTextRenderer(this.host)(text)}`; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'chat-text': ChatText; + } +} diff --git a/packages/presets/src/ai/chat-panel/chat-panel-input.ts b/packages/presets/src/ai/chat-panel/chat-panel-input.ts index 641efe47688f..6a42f285acc3 100644 --- a/packages/presets/src/ai/chat-panel/chat-panel-input.ts +++ b/packages/presets/src/ai/chat-panel/chat-panel-input.ts @@ -1,13 +1,13 @@ import type { EditorHost } from '@blocksuite/block-std'; import { WithDisposable } from '@blocksuite/block-std'; -import { openFileOrFiles } from '@blocksuite/blocks'; +import { type AIError, openFileOrFiles } from '@blocksuite/blocks'; import { css, html, LitElement } from 'lit'; import { customElement, property, query, state } from 'lit/decorators.js'; import { repeat } from 'lit/directives/repeat.js'; import { ChatSendIcon, CloseIcon, ImageIcon } from '../_common/icons.js'; import { AIProvider } from '../provider.js'; -import type { ChatItem, ChatStatus } from './index.js'; +import type { ChatItem, ChatMessage, ChatStatus } from './index.js'; const MaximumImageCount = 8; @@ -61,11 +61,26 @@ export class ChatPanelInput extends WithDisposable(LitElement) { gap: 6px; flex-wrap: wrap; position: relative; - } - .chat-panel-images img { - border-radius: 4px; - border: 1px solid var(--affine-border-color); - cursor: pointer; + + .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 { @@ -107,6 +122,12 @@ export class ChatPanelInput extends WithDisposable(LitElement) { @property({ attribute: false }) items!: ChatItem[]; + @property({ attribute: false }) + error?: Error; + + @property({ attribute: false }) + updateError!: (error: AIError) => void; + @property({ attribute: false }) updateStatus!: (status: ChatStatus) => void; @@ -129,33 +150,50 @@ export class ChatPanelInput extends WithDisposable(LitElement) { focused = false; send = async () => { + if (this.status !== 'idle' && this.status !== 'success') return; + const text = this.textarea.value; if (!text) { return; } + const { images } = this; + const { doc } = this.host; this.textarea.value = ''; this.isInputEmpty = true; + this.images = []; this.updateStatus('loading'); this.addToItems([ - { role: 'user', content: text, createdAt: new Date().toISOString() }, + { + role: 'user', + content: text, + createdAt: new Date().toISOString(), + blobs: images ? images : undefined, + }, { role: 'assistant', content: '', createdAt: new Date().toISOString() }, ]); - const res = await AIProvider.actions.chat?.({ - input: text, - attachments: this.images, - docId: this.host.doc.id, - workspaceId: this.host.doc.collection.id, - }); - - if (res) { - const items = [...this.items]; - items[items.length - 1] = { - role: 'assistant', - content: res, - createdAt: new Date().toISOString(), - }; - this.updateStatus('success'); - this.updateItems(items); + try { + const stream = AIProvider.actions.chat?.({ + input: text, + docId: doc.id, + attachments: images, + workspaceId: doc.collection.id, + stream: true, + }); + + if (stream) { + for await (const text of stream) { + this.updateStatus('transmitting'); + const items = [...this.items]; + const last = items[items.length - 1] as ChatMessage; + last.content += text; + this.updateItems(items); + } + + this.updateStatus('success'); + } + } catch (error) { + this.updateStatus('error'); + this.updateError(error as AIError); } }; @@ -188,7 +226,8 @@ export class ChatPanelInput extends WithDisposable(LitElement) { this.images, image => image.name, (image, index) => - html` { const ele = evt.target as HTMLImageElement; const rect = ele.getBoundingClientRect(); @@ -200,11 +239,9 @@ export class ChatPanelInput extends WithDisposable(LitElement) { this.closeWrapper.style.left = left + 'px'; this.closeWrapper.style.top = top + 'px'; }} - width="58" - height="58" - src="${URL.createObjectURL(image)}" - alt="${image.name}" - />` + > + ${image.name} +
` )}
`; } else { switch (item.action) { case 'Create a presentation': @@ -364,7 +384,9 @@ export class ChatPanelMessages extends WithDisposable(ShadowlessElement) { (item, index) => { return html`
${this.renderAvatar(item)} -
${this.renderItem(item)}
+
+ ${this.renderItem(item, index === items.length - 1)} +
${this.status === 'loading' && index === items.length - 1 ? this.renderLoading() diff --git a/packages/presets/src/ai/chat-panel/index.ts b/packages/presets/src/ai/chat-panel/index.ts index 7cfce87ed342..63f7ebe7348a 100644 --- a/packages/presets/src/ai/chat-panel/index.ts +++ b/packages/presets/src/ai/chat-panel/index.ts @@ -2,6 +2,7 @@ import './chat-panel-input.js'; import './chat-panel-messages.js'; import { ShadowlessElement, WithDisposable } from '@blocksuite/block-std'; +import type { AIError } from '@blocksuite/blocks'; import { css, html } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { createRef, type Ref, ref } from 'lit/directives/ref.js'; @@ -14,6 +15,7 @@ import type { ChatPanelMessages } from './chat-panel-messages.js'; export type ChatMessage = { content: string; role: 'user' | 'assistant'; + blobs?: Blob[]; createdAt: string; }; @@ -25,7 +27,12 @@ export type ChatAction = { export type ChatItem = ChatMessage | ChatAction; -export type ChatStatus = 'loading' | 'success' | 'error' | 'idle'; +export type ChatStatus = + | 'loading' + | 'success' + | 'error' + | 'idle' + | 'transmitting'; @customElement('chat-panel') export class ChatPanel extends WithDisposable(ShadowlessElement) { @@ -93,6 +100,9 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) { @state() status: ChatStatus = 'idle'; + @state() + error?: AIError; + private _chatMessages: Ref = createRef(); @@ -145,6 +155,10 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) { this.scrollToDown(); }; + updateError = (error: AIError) => { + this.error = error; + }; + scrollToDown() { requestAnimationFrame(() => this._chatMessages.value?.scrollToDown()); } @@ -157,6 +171,7 @@ export class ChatPanel extends WithDisposable(ShadowlessElement) { .host=${this.editor.host} .items=${this.items} .status=${this.status} + .error=${this.error} >