feat(core): add ai file context api (#10842)

Close [BS-2349](https://linear.app/affine-design/issue/BS-2349).

### What Changed?
- Add file context graphql apis
- Pass matched file chunks to LLM

[录屏2025-02-19 23.27.47.mov <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.dev/api/v1/graphite/video/thumbnail/sJGviKxfE3Ap685cl5bj/8e8a98ca-6959-4bb6-9759-b51d97cede49.mov" />](https://app.graphite.dev/media/video/sJGviKxfE3Ap685cl5bj/8e8a98ca-6959-4bb6-9759-b51d97cede49.mov)
This commit is contained in:
akumatus
2025-03-14 04:29:54 +00:00
parent 8880cef20b
commit daccb2c865
18 changed files with 251 additions and 99 deletions

View File

@@ -1,7 +1,7 @@
import { StoreExtension } from '@blocksuite/store'; import { StoreExtension } from '@blocksuite/store';
// bytes.parse('2GB') // bytes.parse('2GB')
const maxFileSize = 2147483648; const maxFileSize = 2 * 1024 * 1024 * 1024;
export class FileSizeLimitService extends StoreExtension { export class FileSizeLimitService extends StoreExtension {
static override key = 'file-size-limit'; static override key = 'file-size-limit';

View File

@@ -141,6 +141,7 @@ export class ChatSession implements AsyncDisposable {
finish(params: PromptParams): PromptMessage[] { finish(params: PromptParams): PromptMessage[] {
const messages = this.takeMessages(); const messages = this.takeMessages();
const firstMessage = messages.at(0); const firstMessage = messages.at(0);
// TODO: refactor this {{content}} keyword agreement
// if the message in prompt config contains {{content}}, // if the message in prompt config contains {{content}},
// we should combine it with the user message in the prompt // we should combine it with the user message in the prompt
if ( if (

View File

@@ -1,5 +1,6 @@
import type { import type {
ChatHistoryOrder, ChatHistoryOrder,
ContextMatchedFileChunk,
CopilotContextDoc, CopilotContextDoc,
CopilotContextFile, CopilotContextFile,
CopilotSessionType, CopilotSessionType,
@@ -10,7 +11,7 @@ import type { EditorHost } from '@blocksuite/affine/block-std';
import type { GfxModel } from '@blocksuite/affine/block-std/gfx'; import type { GfxModel } from '@blocksuite/affine/block-std/gfx';
import type { BlockModel } from '@blocksuite/affine/store'; import type { BlockModel } from '@blocksuite/affine/store';
import type { DocContext } from '../chat-panel/chat-context'; import type { DocContext, FileContext } from '../chat-panel/chat-context';
export const translateLangs = [ export const translateLangs = [
'English', 'English',
@@ -114,7 +115,10 @@ declare global {
interface ChatOptions extends AITextActionOptions { interface ChatOptions extends AITextActionOptions {
sessionId?: string; sessionId?: string;
isRootSession?: boolean; isRootSession?: boolean;
docs?: DocContext[]; contexts?: {
docs: DocContext[];
files: FileContext[];
};
} }
interface TranslateOptions extends AITextActionOptions { interface TranslateOptions extends AITextActionOptions {
@@ -250,19 +254,22 @@ declare global {
addContextDoc: (options: { addContextDoc: (options: {
contextId: string; contextId: string;
docId: string; docId: string;
}) => Promise<{ id: string; createdAt: number }>; }) => Promise<CopilotContextDoc>;
removeContextDoc: (options: { removeContextDoc: (options: {
contextId: string; contextId: string;
docId: string; docId: string;
}) => Promise<boolean>; }) => Promise<boolean>;
addContextFile: (options: { addContextFile: (
file: File,
options: {
contextId: string; contextId: string;
fileId: string; blobId: string;
}) => Promise<void>; }
) => Promise<CopilotContextFile>;
removeContextFile: (options: { removeContextFile: (options: {
contextId: string; contextId: string;
fileId: string; fileId: string;
}) => Promise<void>; }) => Promise<boolean>;
getContextDocsAndFiles: ( getContextDocsAndFiles: (
workspaceId: string, workspaceId: string,
sessionId: string, sessionId: string,
@@ -274,6 +281,11 @@ declare global {
} }
| undefined | undefined
>; >;
matchContext: (
contextId: string,
content: string,
limit?: number
) => Promise<ContextMatchedFileChunk[] | undefined>;
} }
// TODO(@Peng): should be refactored to get rid of implement details (like messages, action, role, etc.) // TODO(@Peng): should be refactored to get rid of implement details (like messages, action, role, etc.)

View File

@@ -36,11 +36,18 @@ export type ChatStatus =
export interface DocContext { export interface DocContext {
docId: string; docId: string;
plaintext?: string; refIndex: number;
markdown?: string; markdown: string;
images?: File[];
} }
export type FileContext = {
blobId: string;
refIndex: number;
fileName: string;
fileType: string;
chunks: string;
};
export type ChatContextValue = { export type ChatContextValue = {
// history messages of the chat // history messages of the chat
items: ChatItem[]; items: ChatItem[];
@@ -73,19 +80,19 @@ export interface BaseChip {
* failed: the chip is failed to process * failed: the chip is failed to process
*/ */
state: ChipState; state: ChipState;
tooltip?: string; tooltip?: string | null;
} }
export interface DocChip extends BaseChip { export interface DocChip extends BaseChip {
docId: string; docId: string;
markdown?: Signal<string>; markdown?: Signal<string> | null;
tokenCount?: number; tokenCount?: number | null;
} }
export interface FileChip extends BaseChip { export interface FileChip extends BaseChip {
fileName: string; file: File;
fileId: string; fileId?: string | null;
fileType: string; blobId?: string | null;
} }
export interface TagChip extends BaseChip { export interface TagChip extends BaseChip {

View File

@@ -112,6 +112,7 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
if (isFileChip(chip)) { if (isFileChip(chip)) {
return html`<chat-panel-file-chip return html`<chat-panel-file-chip
.chip=${chip} .chip=${chip}
.removeChip=${this._removeChip}
></chat-panel-file-chip>`; ></chat-panel-file-chip>`;
} }
return null; return null;
@@ -174,14 +175,15 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
}; };
private readonly _addChip = async (chip: ChatChip) => { private readonly _addChip = async (chip: ChatChip) => {
this.isCollapsed = false;
if ( if (
this.chatContextValue.chips.length === 1 && this.chatContextValue.chips.length === 1 &&
this.chatContextValue.chips[0].state === 'candidate' this.chatContextValue.chips[0].state === 'candidate'
) { ) {
await this._addToContext(chip);
this.updateContext({ this.updateContext({
chips: [chip], chips: [chip],
}); });
await this._addToContext(chip);
return; return;
} }
// remove the chip if it already exists // remove the chip if it already exists
@@ -189,16 +191,16 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
if (isDocChip(chip)) { if (isDocChip(chip)) {
return !isDocChip(item) || item.docId !== chip.docId; return !isDocChip(item) || item.docId !== chip.docId;
} else { } else {
return !isFileChip(item) || item.fileId !== chip.fileId; return !isFileChip(item) || item.file !== chip.file;
} }
}); });
this.updateContext({
chips: [...chips, chip],
});
if (chips.length < this.chatContextValue.chips.length) { if (chips.length < this.chatContextValue.chips.length) {
await this._removeFromContext(chip); await this._removeFromContext(chip);
} }
await this._addToContext(chip); await this._addToContext(chip);
this.updateContext({
chips: [...chips, chip],
});
}; };
private readonly _updateChip = ( private readonly _updateChip = (
@@ -209,7 +211,7 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
if (isDocChip(chip)) { if (isDocChip(chip)) {
return isDocChip(item) && item.docId === chip.docId; return isDocChip(item) && item.docId === chip.docId;
} else { } else {
return isFileChip(item) && item.fileId === chip.fileId; return isFileChip(item) && item.file === chip.file;
} }
}); });
const nextChip: ChatChip = { const nextChip: ChatChip = {
@@ -237,7 +239,7 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
await this._removeFromContext(chip); await this._removeFromContext(chip);
this.updateContext({ this.updateContext({
chips: this.chatContextValue.chips.filter(item => { chips: this.chatContextValue.chips.filter(item => {
return !isFileChip(item) || item.fileId !== chip.fileId; return !isFileChip(item) || item.file !== chip.file;
}), }),
}); });
} }
@@ -254,10 +256,23 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
docId: chip.docId, docId: chip.docId,
}); });
} else { } else {
await AIProvider.context.addContextFile({ try {
const blobId = await this.host.doc.blobSync.set(chip.file);
const contextFile = await AIProvider.context.addContextFile(chip.file, {
contextId, contextId,
fileId: chip.fileId, blobId,
}); });
this._updateChip(chip, {
state: 'success',
blobId: contextFile.blobId,
fileId: contextFile.id,
});
} catch (e) {
this._updateChip(chip, {
state: 'failed',
tooltip: e instanceof Error ? e.message : 'Add context file error',
});
}
} }
}; };
@@ -271,7 +286,7 @@ export class ChatPanelChips extends WithDisposable(ShadowlessElement) {
contextId, contextId,
docId: chip.docId, docId: chip.docId,
}); });
} else { } else if (isFileChip(chip) && chip.fileId) {
await AIProvider.context.removeContextFile({ await AIProvider.context.removeContextFile({
contextId, contextId,
fileId: chip.fileId, fileId: chip.fileId,

View File

@@ -19,8 +19,13 @@ import { AIProvider } from '../provider';
import { reportResponse } from '../utils/action-reporter'; import { reportResponse } from '../utils/action-reporter';
import { readBlobAsURL } from '../utils/image'; import { readBlobAsURL } from '../utils/image';
import type { AINetworkSearchConfig } from './chat-config'; import type { AINetworkSearchConfig } from './chat-config';
import type { ChatContextValue, ChatMessage, DocContext } from './chat-context'; import type {
import { isDocChip } from './components/utils'; ChatContextValue,
ChatMessage,
DocContext,
FileContext,
} from './chat-context';
import { isDocChip, isFileChip } from './components/utils';
import { PROMPT_NAME_AFFINE_AI, PROMPT_NAME_NETWORK_SEARCH } from './const'; import { PROMPT_NAME_AFFINE_AI, PROMPT_NAME_NETWORK_SEARCH } from './const';
const MaximumImageCount = 32; const MaximumImageCount = 32;
@@ -199,6 +204,9 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
@property({ attribute: false }) @property({ attribute: false })
accessor getSessionId!: () => Promise<string | undefined>; accessor getSessionId!: () => Promise<string | undefined>;
@property({ attribute: false })
accessor getContextId!: () => Promise<string | undefined>;
@property({ attribute: false }) @property({ attribute: false })
accessor updateContext!: (context: Partial<ChatContextValue>) => void; accessor updateContext!: (context: Partial<ChatContextValue>) => void;
@@ -218,7 +226,7 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
private get _isNetworkDisabled() { private get _isNetworkDisabled() {
return ( return (
!!this.chatContextValue.images.length || !!this.chatContextValue.images.length ||
!!this.chatContextValue.chips.filter(chip => chip.state !== 'candidate') !!this.chatContextValue.chips.filter(chip => chip.state === 'success')
.length .length
); );
} }
@@ -452,7 +460,7 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
}; };
send = async (text: string) => { send = async (text: string) => {
const { status, markdown, chips, images } = this.chatContextValue; const { status, markdown, images } = this.chatContextValue;
if (status === 'loading' || status === 'transmitting') return; if (status === 'loading' || status === 'transmitting') return;
if (!text) return; if (!text) return;
@@ -498,17 +506,12 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
const abortController = new AbortController(); const abortController = new AbortController();
const sessionId = await this.getSessionId(); const sessionId = await this.getSessionId();
const docs: DocContext[] = chips
.filter(isDocChip) const contexts = await this._getMatchedContexts(userInput);
.filter(chip => !!chip.markdown?.value && chip.state === 'success')
.map(chip => ({
docId: chip.docId,
markdown: chip.markdown?.value || '',
}));
const stream = AIProvider.actions.chat?.({ const stream = AIProvider.actions.chat?.({
sessionId, sessionId,
input: userInput, input: userInput,
docs: docs, contexts,
docId: doc.id, docId: doc.id,
attachments: images, attachments: images,
workspaceId: doc.workspace.id, workspaceId: doc.workspace.id,
@@ -550,6 +553,44 @@ export class ChatPanelInput extends SignalWatcher(WithDisposable(LitElement)) {
this.updateContext({ abortController: null }); this.updateContext({ abortController: null });
} }
}; };
private async _getMatchedContexts(userInput: string) {
const contextId = await this.getContextId();
const matched = contextId
? (await AIProvider.context?.matchContext(contextId, userInput)) || []
: [];
const contexts = this.chatContextValue.chips.reduce(
(acc, chip, index) => {
if (chip.state !== 'success') {
return acc;
}
if (isDocChip(chip) && !!chip.markdown?.value) {
acc.docs.push({
docId: chip.docId,
refIndex: index + 1,
markdown: chip.markdown.value,
});
}
if (isFileChip(chip) && chip.blobId) {
const matchedChunks = matched
.filter(chunk => chunk.fileId === chip.fileId)
.map(chunk => chunk.content);
if (matchedChunks.length > 0) {
acc.files.push({
blobId: chip.blobId,
refIndex: index + 1,
fileName: chip.file.name,
fileType: chip.file.type,
chunks: matchedChunks.join('\n'),
});
}
}
return acc;
},
{ docs: [], files: [] } as { docs: DocContext[]; files: FileContext[] }
);
return contexts;
}
} }
declare global { declare global {

View File

@@ -1,8 +1,10 @@
import { toast } from '@affine/component';
import { ShadowlessElement } from '@blocksuite/affine/block-std'; import { ShadowlessElement } from '@blocksuite/affine/block-std';
import type { LinkedMenuGroup } from '@blocksuite/affine/blocks/root'; import { type LinkedMenuGroup } from '@blocksuite/affine/blocks/root';
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { scrollbarStyle } from '@blocksuite/affine/shared/styles'; import { scrollbarStyle } from '@blocksuite/affine/shared/styles';
import { SearchIcon } from '@blocksuite/icons/lit'; import { openFileOrFiles } from '@blocksuite/affine/shared/utils';
import { SearchIcon, UploadIcon } from '@blocksuite/icons/lit';
import type { DocMeta } from '@blocksuite/store'; import type { DocMeta } from '@blocksuite/store';
import { css, html } from 'lit'; import { css, html } from 'lit';
import { property, query, state } from 'lit/decorators.js'; import { property, query, state } from 'lit/decorators.js';
@@ -138,9 +140,35 @@ export class ChatPanelAddPopover extends SignalWatcher(
}) })
: html`<div class="no-result">No Result</div>`} : html`<div class="no-result">No Result</div>`}
</div> </div>
<div class="divider"></div>
<div class="upload-wrapper">
<icon-button
width="280px"
height="30px"
data-id="upload"
.text=${'Upload files (pdf, txt, csv)'}
@click=${this._addFileChip}
>
${UploadIcon()}
</icon-button>
</div>
</div>`; </div>`;
} }
private readonly _addFileChip = async () => {
const file = await openFileOrFiles();
if (!file) return;
if (file.size > 50 * 1024 * 1024) {
toast('You can only upload files less than 50MB');
return;
}
this.addChip({
file,
state: 'processing',
});
this.abortController.abort();
};
private _onInput(event: Event) { private _onInput(event: Event) {
this._query = (event.target as HTMLInputElement).value; this._query = (event.target as HTMLInputElement).value;
this._updateDocGroup(); this._updateDocGroup();

View File

@@ -125,7 +125,7 @@ export class ChatPanelDocChip extends SignalWatcher(
} catch (e) { } catch (e) {
this.updateChip(this.chip, { this.updateChip(this.chip, {
state: 'failed', state: 'failed',
tooltip: e instanceof Error ? e.message : 'Failed to process document', tooltip: e instanceof Error ? e.message : 'Failed to extract markdown',
}); });
} }
}; };

