mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
feat(core): add button supports uploading images (#11943)
Close [AI-81](https://linear.app/affine-design/issue/AI-81).  <!-- 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:
@@ -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`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const MAX_IMAGE_COUNT = 32;
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './ai-chat-input';
|
||||
export * from './const';
|
||||
export * from './type';
|
||||
|
||||
Reference in New Issue
Block a user