diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-tools/artifact-tool.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/artifact-tool.ts new file mode 100644 index 0000000000..f0af1f4eef --- /dev/null +++ b/packages/frontend/core/src/blocksuite/ai/components/ai-tools/artifact-tool.ts @@ -0,0 +1,159 @@ +import { LoadingIcon } from '@blocksuite/affine/components/icons'; +import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; +import type { ImageProxyService } from '@blocksuite/affine/shared/adapters'; +import { type BlockStdScope, ShadowlessElement } from '@blocksuite/affine/std'; +import type { Signal } from '@preact/signals-core'; +import { + css, + html, + nothing, + type PropertyValues, + type TemplateResult, +} from 'lit'; +import { property } from 'lit/decorators.js'; + +import { + isPreviewPanelOpen, + renderPreviewPanel, +} from './artifacts-preview-panel'; + +/** + * Base web-component for AI artifact tools. + * It encapsulates common reactive properties (data/std/width/…) + * and automatically calls `updatePreviewPanel()` when the `data` + * property changes while the preview panel is open. + */ +export abstract class ArtifactTool< + TData extends { type: 'tool-result' | 'tool-call' }, +> extends SignalWatcher(WithDisposable(ShadowlessElement)) { + static override styles = css` + .artifact-tool-card { + cursor: pointer; + margin: 8px 0; + } + + .artifact-tool-card:hover { + background-color: var(--affine-hover-color); + } + `; + + /** Tool data coming from ChatGPT (tool-call / tool-result). */ + @property({ attribute: false }) + accessor data!: TData; + + @property({ attribute: false }) + accessor width: Signal | undefined; + + @property({ attribute: false }) + accessor imageProxyService: ImageProxyService | null | undefined; + + @property({ attribute: false }) + accessor std: BlockStdScope | undefined; + + /* -------------------------- Card meta hooks -------------------------- */ + + /** + * Sub-class must provide primary information for the card. + */ + protected abstract getCardMeta(): { + title: string; + /** Page / file icon shown when not loading */ + icon: TemplateResult | HTMLElement | string | null; + /** Whether the spinner should be displayed */ + loading: boolean; + /** Extra css class appended to card root */ + className?: string; + }; + + /** Banner shown on the right side of the card (can be undefined). */ + protected abstract getBanner(): + | TemplateResult + | HTMLElement + | string + | null + | undefined; + + /** + * Provide the main TemplateResult shown in the preview panel. + * Called each time the panel opens or the tool data updates. + */ + protected abstract getPreviewContent(): TemplateResult<1>; + + /** Provide the action controls (right-side buttons) for the panel. */ + protected getPreviewControls(): TemplateResult<1> | undefined { + return undefined; + } + + /** Open or refresh the preview panel. */ + private openOrUpdatePreviewPanel() { + renderPreviewPanel( + this, + this.getPreviewContent(), + this.getPreviewControls() + ); + } + + protected refreshPreviewPanel() { + if (isPreviewPanelOpen(this)) { + this.openOrUpdatePreviewPanel(); + } + } + + /** Optionally override to show an error card. Return null if no error. */ + protected getErrorTemplate(): TemplateResult | null { + return null; + } + + private readonly onCardClick = (_e: Event) => { + this.openOrUpdatePreviewPanel(); + }; + + protected renderCard() { + const { title, icon, loading, className } = this.getCardMeta(); + + const resolvedIcon = loading + ? LoadingIcon({ + size: '20px', + }) + : icon; + + const banner = this.getBanner(); + + return html` +
+
+
+
+ ${resolvedIcon} +
+
+ ${title} +
+
+
+ ${banner + ? html`
${banner}
` + : nothing} +
+ `; + } + + override render() { + const err = this.getErrorTemplate(); + if (err) { + return err; + } + return this.renderCard(); + } + + override updated(changed: PropertyValues) { + super.updated(changed); + if (changed.has('data') && isPreviewPanelOpen(this)) { + this.openOrUpdatePreviewPanel(); + } + } +} diff --git a/packages/frontend/core/src/components/comment/comment-editor/index.tsx b/packages/frontend/core/src/components/comment/comment-editor/index.tsx index dbe3430a91..44a4ee1331 100644 --- a/packages/frontend/core/src/components/comment/comment-editor/index.tsx +++ b/packages/frontend/core/src/components/comment/comment-editor/index.tsx @@ -1,9 +1,10 @@ -import { IconButton } from '@affine/component'; +import { IconButton, notify } from '@affine/component'; import { LitDocEditor, type PageEditor } from '@affine/core/blocksuite/editors'; import { SnapshotHelper } from '@affine/core/modules/comment/services/snapshot-helper'; import type { CommentAttachment } from '@affine/core/modules/comment/types'; import { PeekViewService } from '@affine/core/modules/peek-view'; import { DebugLogger } from '@affine/debug'; +import { getAttachmentFileIconRC } from '@blocksuite/affine/components/icons'; import { type RichText, selectTextModel } from '@blocksuite/affine/rich-text'; import { ViewportElementExtension } from '@blocksuite/affine/shared/services'; import { openFilesWith } from '@blocksuite/affine/shared/utils'; @@ -15,6 +16,7 @@ import { } from '@blocksuite/icons/rc'; import type { TextSelection } from '@blocksuite/std'; import { useFramework, useService } from '@toeverything/infra'; +import bytes from 'bytes'; import clsx from 'clsx'; import { forwardRef, @@ -30,7 +32,7 @@ import { useAsyncCallback } from '../../hooks/affine-async-hooks'; import { getCommentEditorViewManager } from './specs'; import * as styles from './style.css'; -const MAX_IMAGE_COUNT = 10; +const MAX_ATTACHMENT_COUNT = 10; const logger = new DebugLogger('CommentEditor'); const usePatchSpecs = (readonly: boolean) => { @@ -78,6 +80,16 @@ export interface CommentEditorRef { focus: () => void; } +const download = (url: string, name: string) => { + const element = document.createElement('a'); + element.setAttribute('download', name); + element.setAttribute('href', url); + element.style.display = 'none'; + document.body.append(element); + element.click(); + element.remove(); +}; + // todo: get rid of circular data changes const useSnapshotDoc = ( defaultSnapshotOrDoc: DocSnapshot | Store, @@ -109,6 +121,75 @@ const useSnapshotDoc = ( return doc; }; +const isImageAttachment = (att: EditorAttachment) => { + const type = att.mimeType || att.file?.type || ''; + if (type) return type.startsWith('image/'); + return !!att.url && /\.(png|jpe?g|gif|webp|svg)$/i.test(att.url); +}; + +const AttachmentPreviewItem: React.FC<{ + attachment: EditorAttachment; + index: number; + readonly?: boolean; + handleAttachmentClick: (e: React.MouseEvent, index: number) => void; + handleAttachmentRemove: (id: string) => void; +}> = ({ + attachment, + index, + readonly, + handleAttachmentClick, + handleAttachmentRemove, +}) => { + const isImg = isImageAttachment(attachment); + const Icon = !isImg + ? getAttachmentFileIconRC( + attachment.mimeType || + attachment.file?.type || + attachment.filename?.split('.').pop() || + 'none' + ) + : undefined; + + return ( +
handleAttachmentClick(e, index)} + > + {!isImg && Icon && } + {!isImg && ( +
+ + {attachment.filename || attachment.file?.name || 'File'} + + + {attachment.size ? bytes(attachment.size) : ''} + +
+ )} + + {!readonly && ( + { + e.stopPropagation(); + handleAttachmentRemove(attachment.id); + }} + icon={} + /> + )} +
+ ); +}; + export const CommentEditor = forwardRef( function CommentEditor( { @@ -143,25 +224,28 @@ export const CommentEditor = forwardRef( [attachments, onAttachmentsChange] ); - const isImageUploadDisabled = (attachments?.length ?? 0) >= MAX_IMAGE_COUNT; + const isUploadDisabled = (attachments?.length ?? 0) >= MAX_ATTACHMENT_COUNT; const uploadingAttachments = attachments?.some( att => att.status === 'uploading' ); const commitDisabled = (empty && (attachments?.length ?? 0) === 0) || uploadingAttachments; - const addImages = useAsyncCallback( + const addAttachments = useAsyncCallback( async (files: File[]) => { if (!uploadCommentAttachment) return; - const valid = files.filter(f => f.type.startsWith('image/')); + const remaining = MAX_ATTACHMENT_COUNT - (attachments?.length ?? 0); + const valid = files.slice(0, remaining); if (!valid.length) return; - logger.info('addImages', { files: valid }); + logger.info('addAttachments', { files: valid }); const pendingAttachments: EditorAttachment[] = valid.map(f => ({ id: nanoid(), file: f, localUrl: URL.createObjectURL(f), status: 'uploading', + filename: f.name, + mimeType: f.type, })); setAttachments(prev => [...prev, ...pendingAttachments]); @@ -189,8 +273,12 @@ export const CommentEditor = forwardRef( }; return next; }); - } catch (e) { + } catch (e: any) { logger.error('uploadCommentAttachment failed', { error: e }); + notify.error({ + title: 'Failed to upload attachment', + message: e.message, + }); pending.localUrl && URL.revokeObjectURL(pending.localUrl); setAttachments(prev => { const index = prev.findIndex(att => att.id === pending.id); @@ -202,38 +290,38 @@ export const CommentEditor = forwardRef( } } }, - [setAttachments, uploadCommentAttachment] + [attachments?.length, setAttachments, uploadCommentAttachment] ); - const handlePasteImage = useCallback( + const handlePaste = useCallback( (event: React.ClipboardEvent) => { const items = event.clipboardData?.items; if (!items) return; const files: File[] = []; for (const index in items) { const item = items[index as any]; - if (item.kind === 'file' && item.type.indexOf('image') >= 0) { + if (item.kind === 'file') { const blob = item.getAsFile(); if (blob) files.push(blob); } } if (files.length) { event.preventDefault(); - addImages(files); + addAttachments(files); } }, - [addImages] + [addAttachments] ); - const uploadImageFiles = useAsyncCallback(async () => { - if (isImageUploadDisabled) return; - const files = await openFilesWith('Images'); + const openFilePicker = useAsyncCallback(async () => { + if (isUploadDisabled) return; + const files = await openFilesWith('Any'); if (files) { - addImages(files); + addAttachments(files); } - }, [isImageUploadDisabled, addImages]); + }, [isUploadDisabled, addAttachments]); - const handleImageRemove = useCallback( + const handleAttachmentRemove = useCallback( (id: string) => { setAttachments(prev => { const att = prev.find(att => att.id === id); @@ -247,10 +335,7 @@ export const CommentEditor = forwardRef( const handleImagePreview = useCallback( (index: number) => { if (!attachments) return; - - const imageAttachments = attachments.filter( - att => att.url || att.localUrl - ); + const imageAttachments = attachments.filter(isImageAttachment); if (index >= imageAttachments.length) return; @@ -291,12 +376,31 @@ export const CommentEditor = forwardRef( [attachments, peekViewService] ); - const handleImageClick = useCallback( + const handleAttachmentClick = useCallback( (e: React.MouseEvent, index: number) => { e.stopPropagation(); - handleImagePreview(index); + if (!attachments) return; + const att = attachments[index]; + if (!att) return; + const url = att.url || att.localUrl; + if (!url) return; + if (isImageAttachment(att)) { + // translate attachment index to image index + const imageAttachments = attachments.filter(isImageAttachment); + const imageIndex = imageAttachments.findIndex(i => i.id === att.id); + if (imageIndex >= 0) { + handleImagePreview(imageIndex); + } + } else if (att.url || att.localUrl) { + // todo: open attachment preview. for now, just download it + download(url, att.filename ?? att.file?.name ?? 'attachment'); + notify({ + title: 'Downloading attachment', + message: 'The attachment is being downloaded to your computer.', + }); + } }, - [handleImagePreview] + [attachments, handleImagePreview] ); // upload attachments and call original onCommit @@ -433,38 +537,24 @@ export const CommentEditor = forwardRef(
{attachments?.length && attachments.length > 0 ? (
{attachments.map((att, index) => ( -
handleImageClick(e, index)} - > - {!readonly && ( - { - e.stopPropagation(); - handleImageRemove(att.id); - }} - icon={} - /> - )} -
+ attachment={att} + index={index} + readonly={readonly} + handleAttachmentClick={handleAttachmentClick} + handleAttachmentRemove={handleAttachmentRemove} + /> ))}
) : null} @@ -476,8 +566,8 @@ export const CommentEditor = forwardRef(
} - onClick={uploadImageFiles} - aria-disabled={isImageUploadDisabled} + onClick={openFilePicker} + disabled={isUploadDisabled} />
-
- {comment.content?.preview} -
+
{comment.content?.preview}
{isEditing && editingDoc ? ( diff --git a/packages/frontend/core/src/modules/comment/entities/doc-comment.ts b/packages/frontend/core/src/modules/comment/entities/doc-comment.ts index cf3311fa26..1dc732dcd3 100644 --- a/packages/frontend/core/src/modules/comment/entities/doc-comment.ts +++ b/packages/frontend/core/src/modules/comment/entities/doc-comment.ts @@ -351,6 +351,7 @@ export class DocCommentEntity extends Entity<{ url, filename: file.name, mimeType: file.type, + size: file.size, }); if (isPendingComment) { diff --git a/packages/frontend/core/src/modules/comment/types.ts b/packages/frontend/core/src/modules/comment/types.ts index 27e4036f54..e968386bc2 100644 --- a/packages/frontend/core/src/modules/comment/types.ts +++ b/packages/frontend/core/src/modules/comment/types.ts @@ -13,6 +13,7 @@ export type CommentAttachment = { url?: string; // attachment may not be uploaded yet filename?: string; mimeType?: string; + size?: number; // in bytes }; export interface BaseComment {