View File

@@ -4,7 +4,7 @@ import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
import { html } from 'lit'; import { html } from 'lit';
import { property } from 'lit/decorators.js'; import { property } from 'lit/decorators.js';
import type { FileChip } from '../chat-context'; import type { ChatChip, FileChip } from '../chat-context';
import { getChipIcon, getChipTooltip } from './utils'; import { getChipIcon, getChipTooltip } from './utils';
export class ChatPanelFileChip extends SignalWatcher( export class ChatPanelFileChip extends SignalWatcher(
@@ -13,19 +13,28 @@ export class ChatPanelFileChip extends SignalWatcher(
@property({ attribute: false }) @property({ attribute: false })
accessor chip!: FileChip; accessor chip!: FileChip;
@property({ attribute: false })
accessor removeChip!: (chip: ChatChip) => void;
override render() { override render() {
const { state, fileName, fileType } = this.chip; const { state, file } = this.chip;
const isLoading = state === 'processing'; const isLoading = state === 'processing';
const tooltip = getChipTooltip(state, fileName, this.chip.tooltip); const tooltip = getChipTooltip(state, file.name, this.chip.tooltip);
const fileType = file.name.split('.').pop() ?? '';
const fileIcon = getAttachmentFileIcon(fileType); const fileIcon = getAttachmentFileIcon(fileType);
const icon = getChipIcon(state, fileIcon); const icon = getChipIcon(state, fileIcon);
return html`<chat-panel-chip return html`<chat-panel-chip
.state=${state} .state=${state}
.name=${fileName} .name=${file.name}
.tooltip=${tooltip} .tooltip=${tooltip}
.icon=${icon} .icon=${icon}
.closeable=${!isLoading} .closeable=${!isLoading}
.onChipDelete=${this.onChipDelete}
></chat-panel-chip>`; ></chat-panel-chip>`;
} }
private readonly onChipDelete = () => {
this.removeChip(this.chip);
};
} }

View File

@@ -8,7 +8,7 @@ import type { ChatChip, ChipState, DocChip, FileChip } from '../chat-context';
export function getChipTooltip( export function getChipTooltip(
state: ChipState, state: ChipState,
name: string, name: string,
tooltip?: string tooltip?: string | null
) { ) {
if (tooltip) { if (tooltip) {
return tooltip; return tooltip;
@@ -20,7 +20,7 @@ export function getChipTooltip(
return 'Processing...'; return 'Processing...';
} }
if (state === 'failed') { if (state === 'failed') {
return 'Failed to process'; return 'Failed to add to context';
} }
return name; return name;
} }
@@ -45,7 +45,7 @@ export function isDocChip(chip: ChatChip): chip is DocChip {
} }
export function isFileChip(chip: ChatChip): chip is FileChip { export function isFileChip(chip: ChatChip): chip is FileChip {
return 'fileId' in chip; return 'file' in chip && chip.file instanceof File;
} }
export function isDocContext( export function isDocContext(

View File

@@ -28,6 +28,7 @@ import type {
DocSearchMenuConfig, DocSearchMenuConfig,
} from './chat-config'; } from './chat-config';
import type { import type {
ChatChip,
ChatContextValue, ChatContextValue,
ChatItem, ChatItem,
DocChip, DocChip,
@@ -180,7 +181,6 @@ export class ChatPanel extends SignalWatcher(
} }
// context initialized, show the chips // context initialized, show the chips
let chips: (DocChip | FileChip)[] = [];
const { docs = [], files = [] } = const { docs = [], files = [] } =
(await AIProvider.context?.getContextDocsAndFiles( (await AIProvider.context?.getContextDocsAndFiles(
this.doc.workspace.id, this.doc.workspace.id,
@@ -191,23 +191,34 @@ export class ChatPanel extends SignalWatcher(
(a, b) => (a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
); );
chips = list.map(item => { const chips: ChatChip[] = await Promise.all(
let chip: DocChip | FileChip; list.map(async item => {
if (isDocContext(item)) { if (isDocContext(item)) {
chip = { return {
docId: item.id, docId: item.id,
state: 'processing', state: 'processing',
}; } as DocChip;
}
const file = await this.host.doc.blobSync.get(item.blobId);
if (!file) {
return {
blobId: item.id,
file: new File([], item.name),
state: 'failed',
tooltip: 'File not found in blob storage',
} as FileChip;
} else { } else {
chip = { return {
file: new File([file], item.name),
blobId: item.blobId,
fileId: item.id, fileId: item.id,
state: item.status === 'finished' ? 'success' : item.status, state: item.status === 'finished' ? 'success' : item.status,
fileName: item.name, tooltip: item.error,
fileType: '', } as FileChip;
};
} }
return chip; })
}); );
this.chatContextValue = { this.chatContextValue = {
...this.chatContextValue, ...this.chatContextValue,
chips, chips,
@@ -489,6 +500,7 @@ export class ChatPanel extends SignalWatcher(
<chat-panel-input <chat-panel-input
.chatContextValue=${this.chatContextValue} .chatContextValue=${this.chatContextValue}
.getSessionId=${this._getSessionId} .getSessionId=${this._getSessionId}
.getContextId=${this._getContextId}
.networkSearchConfig=${this.networkSearchConfig} .networkSearchConfig=${this.networkSearchConfig}
.updateContext=${this.updateContext} .updateContext=${this.updateContext}
.host=${this.host} .host=${this.host}

View File

@@ -7,6 +7,7 @@ import { showAILoginRequiredAtom } from '@affine/core/components/affine/auth/ai-
import type { UserFriendlyError } from '@affine/error'; import type { UserFriendlyError } from '@affine/error';
import { import {
addContextDocMutation, addContextDocMutation,
addContextFileMutation,
cleanupCopilotSessionMutation, cleanupCopilotSessionMutation,
createCopilotContextMutation, createCopilotContextMutation,
createCopilotMessageMutation, createCopilotMessageMutation,
@@ -22,6 +23,7 @@ import {
type QueryOptions, type QueryOptions,
type QueryResponse, type QueryResponse,
removeContextDocMutation, removeContextDocMutation,
removeContextFileMutation,
type RequestOptions, type RequestOptions,
updateCopilotSessionMutation, updateCopilotSessionMutation,
} from '@affine/graphql'; } from '@affine/graphql';
@@ -261,12 +263,30 @@ export class CopilotClient {
return res.removeContextDoc; return res.removeContextDoc;
} }
async addContextFile() { async addContextFile(
return; content: File,
options: OptionsField<typeof addContextFileMutation>
) {
const res = await this.gql({
query: addContextFileMutation,
variables: {
content,
options,
},
});
return res.addContextFile;
} }
async removeContextFile() { async removeContextFile(
return; options: OptionsField<typeof removeContextFileMutation>
) {
const res = await this.gql({
query: removeContextFileMutation,
variables: {
options,
},
});
return res.removeContextFile;
} }
async getContextDocsAndFiles( async getContextDocsAndFiles(

View File

@@ -36,21 +36,12 @@ export function setupAIProvider(
) { ) {
//#region actions //#region actions
AIProvider.provide('chat', options => { AIProvider.provide('chat', options => {
const { input, docs, ...rest } = options; const { input, contexts, ...rest } = options;
const params = docs?.length
? {
docs: docs.map((doc, i) => ({
docId: doc.docId,
markdown: doc.markdown,
index: i + 1,
})),
}
: undefined;
return textToText({ return textToText({
...rest, ...rest,
client, client,
content: input, content: input,
params, params: contexts,
}); });
}); });
@@ -441,11 +432,17 @@ Could you make a new website based on these notes and send back just the html fi
removeContextDoc: async (options: { contextId: string; docId: string }) => { removeContextDoc: async (options: { contextId: string; docId: string }) => {
return client.removeContextDoc(options); return client.removeContextDoc(options);
}, },
addContextFile: async () => { addContextFile: async (
return client.addContextFile(); file: File,
options: { contextId: string; blobId: string }
) => {
return client.addContextFile(file, options);
}, },
removeContextFile: async () => { removeContextFile: async (options: {
return client.removeContextFile(); contextId: string;
fileId: string;
}) => {
return client.removeContextFile(options);
}, },
getContextDocsAndFiles: async ( getContextDocsAndFiles: async (
workspaceId: string, workspaceId: string,
@@ -454,6 +451,13 @@ Could you make a new website based on these notes and send back just the html fi
) => { ) => {
return client.getContextDocsAndFiles(workspaceId, sessionId, contextId); return client.getContextDocsAndFiles(workspaceId, sessionId, contextId);
}, },
matchContext: async (
contextId: string,
content: string,
limit?: number
) => {
return client.matchContext(contextId, content, limit);
},
}); });
AIProvider.provide('histories', { AIProvider.provide('histories', {

View File

@@ -4,6 +4,7 @@ mutation addContextFile($content: Upload!, $options: AddContextFileInput!) {
createdAt createdAt
name name
chunkSize chunkSize
error
status status
blobId blobId
} }

View File

@@ -15,6 +15,7 @@ query listContextFiles(
name name
blobId blobId
chunkSize chunkSize
error
status status
createdAt createdAt
} }

View File

@@ -170,6 +170,7 @@ export const addContextFileMutation = {
createdAt createdAt
name name
chunkSize chunkSize
error
status status
blobId blobId
} }
@@ -193,6 +194,7 @@ export const listContextFilesQuery = {
name name
blobId blobId
chunkSize chunkSize
error
status status
createdAt createdAt
} }

View File

@@ -2355,6 +2355,7 @@ export type AddContextFileMutation = {
createdAt: number; createdAt: number;
name: string; name: string;
chunkSize: number; chunkSize: number;
error: string | null;
status: ContextFileStatus; status: ContextFileStatus;
blobId: string; blobId: string;
}; };
@@ -2385,6 +2386,7 @@ export type ListContextFilesQuery = {
name: string; name: string;
blobId: string; blobId: string;
chunkSize: number; chunkSize: number;
error: string | null;
status: ContextFileStatus; status: ContextFileStatus;
createdAt: number; createdAt: number;
}>; }>;

View File

@@ -1040,7 +1040,7 @@ test.describe('chat with doc', () => {
.nth(0); .nth(0);
await richText.click(); // Ensure proper focus await richText.click(); // Ensure proper focus
await page.keyboard.type( await page.keyboard.type(
'AFFiNE AI is an assistant with the ability to create well-structured outlines for any given content.', 'AFiAI is an assistant with the ability to create well-structured outlines for any given content.',
{ {
delay: 50, delay: 50,
} }
@@ -1050,21 +1050,18 @@ test.describe('chat with doc', () => {
// oxlint-disable-next-line unicorn/prefer-dom-node-dataset // oxlint-disable-next-line unicorn/prefer-dom-node-dataset
expect(await chip.getAttribute('data-state')).toBe('success'); expect(await chip.getAttribute('data-state')).toBe('success');
await typeChatSequentially(page, 'What is AFFiNE AI?'); await typeChatSequentially(page, 'What is AFiAI?');
await page.keyboard.press('Enter'); await page.keyboard.press('Enter');
const history = await collectChat(page); const history = await collectChat(page);
expect(history[0]).toEqual({ expect(history[0]).toEqual({
name: 'You', name: 'You',
content: 'What is AFFiNE AI?', content: 'What is AFiAI?',
}); });
expect(history[1].name).toBe(`AFFiNE AI`); expect(history[1].name).toBe(`AFFiNE AI\nwith your docs`);
expect(
// TODO(@akumatus): not stable await page.locator('chat-panel affine-footnote-node').count()
// expect(history[1].name).toBe(`AFFiNE AI\nwith your docs`); ).toBeGreaterThan(0);
// expect(
// await page.locator('chat-panel affine-footnote-node').count()
// ).toBeGreaterThan(0);
await clearChat(page); await clearChat(page);
expect((await collectChat(page)).length).toBe(0); expect((await collectChat(page)).length).toBe(0);