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:
@@ -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';
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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.)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ mutation addContextFile($content: Upload!, $options: AddContextFileInput!) {
|
|||||||
createdAt
|
createdAt
|
||||||
name
|
name
|
||||||
chunkSize
|
chunkSize
|
||||||
|
error
|
||||||
status
|
status
|
||||||
blobId
|
blobId
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ query listContextFiles(
|
|||||||
name
|
name
|
||||||
blobId
|
blobId
|
||||||
chunkSize
|
chunkSize
|
||||||
|
error
|
||||||
status
|
status
|
||||||
createdAt
|
createdAt
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}>;
|
}>;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user