mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-24 09:52:49 +08: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 { ShadowlessElement } from '@blocksuite/affine/std';
|
||||||
import { AiIcon } from '@blocksuite/icons/lit';
|
import { AiIcon } from '@blocksuite/icons/lit';
|
||||||
import { css, html } from 'lit';
|
import { css, html } from 'lit';
|
||||||
|
import { property } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
import type { ChatStatus } from '../../components/ai-chat-messages';
|
||||||
|
|
||||||
const AffineAvatarIcon = AiIcon({
|
const AffineAvatarIcon = AiIcon({
|
||||||
width: '20px',
|
width: '20px',
|
||||||
@@ -9,6 +13,9 @@ const AffineAvatarIcon = AiIcon({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export class AssistantAvatar extends ShadowlessElement {
|
export class AssistantAvatar extends ShadowlessElement {
|
||||||
|
@property({ attribute: 'data-status', reflect: true })
|
||||||
|
accessor status: ChatStatus = 'idle';
|
||||||
|
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
chat-assistant-avatar {
|
chat-assistant-avatar {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -16,8 +23,12 @@ export class AssistantAvatar extends ShadowlessElement {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
protected override render() {
|
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';
|
} from '../../_common/chat-actions-handle';
|
||||||
import {
|
import {
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
|
type ChatStatus,
|
||||||
isChatMessage,
|
isChatMessage,
|
||||||
} from '../../components/ai-chat-messages';
|
} from '../../components/ai-chat-messages';
|
||||||
import { AIChatErrorRenderer } from '../../messages/error';
|
import { AIChatErrorRenderer } from '../../messages/error';
|
||||||
@@ -39,7 +40,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
|
|||||||
accessor isLast: boolean = false;
|
accessor isLast: boolean = false;
|
||||||
|
|
||||||
@property({ attribute: 'data-status', reflect: true })
|
@property({ attribute: 'data-status', reflect: true })
|
||||||
accessor status: string = 'idle';
|
accessor status: ChatStatus = 'idle';
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor error: AIError | null = null;
|
accessor error: AIError | null = null;
|
||||||
@@ -64,7 +65,7 @@ export class ChatMessageAssistant extends WithDisposable(ShadowlessElement) {
|
|||||||
/\[\^\d+\]:{"type":"doc","docId":"[^"]+"}/.test(this.item.content);
|
/\[\^\d+\]:{"type":"doc","docId":"[^"]+"}/.test(this.item.content);
|
||||||
|
|
||||||
return html`<div class="user-info">
|
return html`<div class="user-info">
|
||||||
<chat-assistant-avatar></chat-assistant-avatar>
|
<chat-assistant-avatar .status=${this.status}></chat-assistant-avatar>
|
||||||
${isWithDocs
|
${isWithDocs
|
||||||
? html`<span class="message-info">with your docs</span>`
|
? html`<span class="message-info">with your docs</span>`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|||||||
@@ -161,16 +161,27 @@ export class ChatPanelAddPopover extends SignalWatcher(
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly _addFileChip = async () => {
|
private readonly _addFileChip = async () => {
|
||||||
const file = await openFileOrFiles();
|
const files = await openFileOrFiles({
|
||||||
if (!file) return;
|
multiple: true,
|
||||||
if (file.size > 50 * 1024 * 1024) {
|
|
||||||
toast('You can only upload files less than 50MB');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.addChip({
|
|
||||||
file,
|
|
||||||
state: 'processing',
|
|
||||||
});
|
});
|
||||||
|
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._track('file');
|
||||||
this.abortController.abort();
|
this.abortController.abort();
|
||||||
};
|
};
|
||||||
@@ -249,7 +260,10 @@ export class ChatPanelAddPopover extends SignalWatcher(
|
|||||||
accessor docDisplayConfig!: DocDisplayConfig;
|
accessor docDisplayConfig!: DocDisplayConfig;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@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 })
|
@property({ attribute: false })
|
||||||
accessor abortController!: AbortController;
|
accessor abortController!: AbortController;
|
||||||
@@ -459,8 +473,8 @@ export class ChatPanelAddPopover extends SignalWatcher(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly _addDocChip = (meta: DocMeta) => {
|
private readonly _addDocChip = async (meta: DocMeta) => {
|
||||||
this.addChip({
|
await this.addChip({
|
||||||
docId: meta.id,
|
docId: meta.id,
|
||||||
state: 'processing',
|
state: 'processing',
|
||||||
});
|
});
|
||||||
@@ -469,8 +483,8 @@ export class ChatPanelAddPopover extends SignalWatcher(
|
|||||||
this.abortController.abort();
|
this.abortController.abort();
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly _addTagChip = (tag: TagMeta) => {
|
private readonly _addTagChip = async (tag: TagMeta) => {
|
||||||
this.addChip({
|
await this.addChip({
|
||||||
tagId: tag.id,
|
tagId: tag.id,
|
||||||
state: 'processing',
|
state: 'processing',
|
||||||
});
|
});
|
||||||
@@ -478,8 +492,8 @@ export class ChatPanelAddPopover extends SignalWatcher(
|
|||||||
this.abortController.abort();
|
this.abortController.abort();
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly _addCollectionChip = (collection: CollectionMeta) => {
|
private readonly _addCollectionChip = async (collection: CollectionMeta) => {
|
||||||
this.addChip({
|
await this.addChip({
|
||||||
collectionId: collection.id,
|
collectionId: collection.id,
|
||||||
state: 'processing',
|
state: 'processing',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ export class ChatPanelChips extends SignalWatcher(
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor updateChips!: (chips: ChatChip[]) => void;
|
accessor updateChips!: (chips: ChatChip[]) => void;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor addImages!: (images: File[]) => void;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor pollContextDocsAndFiles!: () => void;
|
accessor pollContextDocsAndFiles!: () => void;
|
||||||
|
|
||||||
@@ -262,6 +265,7 @@ export class ChatPanelChips extends SignalWatcher(
|
|||||||
template: html`
|
template: html`
|
||||||
<chat-panel-add-popover
|
<chat-panel-add-popover
|
||||||
.addChip=${this._addChip}
|
.addChip=${this._addChip}
|
||||||
|
.addImages=${this.addImages}
|
||||||
.searchMenuConfig=${this.searchMenuConfig}
|
.searchMenuConfig=${this.searchMenuConfig}
|
||||||
.docDisplayConfig=${this.docDisplayConfig}
|
.docDisplayConfig=${this.docDisplayConfig}
|
||||||
.abortController=${this._abortController}
|
.abortController=${this._abortController}
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ export class ChatPanelDocChip extends SignalWatcher(
|
|||||||
const markdown = this.chip.markdown ?? new Signal<string>('');
|
const markdown = this.chip.markdown ?? new Signal<string>('');
|
||||||
markdown.value = value;
|
markdown.value = value;
|
||||||
this.updateChip(this.chip, {
|
this.updateChip(this.chip, {
|
||||||
state: 'finished',
|
|
||||||
markdown,
|
markdown,
|
||||||
tokenCount,
|
tokenCount,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type {
|
|||||||
AINetworkSearchConfig,
|
AINetworkSearchConfig,
|
||||||
AIReasoningConfig,
|
AIReasoningConfig,
|
||||||
} from '../ai-chat-input';
|
} from '../ai-chat-input';
|
||||||
|
import { MAX_IMAGE_COUNT } from '../ai-chat-input/const';
|
||||||
|
|
||||||
export class AIChatComposer extends SignalWatcher(
|
export class AIChatComposer extends SignalWatcher(
|
||||||
WithDisposable(ShadowlessElement)
|
WithDisposable(ShadowlessElement)
|
||||||
@@ -118,6 +119,7 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
.docDisplayConfig=${this.docDisplayConfig}
|
.docDisplayConfig=${this.docDisplayConfig}
|
||||||
.searchMenuConfig=${this.searchMenuConfig}
|
.searchMenuConfig=${this.searchMenuConfig}
|
||||||
.portalContainer=${this.portalContainer}
|
.portalContainer=${this.portalContainer}
|
||||||
|
.addImages=${this.addImages}
|
||||||
></chat-panel-chips>
|
></chat-panel-chips>
|
||||||
<ai-chat-input
|
<ai-chat-input
|
||||||
.host=${this.host}
|
.host=${this.host}
|
||||||
@@ -133,6 +135,7 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
.onChatSuccess=${this.onChatSuccess}
|
.onChatSuccess=${this.onChatSuccess}
|
||||||
.trackOptions=${this.trackOptions}
|
.trackOptions=${this.trackOptions}
|
||||||
.sideBarWidth=${this.sideBarWidth}
|
.sideBarWidth=${this.sideBarWidth}
|
||||||
|
.addImages=${this.addImages}
|
||||||
></ai-chat-input>
|
></ai-chat-input>
|
||||||
<div class="chat-panel-footer">
|
<div class="chat-panel-footer">
|
||||||
${InformationIcon()}
|
${InformationIcon()}
|
||||||
@@ -271,6 +274,13 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
this.chips = chips;
|
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 () => {
|
private readonly _pollContextDocsAndFiles = async () => {
|
||||||
const sessionId = await this.getSessionId();
|
const sessionId = await this.getSessionId();
|
||||||
const contextId = await this._getContextId();
|
const contextId = await this._getContextId();
|
||||||
|
|||||||
@@ -31,14 +31,13 @@ import {
|
|||||||
isTagChip,
|
isTagChip,
|
||||||
} from '../ai-chat-chips/utils';
|
} from '../ai-chat-chips/utils';
|
||||||
import type { ChatMessage } from '../ai-chat-messages';
|
import type { ChatMessage } from '../ai-chat-messages';
|
||||||
|
import { MAX_IMAGE_COUNT } from './const';
|
||||||
import type {
|
import type {
|
||||||
AIChatInputContext,
|
AIChatInputContext,
|
||||||
AINetworkSearchConfig,
|
AINetworkSearchConfig,
|
||||||
AIReasoningConfig,
|
AIReasoningConfig,
|
||||||
} from './type';
|
} from './type';
|
||||||
|
|
||||||
const MaximumImageCount = 32;
|
|
||||||
|
|
||||||
function getFirstTwoLines(text: string) {
|
function getFirstTwoLines(text: string) {
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
return lines.slice(0, 2);
|
return lines.slice(0, 2);
|
||||||
@@ -273,6 +272,9 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor sideBarWidth: Signal<number | undefined> = signal(undefined);
|
accessor sideBarWidth: Signal<number | undefined> = signal(undefined);
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor addImages!: (images: File[]) => void;
|
||||||
|
|
||||||
private get _isNetworkActive() {
|
private get _isNetworkActive() {
|
||||||
return (
|
return (
|
||||||
!!this.networkSearchConfig.visible.value &&
|
!!this.networkSearchConfig.visible.value &&
|
||||||
@@ -285,7 +287,7 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private get _isImageUploadDisabled() {
|
private get _isImageUploadDisabled() {
|
||||||
return this.chatContextValue.images.length >= MaximumImageCount;
|
return this.chatContextValue.images.length >= MAX_IMAGE_COUNT;
|
||||||
}
|
}
|
||||||
|
|
||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
@@ -456,7 +458,7 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
|
|||||||
if (item.kind === 'file' && item.type.indexOf('image') >= 0) {
|
if (item.kind === 'file' && item.type.indexOf('image') >= 0) {
|
||||||
const blob = item.getAsFile();
|
const blob = item.getAsFile();
|
||||||
if (!blob) continue;
|
if (!blob) continue;
|
||||||
this._addImages([blob]);
|
this.addImages([blob]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -483,13 +485,6 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
|
|||||||
this.reasoningConfig.setEnabled(!enable);
|
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) => {
|
private readonly _handleImageRemove = (index: number) => {
|
||||||
const oldImages = this.chatContextValue.images;
|
const oldImages = this.chatContextValue.images;
|
||||||
const newImages = oldImages.filter((_, i) => i !== index);
|
const newImages = oldImages.filter((_, i) => i !== index);
|
||||||
@@ -504,7 +499,7 @@ export class AIChatInput extends SignalWatcher(WithDisposable(LitElement)) {
|
|||||||
multiple: true,
|
multiple: true,
|
||||||
});
|
});
|
||||||
if (!images) return;
|
if (!images) return;
|
||||||
this._addImages(images);
|
this.addImages(images);
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly _onTextareaSend = async (e: MouseEvent | KeyboardEvent) => {
|
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 './ai-chat-input';
|
||||||
|
export * from './const';
|
||||||
export * from './type';
|
export * from './type';
|
||||||
|
|||||||
Reference in New Issue
Block a user