mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 12:55:00 +00:00
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:
@@ -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.)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user