From 5fd7dfc8aa4a74c4227e3af1518c2ed8a59ae36c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=BE=B7=E5=B8=83=E5=8A=B3=E5=A4=96=20=C2=B7=20=E8=B4=BE?=
=?UTF-8?q?=E8=B4=B5?= <472285740@qq.com>
Date: Fri, 8 Aug 2025 15:34:04 +0800
Subject: [PATCH] refactor(core): display selected doc & attachment chip
(#13443)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary by CodeRabbit
* **New Features**
* Introduced support for attachment chips in AI chat, allowing
individual attachments to be displayed, added, and removed as separate
chips.
* Added a new visual component for displaying attachment chips in the
chat interface.
* **Improvements**
* Enhanced chat composer to handle attachments and document chips
separately, improving clarity and control over shared content.
* Expanded criteria for triggering chat actions to include both document
and attachment selections.
* **Refactor**
* Updated context management to process attachments individually rather
than in batches, streamlining the addition and removal of context items.
---
.../core/src/blocksuite/ai/actions/types.ts | 10 +-
.../ai-chat-chips/attachment-chip.ts | 40 ++++
.../ai-chat-chips/chat-panel-chips.ts | 7 +
.../ai/components/ai-chat-chips/type.ts | 8 +-
.../ai/components/ai-chat-chips/utils.ts | 14 +-
.../ai-chat-composer/ai-chat-composer.ts | 193 ++++++++++--------
.../core/src/blocksuite/ai/effects.ts | 2 +
.../blocksuite/ai/entries/edgeless/index.ts | 2 +-
.../blocksuite/ai/provider/setup-provider.tsx | 33 +--
9 files changed, 188 insertions(+), 121 deletions(-)
create mode 100644 packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/attachment-chip.ts
diff --git a/packages/frontend/core/src/blocksuite/ai/actions/types.ts b/packages/frontend/core/src/blocksuite/ai/actions/types.ts
index 28acff9def..c915e2e2d8 100644
--- a/packages/frontend/core/src/blocksuite/ai/actions/types.ts
+++ b/packages/frontend/core/src/blocksuite/ai/actions/types.ts
@@ -361,12 +361,12 @@ declare global {
op: string,
updates: string
) => Promise;
- addContextBlobs: (options: {
- blobIds: string[];
+ addContextBlob: (options: {
+ blobId: string;
contextId: string;
- }) => Promise;
- removeContextBlobs: (options: {
- blobIds: string[];
+ }) => Promise;
+ removeContextBlob: (options: {
+ blobId: string;
contextId: string;
}) => Promise;
}
diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/attachment-chip.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/attachment-chip.ts
new file mode 100644
index 0000000000..59a0d790a5
--- /dev/null
+++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/attachment-chip.ts
@@ -0,0 +1,40 @@
+import { getAttachmentFileIcon } from '@blocksuite/affine/components/icons';
+import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
+import { ShadowlessElement } from '@blocksuite/affine/std';
+import { html } from 'lit';
+import { property } from 'lit/decorators.js';
+
+import type { AttachmentChip } from './type';
+import { getChipIcon, getChipTooltip } from './utils';
+
+export class ChatPanelAttachmentChip extends SignalWatcher(
+ WithDisposable(ShadowlessElement)
+) {
+ @property({ attribute: false })
+ accessor chip!: AttachmentChip;
+
+ @property({ attribute: false })
+ accessor removeChip!: (chip: AttachmentChip) => void;
+
+ override render() {
+ const { state, name } = this.chip;
+ const isLoading = state === 'processing';
+ const tooltip = getChipTooltip(state, name, this.chip.tooltip);
+ const fileType = name.split('.').pop() ?? '';
+ const fileIcon = getAttachmentFileIcon(fileType);
+ const icon = getChipIcon(state, fileIcon);
+
+ return html``;
+ }
+
+ private readonly onChipDelete = () => {
+ this.removeChip(this.chip);
+ };
+}
diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/chat-panel-chips.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/chat-panel-chips.ts
index 6fdaaa786f..aef0795dec 100644
--- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/chat-panel-chips.ts
+++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/chat-panel-chips.ts
@@ -15,6 +15,7 @@ import type { ChatChip, DocChip, DocDisplayConfig, FileChip } from './type';
import {
estimateTokenCount,
getChipKey,
+ isAttachmentChip,
isCollectionChip,
isDocChip,
isFileChip,
@@ -160,6 +161,12 @@ export class ChatPanelChips extends SignalWatcher(
.removeChip=${this.removeChip}
>`;
}
+ if (isAttachmentChip(chip)) {
+ return html``;
+ }
if (isTagChip(chip)) {
const tag = this._tags.value.find(tag => tag.id === chip.tagId);
if (!tag) {
diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/type.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/type.ts
index e5799d3779..3522ffa376 100644
--- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/type.ts
+++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/type.ts
@@ -36,10 +36,13 @@ export interface CollectionChip extends BaseChip {
collectionId: string;
}
+export interface AttachmentChip extends BaseChip {
+ sourceId: string;
+ name: string;
+}
+
export interface SelectedContextChip extends BaseChip {
uuid: string;
- attachments: { sourceId: string; name: string }[];
- docs: string[];
snapshot: string | null;
combinedElementsMarkdown: string | null;
html: string | null;
@@ -50,6 +53,7 @@ export type ChatChip =
| FileChip
| TagChip
| CollectionChip
+ | AttachmentChip
| SelectedContextChip;
export interface DocDisplayConfig {
diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/utils.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/utils.ts
index fd80277cb3..fd655b314d 100644
--- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/utils.ts
+++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-chips/utils.ts
@@ -3,6 +3,7 @@ import { WarningIcon } from '@blocksuite/icons/lit';
import { type TemplateResult } from 'lit';
import type {
+ AttachmentChip,
ChatChip,
ChipState,
CollectionChip,
@@ -66,11 +67,11 @@ export function isCollectionChip(chip: ChatChip): chip is CollectionChip {
export function isSelectedContextChip(
chip: ChatChip
): chip is SelectedContextChip {
- return (
- 'attachments' in chip &&
- 'snapshot' in chip &&
- 'combinedElementsMarkdown' in chip
- );
+ return 'snapshot' in chip && 'combinedElementsMarkdown' in chip;
+}
+
+export function isAttachmentChip(chip: ChatChip): chip is AttachmentChip {
+ return 'sourceId' in chip && 'name' in chip;
}
export function getChipKey(chip: ChatChip) {
@@ -130,6 +131,9 @@ export function findChipIndex(chips: ChatChip[], chip: ChatChip) {
if (isSelectedContextChip(chip)) {
return isSelectedContextChip(item) && item.uuid === chip.uuid;
}
+ if (isAttachmentChip(chip)) {
+ return isAttachmentChip(item) && item.sourceId === chip.sourceId;
+ }
return -1;
});
}
diff --git a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-composer/ai-chat-composer.ts b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-composer/ai-chat-composer.ts
index 3c31fd6068..da6d56dd73 100644
--- a/packages/frontend/core/src/blocksuite/ai/components/ai-chat-composer/ai-chat-composer.ts
+++ b/packages/frontend/core/src/blocksuite/ai/components/ai-chat-composer/ai-chat-composer.ts
@@ -6,12 +6,13 @@ import type {
} from '@affine/core/modules/ai-button';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type {
+ ContextEmbedStatus,
ContextWorkspaceEmbeddingStatus,
CopilotChatHistoryFragment,
+ CopilotContextBlob,
CopilotContextDoc,
CopilotContextFile,
} from '@affine/graphql';
-import { ContextEmbedStatus } from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std';
@@ -23,9 +24,14 @@ import type {
import { css, html, type PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js';
-import { AIProvider } from '../../provider';
+import {
+ type AIChatParams,
+ AIProvider,
+ type AISendParams,
+} from '../../provider';
import type { SearchMenuConfig } from '../ai-chat-add-context';
import type {
+ AttachmentChip,
ChatChip,
CollectionChip,
DocChip,
@@ -36,6 +42,7 @@ import type {
} from '../ai-chat-chips';
import {
findChipIndex,
+ isAttachmentChip,
isCollectionChip,
isDocChip,
isFileChip,
@@ -207,44 +214,10 @@ export class AIChatComposer extends SignalWatcher(
override connectedCallback() {
super.connectedCallback();
this._disposables.add(
- AIProvider.slots.requestOpenWithChat.subscribe(params => {
- if (!params) return;
-
- const { context, host } = params;
- if (this.host !== host || !context) return;
-
- if (
- context.attachments ||
- context.snapshot ||
- context.combinedElementsMarkdown ||
- context.html
- ) {
- // Wait for context value updated next frame
- setTimeout(() => {
- this.addSelectedContextChip().catch(console.error);
- }, 0);
- }
- })
+ AIProvider.slots.requestOpenWithChat.subscribe(this.beforeChatContextSend)
);
this._disposables.add(
- AIProvider.slots.requestSendWithChat.subscribe(params => {
- if (!params) return;
-
- const { context, host } = params;
- if (this.host !== host || !context) return;
-
- if (
- context.attachments ||
- context.snapshot ||
- context.combinedElementsMarkdown ||
- context.html
- ) {
- // Wait for context value updated next frame
- setTimeout(() => {
- this.addSelectedContextChip().catch(console.error);
- }, 0);
- }
- })
+ AIProvider.slots.requestSendWithChat.subscribe(this.beforeChatContextSend)
);
this.initComposer().catch(console.error);
}
@@ -266,6 +239,31 @@ export class AIChatComposer extends SignalWatcher(
}
}
+ private readonly beforeChatContextSend = (
+ params: AISendParams | AIChatParams | null
+ ) => {
+ if (!params) return;
+
+ const { context, host } = params;
+ if (this.host !== host || !context) return;
+
+ if (context) {
+ this.updateContext(context);
+ }
+ if (
+ context.docs ||
+ context.attachments ||
+ context.snapshot ||
+ context.combinedElementsMarkdown ||
+ context.html
+ ) {
+ // Wait for context value updated next frame
+ setTimeout(() => {
+ this.addSelectedContextChip().catch(console.error);
+ }, 0);
+ }
+ };
+
private get isContextProcessing() {
return this.chips.some(chip => chip.state === 'processing');
}
@@ -414,19 +412,45 @@ export class AIChatComposer extends SignalWatcher(
};
private readonly addSelectedContextChip = async () => {
- const { attachments, snapshot, combinedElementsMarkdown, docs, html } =
- this.chatContextValue;
+ const {
+ attachments = [],
+ snapshot,
+ combinedElementsMarkdown,
+ docs = [],
+ html,
+ } = this.chatContextValue;
await this.removeSelectedContextChip();
const chip: SelectedContextChip = {
uuid: uuidv4(),
- attachments,
- docs,
snapshot,
combinedElementsMarkdown,
html,
- state: attachments.length > 0 ? 'processing' : 'finished',
+ state: 'finished',
};
await this.addChip(chip, true);
+ await Promise.all(
+ docs.map(docId =>
+ this.addChip(
+ {
+ docId,
+ state: 'processing',
+ },
+ true
+ )
+ )
+ );
+ await Promise.all(
+ attachments.map(attachment =>
+ this.addChip(
+ {
+ sourceId: attachment.sourceId,
+ name: attachment.name,
+ state: 'processing',
+ },
+ true
+ )
+ )
+ );
};
private readonly removeSelectedContextChip = async () => {
@@ -449,8 +473,8 @@ export class AIChatComposer extends SignalWatcher(
if (isCollectionChip(chip)) {
return await this.addCollectionToContext(chip);
}
- if (isSelectedContextChip(chip)) {
- return await this.addSelectedContextChipToContext(chip);
+ if (isAttachmentChip(chip)) {
+ return await this.addAttachmentChipToContext(chip);
}
return null;
};
@@ -546,26 +570,29 @@ export class AIChatComposer extends SignalWatcher(
}
};
- private readonly addSelectedContextChipToContext = async (
- chip: SelectedContextChip
+ private readonly addAttachmentChipToContext = async (
+ chip: AttachmentChip
) => {
- const { attachments, docs } = chip;
const contextId = await this.createContextId();
if (!contextId || !AIProvider.context) {
throw new Error('Context not found');
}
- await AIProvider.context.addContextBlobs({
- blobIds: attachments.map(attachment => attachment.sourceId),
- contextId,
- });
- await Promise.all(
- docs.map(docId =>
- AIProvider.context?.addContextDoc({
- contextId,
- docId,
- })
- )
- );
+ try {
+ const contextBlob = await AIProvider.context.addContextBlob({
+ blobId: chip.sourceId,
+ contextId,
+ });
+ this.updateChip(chip, {
+ state: contextBlob.status || 'processing',
+ blobId: chip.sourceId,
+ });
+ } catch (e) {
+ this.updateChip(chip, {
+ state: 'failed',
+ tooltip:
+ e instanceof Error ? e.message : 'Add context attachment error',
+ });
+ }
};
private readonly removeFromContext = async (
@@ -600,20 +627,18 @@ export class AIChatComposer extends SignalWatcher(
collectionId: chip.collectionId,
});
}
- if (isSelectedContextChip(chip)) {
- const { attachments, docs } = chip;
- await AIProvider.context.removeContextBlobs({
+ if (isAttachmentChip(chip)) {
+ return await AIProvider.context.removeContextBlob({
contextId,
- blobIds: attachments.map(attachment => attachment.sourceId),
+ blobId: chip.sourceId,
+ });
+ }
+ if (isSelectedContextChip(chip)) {
+ this.updateContext({
+ ...this.chatContextValue,
+ snapshot: null,
+ combinedElementsMarkdown: null,
});
- await Promise.all(
- docs.map(docId =>
- AIProvider.context?.removeContextDoc({
- contextId,
- docId,
- })
- )
- );
}
return true;
} catch {
@@ -707,7 +732,7 @@ export class AIChatComposer extends SignalWatcher(
];
const hashMap = new Map<
string,
- CopilotContextDoc | CopilotContextFile | { status: ContextEmbedStatus }
+ CopilotContextDoc | CopilotContextFile | CopilotContextBlob
>();
const count: Record = {
finished: 0,
@@ -722,18 +747,10 @@ export class AIChatComposer extends SignalWatcher(
hashMap.set(file.id, file);
file.status && count[file.status]++;
});
- const selectedChip = this.chips.find(c => isSelectedContextChip(c));
- if (selectedChip) {
- const status: ContextEmbedStatus = blobs.every(
- blob => blob.status === 'finished'
- )
- ? ContextEmbedStatus.finished
- : ContextEmbedStatus.processing;
- hashMap.set(selectedChip.uuid, {
- status,
- });
- count[status]++;
- }
+ blobs.forEach(blob => {
+ hashMap.set(blob.id, blob);
+ blob.status && count[blob.status]++;
+ });
const nextChips = this.chips.map(chip => {
if (isTagChip(chip) || isCollectionChip(chip)) {
return chip;
@@ -742,7 +759,11 @@ export class AIChatComposer extends SignalWatcher(
? chip.docId
: isFileChip(chip)
? chip.fileId
- : chip.uuid;
+ : isAttachmentChip(chip)
+ ? chip.sourceId
+ : isSelectedContextChip(chip)
+ ? chip.uuid
+ : undefined;
const item = id && hashMap.get(id);
if (item && item.status) {
return {
diff --git a/packages/frontend/core/src/blocksuite/ai/effects.ts b/packages/frontend/core/src/blocksuite/ai/effects.ts
index 2215cc3648..c0a40cdc49 100644
--- a/packages/frontend/core/src/blocksuite/ai/effects.ts
+++ b/packages/frontend/core/src/blocksuite/ai/effects.ts
@@ -30,6 +30,7 @@ import { ChatPanelSplitView } from './chat-panel/split-view';
import { ArtifactSkeleton } from './components/ai-artifact-skeleton';
import { AIChatAddContext } from './components/ai-chat-add-context';
import { ChatPanelAddPopover } from './components/ai-chat-chips/add-popover';
+import { ChatPanelAttachmentChip } from './components/ai-chat-chips/attachment-chip';
import { ChatPanelCandidatesPopover } from './components/ai-chat-chips/candidates-popover';
import { ChatPanelChips } from './components/ai-chat-chips/chat-panel-chips';
import { ChatPanelChip } from './components/ai-chat-chips/chip';
@@ -166,6 +167,7 @@ export function registerAIEffects() {
customElements.define('chat-panel-tag-chip', ChatPanelTagChip);
customElements.define('chat-panel-collection-chip', ChatPanelCollectionChip);
customElements.define('chat-panel-selected-chip', ChatPanelSelectedChip);
+ customElements.define('chat-panel-attachment-chip', ChatPanelAttachmentChip);
customElements.define('chat-panel-chip', ChatPanelChip);
customElements.define('ai-error-wrapper', AIErrorWrapper);
customElements.define('ai-slides-renderer', AISlidesRenderer);
diff --git a/packages/frontend/core/src/blocksuite/ai/entries/edgeless/index.ts b/packages/frontend/core/src/blocksuite/ai/entries/edgeless/index.ts
index f5d70ccc3c..c0d35e90fc 100644
--- a/packages/frontend/core/src/blocksuite/ai/entries/edgeless/index.ts
+++ b/packages/frontend/core/src/blocksuite/ai/entries/edgeless/index.ts
@@ -57,7 +57,7 @@ export function edgelessToolbarAIEntryConfig(): ToolbarModuleConfig {
aiPanel.hide();
extractSelectedContent(host)
.then(context => {
- if (context?.attachments?.length) {
+ if (context?.attachments?.length || context?.docs?.length) {
AIProvider.slots.requestOpenWithChat.next({
input,
host,
diff --git a/packages/frontend/core/src/blocksuite/ai/provider/setup-provider.tsx b/packages/frontend/core/src/blocksuite/ai/provider/setup-provider.tsx
index 0509e1e1db..ceb97f8f37 100644
--- a/packages/frontend/core/src/blocksuite/ai/provider/setup-provider.tsx
+++ b/packages/frontend/core/src/blocksuite/ai/provider/setup-provider.tsx
@@ -748,31 +748,20 @@ Could you make a new website based on these notes and send back just the html fi
) => {
return client.applyDocUpdates(workspaceId, docId, op, updates);
},
- addContextBlobs: async (options: {
- blobIds: string[];
- contextId: string;
- }) => {
- return Promise.all(
- options.blobIds.map(blobId =>
- client.addContextBlob({
- contextId: options.contextId,
- blobId,
- })
- )
- );
+ addContextBlob: async (options: { blobId: string; contextId: string }) => {
+ return client.addContextBlob({
+ contextId: options.contextId,
+ blobId: options.blobId,
+ });
},
- removeContextBlobs: async (options: {
- blobIds: string[];
+ removeContextBlob: async (options: {
+ blobId: string;
contextId: string;
}) => {
- return Promise.all(
- options.blobIds.map(blobId =>
- client.removeContextBlob({
- contextId: options.contextId,
- blobId,
- })
- )
- ).then(results => results.every(Boolean));
+ return client.removeContextBlob({
+ contextId: options.contextId,
+ blobId: options.blobId,
+ });
},
});