feat(core): selected context ui (#13379)

<img width="1133" height="982" alt="截屏2025-07-31 17 56 24"
src="https://github.com/user-attachments/assets/5f2d577b-5b25-44ed-896a-17fe212de0f8"
/>
<img width="1151" height="643" alt="截屏2025-07-31 17 55 32"
src="https://github.com/user-attachments/assets/b2320023-ab75-4455-9c24-d133fda1b7e1"
/>

> CLOSE AF-2771 AF-2772 AF-2778

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

* **New Features**
* Added support for sending detailed object information (JSON snapshot
and markdown) to AI when using "Continue with AI", enhancing AI's
context awareness.
* Introduced a new chip type for selected context attachments in the AI
chat interface, allowing users to manage and view detailed context
fragments.
* Added feature flags to enable or disable sending detailed context
objects to AI and to require journal confirmation.
* New settings and localization for the "Send detailed object
information to AI" feature.

* **Improvements**
* Enhanced chat input and composer to handle context processing states
and prevent sending messages while context is being processed.
* Improved context management with batch addition and removal of context
blobs.

* **Bug Fixes**
* Fixed UI rendering to properly display and manage new selected context
chips.

* **Documentation**
* Updated localization and settings to reflect new experimental AI
features.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
德布劳外 · 贾贵
2025-08-01 11:39:38 +08:00
committed by GitHub
parent 4e1f047cf2
commit a088874c41
17 changed files with 382 additions and 21 deletions

View File

@@ -5,6 +5,7 @@ import type {
ContextMatchedFileChunk,
ContextWorkspaceEmbeddingStatus,
CopilotChatHistoryFragment,
CopilotContextBlob,
CopilotContextCategory,
CopilotContextDoc,
CopilotContextFile,
@@ -147,6 +148,8 @@ declare global {
contexts?: {
docs: AIDocContextOption[];
files: AIFileContextOption[];
selectedSnapshot?: string;
selectedMarkdown?: string;
};
postfix?: (text: string) => string;
}
@@ -277,6 +280,7 @@ declare global {
files: CopilotContextFile[];
tags: CopilotContextCategory[];
collections: CopilotContextCategory[];
blobs: CopilotContextBlob[];
};
interface AIContextService {
@@ -356,6 +360,14 @@ declare global {
op: string,
updates: string
) => Promise<string>;
addContextBlobs: (options: {
blobIds: string[];
contextId: string;
}) => Promise<CopilotContextBlob[]>;
removeContextBlobs: (options: {
blobIds: string[];
contextId: string;
}) => Promise<boolean>;
}
// TODO(@Peng): should be refactored to get rid of implement details (like messages, action, role, etc.)

View File

@@ -18,6 +18,7 @@ import {
isCollectionChip,
isDocChip,
isFileChip,
isSelectedContextChip,
isTagChip,
} from './utils';
@@ -183,6 +184,12 @@ export class ChatPanelChips extends SignalWatcher(
.removeChip=${this.removeChip}
></chat-panel-collection-chip>`;
}
if (isSelectedContextChip(chip)) {
return html`<chat-panel-selected-chip
.chip=${chip}
.removeChip=${this.removeChip}
></chat-panel-selected-chip>`;
}
return null;
}
)}
@@ -271,14 +278,29 @@ export class ChatPanelChips extends SignalWatcher(
if (isFileChip(chip) || isTagChip(chip) || isCollectionChip(chip)) {
return acc;
}
if (chip.docId === newChip.docId) {
if (isDocChip(chip) && chip.docId === newChip.docId) {
return acc + newTokenCount;
}
if (chip.markdown?.value && chip.state === 'finished') {
if (
isDocChip(chip) &&
chip.markdown?.value &&
chip.state === 'finished'
) {
const tokenCount =
chip.tokenCount ?? estimateTokenCount(chip.markdown.value);
return acc + tokenCount;
}
if (
isSelectedContextChip(chip) &&
chip.combinedElementsMarkdown &&
chip.snapshot
) {
const tokenCount =
estimateTokenCount(chip.combinedElementsMarkdown) +
estimateTokenCount(JSON.stringify(chip.snapshot));
return acc + tokenCount;
}
return acc;
}, 0);
return estimatedTokens <= MAX_TOKEN_COUNT;

View File

@@ -0,0 +1,43 @@
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { ShadowlessElement } from '@blocksuite/affine/std';
import { UngroupIcon } from '@blocksuite/icons/lit';
import { html } from 'lit';
import { property } from 'lit/decorators.js';
import type { SelectedContextChip } from './type';
import { getChipIcon, getChipTooltip } from './utils';
export class ChatPanelSelectedChip extends SignalWatcher(
WithDisposable(ShadowlessElement)
) {
@property({ attribute: false })
accessor chip!: SelectedContextChip;
@property({ attribute: false })
accessor removeChip!: (chip: SelectedContextChip) => void;
override render() {
const { state } = this.chip;
const isLoading = state === 'processing';
const tooltip = getChipTooltip(
state,
'selected-content',
this.chip.tooltip
);
const icon = getChipIcon(state, UngroupIcon());
return html`<chat-panel-chip
.state=${state}
.name=${'selected-content'}
.tooltip=${tooltip}
.icon=${icon}
.closeable=${!isLoading}
.onChipDelete=${this.onChipDelete}
></chat-panel-chip>`;
}
private readonly onChipDelete = () => {
this.removeChip(this.chip);
};
}

View File

@@ -36,7 +36,19 @@ export interface CollectionChip extends BaseChip {
collectionId: string;
}
export type ChatChip = DocChip | FileChip | TagChip | CollectionChip;
export interface SelectedContextChip extends BaseChip {
uuid: string;
attachments: { sourceId: string; name: string }[];
snapshot: string | null;
combinedElementsMarkdown: string | null;
}
export type ChatChip =
| DocChip
| FileChip
| TagChip
| CollectionChip
| SelectedContextChip;
export interface DocDisplayConfig {
getIcon: (docId: string) => any;

View File

@@ -8,6 +8,7 @@ import type {
CollectionChip,
DocChip,
FileChip,
SelectedContextChip,
TagChip,
} from './type';
@@ -62,6 +63,16 @@ export function isCollectionChip(chip: ChatChip): chip is CollectionChip {
return 'collectionId' in chip;
}
export function isSelectedContextChip(
chip: ChatChip
): chip is SelectedContextChip {
return (
'attachments' in chip &&
'snapshot' in chip &&
'combinedElementsMarkdown' in chip
);
}
export function getChipKey(chip: ChatChip) {
if (isDocChip(chip)) {
return chip.docId;
@@ -75,6 +86,9 @@ export function getChipKey(chip: ChatChip) {
if (isCollectionChip(chip)) {
return chip.collectionId;
}
if (isSelectedContextChip(chip)) {
return chip.uuid;
}
return null;
}
@@ -92,6 +106,9 @@ export function omitChip(chips: ChatChip[], chip: ChatChip) {
if (isCollectionChip(chip)) {
return !isCollectionChip(item) || item.collectionId !== chip.collectionId;
}
if (isSelectedContextChip(chip)) {
return !isSelectedContextChip(chip);
}
return true;
});
}
@@ -110,6 +127,9 @@ export function findChipIndex(chips: ChatChip[], chip: ChatChip) {
if (isCollectionChip(chip)) {
return isCollectionChip(item) && item.collectionId === chip.collectionId;
}
if (isSelectedContextChip(chip)) {
return isSelectedContextChip(item) && item.uuid === chip.uuid;
}
return -1;
});
}

View File

@@ -6,16 +6,20 @@ import type {
} from '@affine/core/modules/ai-button';
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
import type {
ContextEmbedStatus,
ContextWorkspaceEmbeddingStatus,
CopilotChatHistoryFragment,
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';
import type { NotificationService } from '@blocksuite/affine-shared/services';
import { uuidv4 } from '@blocksuite/affine/store';
import type {
FeatureFlagService,
NotificationService,
} from '@blocksuite/affine-shared/services';
import { css, html, type PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js';
@@ -27,6 +31,7 @@ import type {
DocChip,
DocDisplayConfig,
FileChip,
SelectedContextChip,
TagChip,
} from '../ai-chat-chips';
import {
@@ -34,6 +39,7 @@ import {
isCollectionChip,
isDocChip,
isFileChip,
isSelectedContextChip,
isTagChip,
omitChip,
} from '../ai-chat-chips';
@@ -125,6 +131,9 @@ export class AIChatComposer extends SignalWatcher(
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@state()
accessor chips: ChatChip[] = [];
@@ -170,11 +179,13 @@ export class AIChatComposer extends SignalWatcher(
.reasoningConfig=${this.reasoningConfig}
.docDisplayConfig=${this.docDisplayConfig}
.searchMenuConfig=${this.searchMenuConfig}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.aiDraftService=${this.aiDraftService}
.aiToolsConfigService=${this.aiToolsConfigService}
.portalContainer=${this.portalContainer}
.onChatSuccess=${this.onChatSuccess}
.trackOptions=${this.trackOptions}
.isContextProcessing=${this.isContextProcessing}
></ai-chat-input>
<div class="chat-panel-footer">
<ai-chat-composer-tip
@@ -195,6 +206,25 @@ 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) return;
if (context) {
this.updateContext(context);
}
if (context?.attachments) {
// Wait for context value updated next frame
setTimeout(() => {
this.addSelectedContextChip().catch(console.error);
}, 0);
}
})
);
this.initComposer().catch(console.error);
}
@@ -215,6 +245,10 @@ export class AIChatComposer extends SignalWatcher(
}
}
private get isContextProcessing() {
return this.chips.some(chip => chip.state === 'processing');
}
private readonly _getContextId = async () => {
if (this._contextId) {
return this._contextId;
@@ -334,12 +368,17 @@ export class AIChatComposer extends SignalWatcher(
]);
};
private readonly addChip = async (chip: ChatChip) => {
private readonly addChip = async (
chip: ChatChip,
silent: boolean = false
) => {
this.isChipsCollapsed = false;
// if already exists
const index = findChipIndex(this.chips, chip);
if (index !== -1) {
this.notificationService.toast('chip already exists');
if (!silent) {
this.notificationService.toast('chip already exists');
}
return;
}
this.updateChips([...this.chips, chip]);
@@ -353,6 +392,27 @@ export class AIChatComposer extends SignalWatcher(
await this.removeFromContext(chip);
};
private readonly addSelectedContextChip = async () => {
const { attachments, snapshot, combinedElementsMarkdown } =
this.chatContextValue;
await this.removeSelectedContextChip();
const chip: SelectedContextChip = {
uuid: uuidv4(),
attachments,
snapshot,
combinedElementsMarkdown,
state: 'processing',
};
await this.addChip(chip, true);
};
private readonly removeSelectedContextChip = async () => {
const selectedContextChip = this.chips.find(c => isSelectedContextChip(c));
if (selectedContextChip) {
await this.removeChip(selectedContextChip);
}
};
private readonly addToContext = async (chip: ChatChip) => {
if (isDocChip(chip)) {
return await this.addDocToContext(chip);
@@ -366,6 +426,9 @@ export class AIChatComposer extends SignalWatcher(
if (isCollectionChip(chip)) {
return await this.addCollectionToContext(chip);
}
if (isSelectedContextChip(chip)) {
return await this.addSelectedContextChipToContext(chip);
}
return null;
};
@@ -460,6 +523,20 @@ export class AIChatComposer extends SignalWatcher(
}
};
private readonly addSelectedContextChipToContext = async (
chip: SelectedContextChip
) => {
const { attachments } = 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,
});
};
private readonly removeFromContext = async (
chip: ChatChip
): Promise<boolean> => {
@@ -492,6 +569,13 @@ export class AIChatComposer extends SignalWatcher(
collectionId: chip.collectionId,
});
}
if (isSelectedContextChip(chip)) {
const { attachments } = chip;
return await AIProvider.context.removeContextBlobs({
contextId,
blobIds: attachments.map(attachment => attachment.sourceId),
});
}
return true;
} catch {
return true;
@@ -575,13 +659,17 @@ export class AIChatComposer extends SignalWatcher(
files = [],
tags = [],
collections = [],
blobs = [],
} = result;
const docs = [
...sDocs,
...tags.flatMap(tag => tag.docs),
...collections.flatMap(collection => collection.docs),
];
const hashMap = new Map<string, CopilotContextDoc | CopilotContextFile>();
const hashMap = new Map<
string,
CopilotContextDoc | CopilotContextFile | { status: ContextEmbedStatus }
>();
const count: Record<ContextEmbedStatus, number> = {
finished: 0,
processing: 0,
@@ -595,11 +683,27 @@ 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]++;
}
const nextChips = this.chips.map(chip => {
if (isTagChip(chip) || isCollectionChip(chip)) {
return chip;
}
const id = isDocChip(chip) ? chip.docId : chip.fileId;
const id = isDocChip(chip)
? chip.docId
: isFileChip(chip)
? chip.fileId
: chip.uuid;
const item = id && hashMap.get(id);
if (item && item.status) {
return {

View File

@@ -439,6 +439,7 @@ export class AIChatContent extends SignalWatcher(
[this.onboardingOffsetY > 0 ? 'paddingTop' : 'paddingBottom']:
`${this.messages.length === 0 ? Math.abs(this.onboardingOffsetY) * 2 : 0}px`,
})}
.affineFeatureFlagService=${this.affineFeatureFlagService}
.independentMode=${this.independentMode}
.host=${this.host}
.workspaceId=${this.workspaceId}

View File

@@ -2,6 +2,7 @@ import type {
AIDraftService,
AIToolsConfigService,
} from '@affine/core/modules/ai-button';
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
import type { CopilotChatHistoryFragment } from '@affine/graphql';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
@@ -308,6 +309,9 @@ export class AIChatInput extends SignalWatcher(
@property({ attribute: false })
accessor session!: CopilotChatHistoryFragment | null | undefined;
@property({ attribute: false })
accessor isContextProcessing!: boolean | undefined;
@query('image-preview-grid')
accessor imagePreviewGrid: HTMLDivElement | null = null;
@@ -341,7 +345,7 @@ export class AIChatInput extends SignalWatcher(
accessor addImages!: (images: File[]) => void;
@property({ attribute: false })
accessor addChip!: (chip: ChatChip) => Promise<void>;
accessor addChip!: (chip: ChatChip, silent?: boolean) => Promise<void>;
@property({ attribute: false })
accessor networkSearchConfig!: AINetworkSearchConfig;
@@ -361,6 +365,9 @@ export class AIChatInput extends SignalWatcher(
@property({ attribute: false })
accessor aiToolsConfigService!: AIToolsConfigService;
@property({ attribute: false })
accessor affineFeatureFlagService!: FeatureFlagService;
@property({ attribute: false })
accessor isRootSession: boolean = true;
@@ -409,6 +416,20 @@ export class AIChatInput extends SignalWatcher(
}
)
);
this._disposables.add(
AIProvider.slots.requestOpenWithChat.subscribe(params => {
if (!params) return;
const { input, host } = params;
if (this.host !== host) return;
if (input) {
this.textarea.value = input;
this.isInputEmpty = !this.textarea.value.trim();
}
})
);
}
protected override firstUpdated(changedProperties: PropertyValues): void {
@@ -515,7 +536,7 @@ export class AIChatInput extends SignalWatcher(
: html`<button
@click="${this._onTextareaSend}"
class="chat-panel-send"
aria-disabled=${this.isInputEmpty}
aria-disabled=${this.isSendDisabled}
data-testid="chat-panel-send"
>
${ArrowUpBigIcon()}
@@ -524,6 +545,18 @@ export class AIChatInput extends SignalWatcher(
</div>`;
}
private get isSendDisabled() {
if (this.isInputEmpty) {
return true;
}
if (this.isContextProcessing) {
return true;
}
return false;
}
private readonly _handlePointerDown = (e: MouseEvent) => {
if (e.target !== this.textarea) {
// by default the div will be focused and will blur the textarea
@@ -619,7 +652,9 @@ export class AIChatInput extends SignalWatcher(
send = async (text: string) => {
try {
const { status, markdown, images } = this.chatContextValue;
const { status, markdown, images, snapshot, combinedElementsMarkdown } =
this.chatContextValue;
if (status === 'loading' || status === 'transmitting') return;
if (!text) return;
if (!AIProvider.actions.chat) return;
@@ -634,25 +669,38 @@ export class AIChatInput extends SignalWatcher(
abortController,
});
const attachments = await Promise.all(
const imageAttachments = await Promise.all(
images?.map(image => readBlobAsURL(image))
);
const userInput = (markdown ? `${markdown}\n` : '') + text;
// optimistic update messages
await this._preUpdateMessages(userInput, attachments);
await this._preUpdateMessages(userInput, imageAttachments);
const sessionId = (await this.createSession())?.sessionId;
let contexts = await this._getMatchedContexts();
if (abortController.signal.aborted) {
return;
}
const enableSendDetailedObject =
this.affineFeatureFlagService.flags.enable_send_detailed_object_to_ai
.value;
const stream = await AIProvider.actions.chat({
sessionId,
input: userInput,
contexts,
contexts: {
...contexts,
selectedSnapshot:
snapshot && enableSendDetailedObject ? snapshot : undefined,
selectedMarkdown:
combinedElementsMarkdown && enableSendDetailedObject
? combinedElementsMarkdown
: undefined,
},
docId: this.docId,
attachments: images,
attachments: [],
workspaceId: this.workspaceId,
stream: true,
signal: abortController.signal,

View File

@@ -36,6 +36,7 @@ import { ChatPanelChip } from './components/ai-chat-chips/chip';
import { ChatPanelCollectionChip } from './components/ai-chat-chips/collection-chip';
import { ChatPanelDocChip } from './components/ai-chat-chips/doc-chip';
import { ChatPanelFileChip } from './components/ai-chat-chips/file-chip';
import { ChatPanelSelectedChip } from './components/ai-chat-chips/selected-chip';
import { ChatPanelTagChip } from './components/ai-chat-chips/tag-chip';
import { AIChatComposer } from './components/ai-chat-composer';
import { AIChatContent } from './components/ai-chat-content';
@@ -164,6 +165,7 @@ export function registerAIEffects() {
customElements.define('chat-panel-file-chip', ChatPanelFileChip);
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-chip', ChatPanelChip);
customElements.define('ai-error-wrapper', AIErrorWrapper);
customElements.define('ai-slides-renderer', AISlidesRenderer);

View File

@@ -57,11 +57,20 @@ export function edgelessToolbarAIEntryConfig(): ToolbarModuleConfig {
aiPanel.hide();
extractSelectedContent(host)
.then(context => {
AIProvider.slots.requestSendWithChat.next({
input,
context,
host,
});
if (context?.attachments?.length) {
AIProvider.slots.requestOpenWithChat.next({
input,
host,
context,
autoSelect: true,
});
} else {
AIProvider.slots.requestSendWithChat.next({
input,
context,
host,
});
}
})
.catch(console.error);
};

View File

@@ -18,9 +18,11 @@ export interface AIUserInfo {
export interface AIChatParams {
host: EditorHost;
input?: string;
mode?: 'page' | 'edgeless';
// Auto select and append selection to input via `Continue in AI Chat` action.
autoSelect?: boolean;
context?: Partial<ChatContextValue | null>;
}
export interface AISendParams {

View File

@@ -2,6 +2,7 @@ import { showAILoginRequiredAtom } from '@affine/core/components/affine/auth/ai-
import type { AIToolsConfig } from '@affine/core/modules/ai-button';
import type { UserFriendlyError } from '@affine/error';
import {
addContextBlobMutation,
addContextCategoryMutation,
addContextDocMutation,
addContextFileMutation,
@@ -24,6 +25,7 @@ import {
type PaginationInput,
type QueryOptions,
type QueryResponse,
removeContextBlobMutation,
removeContextCategoryMutation,
removeContextDocMutation,
removeContextFileMutation,
@@ -534,4 +536,22 @@ export class CopilotClient {
},
}).then(res => res.applyDocUpdates);
}
addContextBlob(options: OptionsField<typeof addContextBlobMutation>) {
return this.gql({
query: addContextBlobMutation,
variables: {
options,
},
}).then(res => res.addContextBlob);
}
removeContextBlob(options: OptionsField<typeof removeContextBlobMutation>) {
return this.gql({
query: removeContextBlobMutation,
variables: {
options,
},
}).then(res => res.removeContextBlob);
}
}

View File

@@ -99,6 +99,8 @@ export function setupAIProvider(
params: {
docs: contexts?.docs,
files: contexts?.files,
selectedSnapshot: contexts?.selectedSnapshot,
selectedMarkdown: contexts?.selectedMarkdown,
searchMode: webSearch ? 'MUST' : 'AUTO',
},
endpoint: Endpoint.StreamObject,
@@ -745,6 +747,32 @@ 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,
})
)
);
},
removeContextBlobs: async (options: {
blobIds: string[];
contextId: string;
}) => {
return Promise.all(
options.blobIds.map(blobId =>
client.removeContextBlob({
contextId: options.contextId,
blobId,
})
)
).then(results => results.every(Boolean));
},
});
AIProvider.provide('histories', {

View File

@@ -264,6 +264,23 @@ export const AFFINE_FLAGS = {
configurable: isCanaryBuild,
defaultState: false,
},
enable_two_step_journal_confirmation: {
category: 'affine',
displayName: 'Enable Two Step Journal Confirmation',
description:
'When enabled, you must confirm the journal before you can create a new journal.',
configurable: isCanaryBuild,
defaultState: isCanaryBuild,
},
enable_send_detailed_object_to_ai: {
category: 'affine',
displayName:
'com.affine.settings.workspace.experimental-features.enable-ai-send-detailed-object.name',
description:
'com.affine.settings.workspace.experimental-features.enable-ai-send-detailed-object.description',
configurable: true,
defaultState: true,
},
} satisfies { [key in string]: FlagInfo };
// oxlint-disable-next-line no-redeclare