feat(core): add button supports uploading images (#11943)

Close [AI-81](https://linear.app/affine-design/issue/AI-81).

![截屏2025-04-24 16.09.19.png](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/sJGviKxfE3Ap685cl5bj/ace2f896-b652-450d-be86-fb0365d5b2bc.png)

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

- **New Features**
  - Added support for uploading and handling multiple images in the AI chat interface, with a maximum image limit enforced.
  - Chat assistant avatar now visually indicates different statuses, including a transmitting animation.

- **Improvements**
  - Enhanced file upload experience by allowing batch image uploads and clearer notifications for files exceeding size limits.
  - Stronger status handling for chat assistant messages, improving reliability and user feedback.
  - Unified image count limit across chat input components using a shared constant.

- **Bug Fixes**
  - Improved consistency in how image limits are enforced across chat components.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
akumatus
2025-04-25 02:51:20 +00:00
parent 4c5e3a875e
commit 0da5ef28d6
9 changed files with 68 additions and 32 deletions

View File

@@ -1,6 +1,10 @@
import { AIStarIconWithAnimation } from '@blocksuite/affine/components/icons';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { AiIcon } from '@blocksuite/icons/lit';
import { css, html } from 'lit';
import { property } from 'lit/decorators.js';
import type { ChatStatus } from '../../components/ai-chat-messages';
const AffineAvatarIcon = AiIcon({
width: '20px',
@@ -9,6 +13,9 @@ const AffineAvatarIcon = AiIcon({
});
export class AssistantAvatar extends ShadowlessElement {
@property({ attribute: 'data-status', reflect: true })
accessor status: ChatStatus = 'idle';
static override styles = css`
chat-assistant-avatar {
display: inline-flex;
@@ -16,8 +23,12 @@ export class AssistantAvatar extends ShadowlessElement {
gap: 8px;
}
`;
protected override render() {
return html`${AffineAvatarIcon} AFFiNE AI`;
return html`${this.status === 'transmitting'
? AIStarIconWithAnimation
: AffineAvatarIcon}
AFFiNE AI`;
}
}

View File

@@ -15,6 +15,7 @@ import {
} from '../../_common/chat-actions-handle';
import {
type ChatMessage,
type ChatStatus,
isChatMessage,
} from '../../components/ai-chat-messages';
import { AIChatErrorRenderer } from '../../messages/error';
@@ -39,7 +40,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
accessor isLast: boolean = false;
@property({ attribute: 'data-status', reflect: true })
accessor status: string = 'idle';
accessor status: ChatStatus = 'idle';
@property({ attribute: false })
accessor error: AIError | null = null;
@@ -64,7 +65,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
/\[\^\d+\]:{"type":"doc","docId":"[^"]+"}/.test(this.item.content);
return html`<div class="user-info">
<chat-assistant-avatar></chat-assistant-avatar>
<chat-assistant-avatar .status=${this.status}></chat-assistant-avatar>
${isWithDocs
? html`<span class="message-info">with your docs</span>`
: nothing}

View File

@@ -161,16 +161,27 @@ export class ChatPanelAddPopover extends SignalWatcher(
};
private readonly _addFileChip = async () => {
const file = await openFileOrFiles();
if (!file) return;
if (file.size > 50 * 1024 * 1024) {
toast('You can only upload files less than 50MB');
return;
}
this.addChip({
file,
state: 'processing',
const files = await openFileOrFiles({
multiple: true,
});
if (!files || files.length === 0) return;
const images = files.filter(file => file.type.startsWith('image/'));
if (images.length > 0) {
this.addImages(images);
}
const others = files.filter(file => !file.type.startsWith('image/'));
for (const file of others) {
if (file.size > 50 * 1024 * 1024) {
toast(`${file.name} is too large, please upload a file less than 50MB`);
} else {
await this.addChip({
file,
state: 'processing',
});
}
}
this._track('file');
this.abortController.abort();
};
@@ -249,7 +260,10 @@ export class ChatPanelAddPopover extends SignalWatcher(
accessor docDisplayConfig!: DocDisplayConfig;
@property({ attribute: false })
accessor addChip!: (chip: ChatChip) => void;
accessor addChip!: (chip: ChatChip) => Promise<void>;
@property({ attribute: false })
accessor addImages!: (images: File[]) => void;
@property({ attribute: false })
accessor abortController!: AbortController;
@@ -459,8 +473,8 @@ export class ChatPanelAddPopover extends SignalWatcher(
}
}
private readonly _addDocChip = (meta: DocMeta) => {
this.addChip({
private readonly _addDocChip = async (meta: DocMeta) => {
await this.addChip({
docId: meta.id,
state: 'processing',
});
@@ -469,8 +483,8 @@ export class ChatPanelAddPopover extends SignalWatcher(
this.abortController.abort();
};
private readonly _addTagChip = (tag: TagMeta) => {
this.addChip({
private readonly _addTagChip = async (tag: TagMeta) => {
await this.addChip({
tagId: tag.id,
state: 'processing',
});
@@ -478,8 +492,8 @@ export class ChatPanelAddPopover extends SignalWatcher(
this.abortController.abort();
};
private readonly _addCollectionChip = (collection: CollectionMeta) => {
this.addChip({
private readonly _addCollectionChip = async (collection: CollectionMeta) => {
await this.addChip({
collectionId: collection.id,
state: 'processing',
});

View File

@@ -91,6 +91,9 @@ export class ChatPanelChips extends SignalWatcher(
@property({ attribute: false })
accessor updateChips!: (chips: ChatChip[]) => void;
@property({ attribute: false })
accessor addImages!: (images: File[]) => void;
@property({ attribute: false })
accessor pollContextDocsAndFiles!: () => void;
@@ -262,6 +265,7 @@ export class ChatPanelChips extends SignalWatcher(
template: html`
<chat-panel-add-popover
.addChip=${this._addChip}
.addImages=${this.addImages}
.searchMenuConfig=${this.searchMenuConfig}
.docDisplayConfig=${this.docDisplayConfig}
.abortController=${this._abortController}

View File

@@ -120,7 +120,6 @@ export class ChatPanelDocChip extends SignalWatcher(
const markdown = this.chip.markdown ?? new Signal<string>('');
markdown.value = value;
this.updateChip(this.chip, {
state: 'finished',
markdown,
tokenCount,
});

View File

@@ -29,6 +29,7 @@ import type {
AINetworkSearchConfig,
AIReasoningConfig,
} from '../ai-chat-input';
import { MAX_IMAGE_COUNT } from '../ai-chat-input/const';
export class AIChatComposer extends SignalWatcher(
WithDisposable(ShadowlessElement)
@@ -118,6 +119,7 @@ export class AIChatComposer extends SignalWatcher(
.docDisplayConfig=${this.docDisplayConfig}
.searchMenuConfig=${this.searchMenuConfig}
.portalContainer=${this.portalContainer}
.addImages=${this.addImages}
></chat-panel-chips>
<ai-chat-input
.host=${this.host}
@@ -133,6 +135,7 @@ export class AIChatComposer extends SignalWatcher(
.onChatSuccess=${this.onChatSuccess}
.trackOptions=${this.trackOptions}
.sideBarWidth=${this.sideBarWidth}
.addImages=${this.addImages}
></ai-chat-input>
<div class="chat-panel-footer">
${InformationIcon()}
@@ -271,6 +274,13 @@ export class AIChatComposer extends SignalWatcher(
this.chips = chips;
};
private readonly addImages = (images: File[]) => {
const oldImages = this.chatContextValue.images;
this.updateContext({
images: [...oldImages, ...images].slice(0, MAX_IMAGE_COUNT),
});
};
private readonly _pollContextDocsAndFiles = async () => {
const sessionId = await this.getSessionId();
const contextId = await this._getContextId();

View File

@@ -31,14 +31,13 @@ import {
isTagChip,
} from '../ai-chat-chips/utils';
import type { ChatMessage } from '../ai-chat-messages';
import { MAX_IMAGE_COUNT } from './const';
import type {
AIChatInputContext,
AINetworkSearchConfig,
AIReasoningConfig,
} from './type';
const MaximumImageCount = 32;
function getFirstTwoLines(text: string) {
const lines = text.split('\n');
return lines.slice(0, 2);
@@ -273,6 +272,9 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
@property({ attribute: false })
accessor sideBarWidth: Signal<number | undefined> = signal(undefined);
@property({ attribute: false })
accessor addImages!: (images: File[]) => void;
private get _isNetworkActive() {
return (
!!this.networkSearchConfig.visible.value &&
@@ -285,7 +287,7 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
}
private get _isImageUploadDisabled() {
return this.chatContextValue.images.length >= MaximumImageCount;
return this.chatContextValue.images.length >= MAX_IMAGE_COUNT;
}
override connectedCallback() {
@@ -456,7 +458,7 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
if (item.kind === 'file' && item.type.indexOf('image') >= 0) {
const blob = item.getAsFile();
if (!blob) continue;
this._addImages([blob]);
this.addImages([blob]);
}
}
};
@@ -483,13 +485,6 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
this.reasoningConfig.setEnabled(!enable);
};
private _addImages(images: File[]) {
const oldImages = this.chatContextValue.images;
this.updateContext({
images: [...oldImages, ...images].slice(0, MaximumImageCount),
});
}
private readonly _handleImageRemove = (index: number) => {
const oldImages = this.chatContextValue.images;
const newImages = oldImages.filter((_, i) => i !== index);
@@ -504,7 +499,7 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
multiple: true,
});
if (!images) return;
this._addImages(images);
this.addImages(images);
};
private readonly _onTextareaSend = async (e: MouseEvent | KeyboardEvent) => {

View File

@@ -0,0 +1 @@
export const MAX_IMAGE_COUNT = 32;

View File

@@ -1,2 +1,3 @@
export * from './ai-chat-input';
export * from './const';
export * from './type';