refactor(core): display selected doc & attachment chip (#13443)

<img width="1275" height="997" alt="截屏2025-08-08 15 13 59"
src="https://github.com/user-attachments/assets/b429239d-84dc-490d-ad1e-957652e3caba"
/>


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

## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
德布劳外 · 贾贵
2025-08-08 15:34:04 +08:00
committed by GitHub
parent 009288dee2
commit 5fd7dfc8aa
9 changed files with 188 additions and 121 deletions
@@ -361,12 +361,12 @@ declare global {
op: string, op: string,
updates: string updates: string
) => Promise<string>; ) => Promise<string>;
addContextBlobs: (options: { addContextBlob: (options: {
blobIds: string[]; blobId: string;
contextId: string; contextId: string;
}) => Promise<CopilotContextBlob[]>; }) => Promise<CopilotContextBlob>;
removeContextBlobs: (options: { removeContextBlob: (options: {
blobIds: string[]; blobId: string;
contextId: string; contextId: string;
}) => Promise<boolean>; }) => Promise<boolean>;
} }
@@ -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`<chat-panel-chip
.state=${state}
.name=${name}
.tooltip=${tooltip}
.icon=${icon}
.closeable=${!isLoading}
.onChipDelete=${this.onChipDelete}
></chat-panel-chip>`;
}
private readonly onChipDelete = () => {
this.removeChip(this.chip);
};
}
@@ -15,6 +15,7 @@ import type { ChatChip, DocChip, DocDisplayConfig, FileChip } from './type';
import { import {
estimateTokenCount, estimateTokenCount,
getChipKey, getChipKey,
isAttachmentChip,
isCollectionChip, isCollectionChip,
isDocChip, isDocChip,
isFileChip, isFileChip,
@@ -160,6 +161,12 @@ export class ChatPanelChips extends SignalWatcher(
.removeChip=${this.removeChip} .removeChip=${this.removeChip}
></chat-panel-file-chip>`; ></chat-panel-file-chip>`;
} }
if (isAttachmentChip(chip)) {
return html`<chat-panel-attachment-chip
.chip=${chip}
.removeChip=${this.removeChip}
></chat-panel-attachment-chip>`;
}
if (isTagChip(chip)) { if (isTagChip(chip)) {
const tag = this._tags.value.find(tag => tag.id === chip.tagId); const tag = this._tags.value.find(tag => tag.id === chip.tagId);
if (!tag) { if (!tag) {
@@ -36,10 +36,13 @@ export interface CollectionChip extends BaseChip {
collectionId: string; collectionId: string;
} }
export interface AttachmentChip extends BaseChip {
sourceId: string;
name: string;
}
export interface SelectedContextChip extends BaseChip { export interface SelectedContextChip extends BaseChip {
uuid: string; uuid: string;
attachments: { sourceId: string; name: string }[];
docs: string[];
snapshot: string | null; snapshot: string | null;
combinedElementsMarkdown: string | null; combinedElementsMarkdown: string | null;
html: string | null; html: string | null;
@@ -50,6 +53,7 @@ export type ChatChip =
| FileChip | FileChip
| TagChip | TagChip
| CollectionChip | CollectionChip
| AttachmentChip
| SelectedContextChip; | SelectedContextChip;
export interface DocDisplayConfig { export interface DocDisplayConfig {
@@ -3,6 +3,7 @@ import { WarningIcon } from '@blocksuite/icons/lit';
import { type TemplateResult } from 'lit'; import { type TemplateResult } from 'lit';
import type { import type {
AttachmentChip,
ChatChip, ChatChip,
ChipState, ChipState,
CollectionChip, CollectionChip,
@@ -66,11 +67,11 @@ export function isCollectionChip(chip: ChatChip): chip is CollectionChip {
export function isSelectedContextChip( export function isSelectedContextChip(
chip: ChatChip chip: ChatChip
): chip is SelectedContextChip { ): chip is SelectedContextChip {
return ( return 'snapshot' in chip && 'combinedElementsMarkdown' in chip;
'attachments' in chip && }
'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) { export function getChipKey(chip: ChatChip) {
@@ -130,6 +131,9 @@ export function findChipIndex(chips: ChatChip[], chip: ChatChip) {
if (isSelectedContextChip(chip)) { if (isSelectedContextChip(chip)) {
return isSelectedContextChip(item) && item.uuid === chip.uuid; return isSelectedContextChip(item) && item.uuid === chip.uuid;
} }
if (isAttachmentChip(chip)) {
return isAttachmentChip(item) && item.sourceId === chip.sourceId;
}
return -1; return -1;
}); });
} }
@@ -6,12 +6,13 @@ import type {
} from '@affine/core/modules/ai-button'; } from '@affine/core/modules/ai-button';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs'; import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type { import type {
ContextEmbedStatus,
ContextWorkspaceEmbeddingStatus, ContextWorkspaceEmbeddingStatus,
CopilotChatHistoryFragment, CopilotChatHistoryFragment,
CopilotContextBlob,
CopilotContextDoc, CopilotContextDoc,
CopilotContextFile, CopilotContextFile,
} from '@affine/graphql'; } from '@affine/graphql';
import { ContextEmbedStatus } from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import type { EditorHost } from '@blocksuite/affine/std'; import type { EditorHost } from '@blocksuite/affine/std';
import { ShadowlessElement } from '@blocksuite/affine/std'; import { ShadowlessElement } from '@blocksuite/affine/std';
@@ -23,9 +24,14 @@ import type {
import { css, html, type PropertyValues } from 'lit'; import { css, html, type PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js'; 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 { SearchMenuConfig } from '../ai-chat-add-context';
import type { import type {
AttachmentChip,
ChatChip, ChatChip,
CollectionChip, CollectionChip,
DocChip, DocChip,
@@ -36,6 +42,7 @@ import type {
} from '../ai-chat-chips'; } from '../ai-chat-chips';
import { import {
findChipIndex, findChipIndex,
isAttachmentChip,
isCollectionChip, isCollectionChip,
isDocChip, isDocChip,
isFileChip, isFileChip,
@@ -207,44 +214,10 @@ export class AIChatComposer extends SignalWatcher(
override connectedCallback() { override connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this._disposables.add( this._disposables.add(
AIProvider.slots.requestOpenWithChat.subscribe(params => { AIProvider.slots.requestOpenWithChat.subscribe(this.beforeChatContextSend)
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);
}
})
); );
this._disposables.add( this._disposables.add(
AIProvider.slots.requestSendWithChat.subscribe(params => { AIProvider.slots.requestSendWithChat.subscribe(this.beforeChatContextSend)
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);
}
})
); );
this.initComposer().catch(console.error); 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() { private get isContextProcessing() {
return this.chips.some(chip => chip.state === 'processing'); return this.chips.some(chip => chip.state === 'processing');
} }
@@ -414,19 +412,45 @@ export class AIChatComposer extends SignalWatcher(
}; };
private readonly addSelectedContextChip = async () => { private readonly addSelectedContextChip = async () => {
const { attachments, snapshot, combinedElementsMarkdown, docs, html } = const {
this.chatContextValue; attachments = [],
snapshot,
combinedElementsMarkdown,
docs = [],
html,
} = this.chatContextValue;
await this.removeSelectedContextChip(); await this.removeSelectedContextChip();
const chip: SelectedContextChip = { const chip: SelectedContextChip = {
uuid: uuidv4(), uuid: uuidv4(),
attachments,
docs,
snapshot, snapshot,
combinedElementsMarkdown, combinedElementsMarkdown,
html, html,
state: attachments.length > 0 ? 'processing' : 'finished', state: 'finished',
}; };
await this.addChip(chip, true); 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 () => { private readonly removeSelectedContextChip = async () => {
@@ -449,8 +473,8 @@ export class AIChatComposer extends SignalWatcher(
if (isCollectionChip(chip)) { if (isCollectionChip(chip)) {
return await this.addCollectionToContext(chip); return await this.addCollectionToContext(chip);
} }
if (isSelectedContextChip(chip)) { if (isAttachmentChip(chip)) {
return await this.addSelectedContextChipToContext(chip); return await this.addAttachmentChipToContext(chip);
} }
return null; return null;
}; };
@@ -546,26 +570,29 @@ export class AIChatComposer extends SignalWatcher(
} }
}; };
private readonly addSelectedContextChipToContext = async ( private readonly addAttachmentChipToContext = async (
chip: SelectedContextChip chip: AttachmentChip
) => { ) => {
const { attachments, docs } = chip;
const contextId = await this.createContextId(); const contextId = await this.createContextId();
if (!contextId || !AIProvider.context) { if (!contextId || !AIProvider.context) {
throw new Error('Context not found'); throw new Error('Context not found');
} }
await AIProvider.context.addContextBlobs({ try {
blobIds: attachments.map(attachment => attachment.sourceId), const contextBlob = await AIProvider.context.addContextBlob({
contextId, blobId: chip.sourceId,
}); contextId,
await Promise.all( });
docs.map(docId => this.updateChip(chip, {
AIProvider.context?.addContextDoc({ state: contextBlob.status || 'processing',
contextId, blobId: chip.sourceId,
docId, });
}) } catch (e) {
) this.updateChip(chip, {
); state: 'failed',
tooltip:
e instanceof Error ? e.message : 'Add context attachment error',
});
}
}; };
private readonly removeFromContext = async ( private readonly removeFromContext = async (
@@ -600,20 +627,18 @@ export class AIChatComposer extends SignalWatcher(
collectionId: chip.collectionId, collectionId: chip.collectionId,
}); });
} }
if (isSelectedContextChip(chip)) { if (isAttachmentChip(chip)) {
const { attachments, docs } = chip; return await AIProvider.context.removeContextBlob({
await AIProvider.context.removeContextBlobs({
contextId, 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; return true;
} catch { } catch {
@@ -707,7 +732,7 @@ export class AIChatComposer extends SignalWatcher(
]; ];
const hashMap = new Map< const hashMap = new Map<
string, string,
CopilotContextDoc | CopilotContextFile | { status: ContextEmbedStatus } CopilotContextDoc | CopilotContextFile | CopilotContextBlob
>(); >();
const count: Record<ContextEmbedStatus, number> = { const count: Record<ContextEmbedStatus, number> = {
finished: 0, finished: 0,
@@ -722,18 +747,10 @@ export class AIChatComposer extends SignalWatcher(
hashMap.set(file.id, file); hashMap.set(file.id, file);
file.status && count[file.status]++; file.status && count[file.status]++;
}); });
const selectedChip = this.chips.find(c => isSelectedContextChip(c)); blobs.forEach(blob => {
if (selectedChip) { hashMap.set(blob.id, blob);
const status: ContextEmbedStatus = blobs.every( blob.status && count[blob.status]++;
blob => blob.status === 'finished' });
)
? ContextEmbedStatus.finished
: ContextEmbedStatus.processing;
hashMap.set(selectedChip.uuid, {
status,
});
count[status]++;
}
const nextChips = this.chips.map(chip => { const nextChips = this.chips.map(chip => {
if (isTagChip(chip) || isCollectionChip(chip)) { if (isTagChip(chip) || isCollectionChip(chip)) {
return chip; return chip;
@@ -742,7 +759,11 @@ export class AIChatComposer extends SignalWatcher(
? chip.docId ? chip.docId
: isFileChip(chip) : isFileChip(chip)
? chip.fileId ? chip.fileId
: chip.uuid; : isAttachmentChip(chip)
? chip.sourceId
: isSelectedContextChip(chip)
? chip.uuid
: undefined;
const item = id && hashMap.get(id); const item = id && hashMap.get(id);
if (item && item.status) { if (item && item.status) {
return { return {
@@ -30,6 +30,7 @@ import { ChatPanelSplitView } from './chat-panel/split-view';
import { ArtifactSkeleton } from './components/ai-artifact-skeleton'; import { ArtifactSkeleton } from './components/ai-artifact-skeleton';
import { AIChatAddContext } from './components/ai-chat-add-context'; import { AIChatAddContext } from './components/ai-chat-add-context';
import { ChatPanelAddPopover } from './components/ai-chat-chips/add-popover'; 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 { ChatPanelCandidatesPopover } from './components/ai-chat-chips/candidates-popover';
import { ChatPanelChips } from './components/ai-chat-chips/chat-panel-chips'; import { ChatPanelChips } from './components/ai-chat-chips/chat-panel-chips';
import { ChatPanelChip } from './components/ai-chat-chips/chip'; 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-tag-chip', ChatPanelTagChip);
customElements.define('chat-panel-collection-chip', ChatPanelCollectionChip); customElements.define('chat-panel-collection-chip', ChatPanelCollectionChip);
customElements.define('chat-panel-selected-chip', ChatPanelSelectedChip); customElements.define('chat-panel-selected-chip', ChatPanelSelectedChip);
customElements.define('chat-panel-attachment-chip', ChatPanelAttachmentChip);
customElements.define('chat-panel-chip', ChatPanelChip); customElements.define('chat-panel-chip', ChatPanelChip);
customElements.define('ai-error-wrapper', AIErrorWrapper); customElements.define('ai-error-wrapper', AIErrorWrapper);
customElements.define('ai-slides-renderer', AISlidesRenderer); customElements.define('ai-slides-renderer', AISlidesRenderer);
@@ -57,7 +57,7 @@ export function edgelessToolbarAIEntryConfig(): ToolbarModuleConfig {
aiPanel.hide(); aiPanel.hide();
extractSelectedContent(host) extractSelectedContent(host)
.then(context => { .then(context => {
if (context?.attachments?.length) { if (context?.attachments?.length || context?.docs?.length) {
AIProvider.slots.requestOpenWithChat.next({ AIProvider.slots.requestOpenWithChat.next({
input, input,
host, host,
@@ -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); return client.applyDocUpdates(workspaceId, docId, op, updates);
}, },
addContextBlobs: async (options: { addContextBlob: async (options: { blobId: string; contextId: string }) => {
blobIds: string[]; return client.addContextBlob({
contextId: string; contextId: options.contextId,
}) => { blobId: options.blobId,
return Promise.all( });
options.blobIds.map(blobId =>
client.addContextBlob({
contextId: options.contextId,
blobId,
})
)
);
}, },
removeContextBlobs: async (options: { removeContextBlob: async (options: {
blobIds: string[]; blobId: string;
contextId: string; contextId: string;
}) => { }) => {
return Promise.all( return client.removeContextBlob({
options.blobIds.map(blobId => contextId: options.contextId,
client.removeContextBlob({ blobId: options.blobId,
contextId: options.contextId, });
blobId,
})
)
).then(results => results.every(Boolean));
}, },
}); });