feat(editor): extract chat runtime (#14937)
#### PR Dependency Tree * **PR #14937** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Centralized AI event system and a runtime powering chat sessions and actions. * **Improvements** * Chat UI (composer, messages, toolbar, tabs, panels) now syncs with runtime snapshots for more consistent state. * Improved session/tab lifecycle (create, fork, delete), context embedding status, and history handling. * More reliable send/stop/retry flows, better telemetry scoping, and clearer upgrade/login/insert-template prompts. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -42,7 +42,9 @@ import type { TemplateResult } from 'lit';
|
|||||||
|
|
||||||
import { insertFromMarkdown } from '../../utils';
|
import { insertFromMarkdown } from '../../utils';
|
||||||
import type { ChatMessage } from '../components/ai-chat-messages';
|
import type { ChatMessage } from '../components/ai-chat-messages';
|
||||||
import { AIProvider, type AIUserInfo } from '../provider';
|
import { AIAppEvents, type AIUserInfo } from '../provider';
|
||||||
|
import { AIChatRuntime, ForkAIChatSessionStrategy } from '../runtime/chat';
|
||||||
|
import { getAIRequestService } from '../runtime/request';
|
||||||
import { reportResponse } from '../utils/action-reporter';
|
import { reportResponse } from '../utils/action-reporter';
|
||||||
import { insertBelow } from '../utils/editor-actions';
|
import { insertBelow } from '../utils/editor-actions';
|
||||||
|
|
||||||
@@ -72,7 +74,7 @@ export async function queryHistoryMessages(
|
|||||||
docId?: string
|
docId?: string
|
||||||
) {
|
) {
|
||||||
// Get fork session messages
|
// Get fork session messages
|
||||||
const histories = await AIProvider.histories?.chats(
|
const histories = await getAIRequestService().histories.chats(
|
||||||
workspaceId,
|
workspaceId,
|
||||||
forkSessionId,
|
forkSessionId,
|
||||||
docId
|
docId
|
||||||
@@ -114,7 +116,7 @@ export async function constructRootChatBlockMessages(
|
|||||||
forkSessionId: string
|
forkSessionId: string
|
||||||
) {
|
) {
|
||||||
// Convert chat messages to AI chat block messages
|
// Convert chat messages to AI chat block messages
|
||||||
const userInfo = await AIProvider.userInfo;
|
const userInfo = AIAppEvents.userInfo.value;
|
||||||
const forkMessages = (await queryHistoryMessages(
|
const forkMessages = (await queryHistoryMessages(
|
||||||
doc.workspace.id,
|
doc.workspace.id,
|
||||||
forkSessionId,
|
forkSessionId,
|
||||||
@@ -247,7 +249,7 @@ async function insertBelowBlock(
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!block) return false;
|
if (!block) return false;
|
||||||
|
|
||||||
reportResponse('result:insert');
|
reportResponse('result:insert', host);
|
||||||
await insertBelow(host, content, block);
|
await insertBelow(host, content, block);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -376,13 +378,20 @@ const SAVE_AS_BLOCK: ChatAction = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const runtime = new AIChatRuntime({
|
||||||
const newSessionId = await AIProvider.forkChat?.({
|
request: getAIRequestService(),
|
||||||
|
scope: {
|
||||||
|
kind: 'fork',
|
||||||
workspaceId: host.store.workspace.id,
|
workspaceId: host.store.workspace.id,
|
||||||
docId: host.store.id,
|
docId: host.store.id,
|
||||||
sessionId: parentSessionId,
|
parentSessionId,
|
||||||
latestMessageId: messageId,
|
latestMessageId: messageId,
|
||||||
|
},
|
||||||
|
strategy: new ForkAIChatSessionStrategy(),
|
||||||
});
|
});
|
||||||
|
try {
|
||||||
|
const newSession = await runtime.createSession();
|
||||||
|
const newSessionId = newSession?.sessionId;
|
||||||
|
|
||||||
if (!newSessionId) {
|
if (!newSessionId) {
|
||||||
return false;
|
return false;
|
||||||
@@ -424,6 +433,8 @@ const SAVE_AS_BLOCK: ChatAction = {
|
|||||||
onClose: function (): void {},
|
onClose: function (): void {},
|
||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
|
} finally {
|
||||||
|
runtime.dispose();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -439,7 +450,7 @@ const ADD_TO_EDGELESS_AS_NOTE = {
|
|||||||
},
|
},
|
||||||
toast: 'New note created',
|
toast: 'New note created',
|
||||||
handler: async (host: EditorHost, content: string): Promise<boolean> => {
|
handler: async (host: EditorHost, content: string): Promise<boolean> => {
|
||||||
reportResponse('result:add-note');
|
reportResponse('result:add-note', host);
|
||||||
const { store } = host;
|
const { store } = host;
|
||||||
|
|
||||||
const gfx = host.std.get(GfxControllerIdentifier);
|
const gfx = host.std.get(GfxControllerIdentifier);
|
||||||
@@ -475,7 +486,7 @@ export const SAVE_AS_DOC = {
|
|||||||
showWhen: () => true,
|
showWhen: () => true,
|
||||||
toast: 'New doc created',
|
toast: 'New doc created',
|
||||||
handler: (host: EditorHost, content: string) => {
|
handler: (host: EditorHost, content: string) => {
|
||||||
reportResponse('result:add-page');
|
reportResponse('result:add-page', host);
|
||||||
const doc = host.store.workspace.createDoc();
|
const doc = host.store.workspace.createDoc();
|
||||||
const newDoc = doc.getStore();
|
const newDoc = doc.getStore();
|
||||||
newDoc.load();
|
newDoc.load();
|
||||||
@@ -518,7 +529,7 @@ const CREATE_AS_LINKED_DOC = {
|
|||||||
},
|
},
|
||||||
toast: 'New doc created',
|
toast: 'New doc created',
|
||||||
handler: async (host: EditorHost, content: string) => {
|
handler: async (host: EditorHost, content: string) => {
|
||||||
reportResponse('result:add-page');
|
reportResponse('result:add-page', host);
|
||||||
|
|
||||||
const { store } = host;
|
const { store } = host;
|
||||||
const surfaceBlock = store
|
const surfaceBlock = store
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import type {
|
|||||||
AIItemGroupConfig,
|
AIItemGroupConfig,
|
||||||
AISubItemConfig,
|
AISubItemConfig,
|
||||||
} from '../components/ai-item/types';
|
} from '../components/ai-item/types';
|
||||||
import { AIProvider } from '../provider';
|
import { AIAppEvents } from '../provider';
|
||||||
import { getAIPanelWidget } from '../utils/ai-widgets';
|
import { getAIPanelWidget } from '../utils/ai-widgets';
|
||||||
import { getEdgelessCopilotWidget } from '../utils/get-edgeless-copilot-widget';
|
import { getEdgelessCopilotWidget } from '../utils/get-edgeless-copilot-widget';
|
||||||
import {
|
import {
|
||||||
@@ -386,7 +386,7 @@ const OthersAIGroup: AIItemGroupConfig = {
|
|||||||
handler: host => {
|
handler: host => {
|
||||||
const panel = getAIPanelWidget(host);
|
const panel = getAIPanelWidget(host);
|
||||||
const edgelessCopilot = getEdgelessCopilotWidget(host);
|
const edgelessCopilot = getEdgelessCopilotWidget(host);
|
||||||
AIProvider.slots.requestOpenWithChat.next({
|
AIAppEvents.requestOpenWithChat.next({
|
||||||
host,
|
host,
|
||||||
autoSelect: true,
|
autoSelect: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
} from '../ai-panel';
|
} from '../ai-panel';
|
||||||
import { StreamObjectSchema } from '../components/ai-chat-messages';
|
import { StreamObjectSchema } from '../components/ai-chat-messages';
|
||||||
import { type AIItemGroupConfig } from '../components/ai-item/types';
|
import { type AIItemGroupConfig } from '../components/ai-item/types';
|
||||||
import { type AIError, AIProvider } from '../provider';
|
import { type AIError } from '../provider';
|
||||||
|
import { getAIRequestService } from '../runtime/request';
|
||||||
import { reportResponse } from '../utils/action-reporter';
|
import { reportResponse } from '../utils/action-reporter';
|
||||||
import { getAIPanelWidget } from '../utils/ai-widgets';
|
import { getAIPanelWidget } from '../utils/ai-widgets';
|
||||||
import { AIContext } from '../utils/context';
|
import { AIContext } from '../utils/context';
|
||||||
@@ -33,10 +34,12 @@ export function bindTextStream(
|
|||||||
update,
|
update,
|
||||||
finish,
|
finish,
|
||||||
signal,
|
signal,
|
||||||
|
host,
|
||||||
}: {
|
}: {
|
||||||
update: (answer: AIActionAnswer) => void;
|
update: (answer: AIActionAnswer) => void;
|
||||||
finish: (state: 'success' | 'error' | 'aborted', err?: AIError) => void;
|
finish: (state: 'success' | 'error' | 'aborted', err?: AIError) => void;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
|
host?: EditorHost;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -45,7 +48,7 @@ export function bindTextStream(
|
|||||||
};
|
};
|
||||||
signal?.addEventListener('abort', () => {
|
signal?.addEventListener('abort', () => {
|
||||||
finish('aborted');
|
finish('aborted');
|
||||||
reportResponse('aborted:stop');
|
reportResponse('aborted:stop', host);
|
||||||
});
|
});
|
||||||
for await (const data of stream) {
|
for await (const data of stream) {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
@@ -88,9 +91,6 @@ function actionToStream<T extends keyof BlockSuitePresets.AIActions>(
|
|||||||
>,
|
>,
|
||||||
trackerOptions?: BlockSuitePresets.TrackerOptions
|
trackerOptions?: BlockSuitePresets.TrackerOptions
|
||||||
): BlockSuitePresets.TextStream | undefined {
|
): BlockSuitePresets.TextStream | undefined {
|
||||||
const action = AIProvider.actions[id];
|
|
||||||
if (!action || typeof action !== 'function') return;
|
|
||||||
|
|
||||||
let stream: BlockSuitePresets.TextStream | undefined;
|
let stream: BlockSuitePresets.TextStream | undefined;
|
||||||
return {
|
return {
|
||||||
async *[Symbol.asyncIterator]() {
|
async *[Symbol.asyncIterator]() {
|
||||||
@@ -123,9 +123,11 @@ function actionToStream<T extends keyof BlockSuitePresets.AIActions>(
|
|||||||
where,
|
where,
|
||||||
docId: host.store.id,
|
docId: host.store.id,
|
||||||
workspaceId: host.store.workspace.id,
|
workspaceId: host.store.workspace.id,
|
||||||
} as Parameters<typeof action>[0];
|
} as BlockSuitePresets.AITextActionOptions & Record<string, unknown>;
|
||||||
// @ts-expect-error TODO(@Peng): maybe fix this
|
stream = (await getAIRequestService().executeAction(
|
||||||
stream = await action(options);
|
id,
|
||||||
|
options
|
||||||
|
)) as BlockSuitePresets.TextStream;
|
||||||
if (!stream) return;
|
if (!stream) return;
|
||||||
yield* stream;
|
yield* stream;
|
||||||
},
|
},
|
||||||
@@ -163,7 +165,7 @@ function actionToGenerateAnswer<T extends keyof BlockSuitePresets.AIActions>(
|
|||||||
trackerOptions
|
trackerOptions
|
||||||
);
|
);
|
||||||
if (!stream) return;
|
if (!stream) return;
|
||||||
bindTextStream(stream, { update, finish, signal });
|
bindTextStream(stream, { update, finish, signal, host });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +200,7 @@ function updateAIPanelConfig<T extends keyof BlockSuitePresets.AIActions>(
|
|||||||
config.errorStateConfig = buildErrorConfig(aiPanel);
|
config.errorStateConfig = buildErrorConfig(aiPanel);
|
||||||
config.copy = buildCopyConfig(aiPanel);
|
config.copy = buildCopyConfig(aiPanel);
|
||||||
config.discardCallback = () => {
|
config.discardCallback = () => {
|
||||||
reportResponse('result:discard');
|
reportResponse('result:discard', host);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ import type { TemplateResult } from 'lit';
|
|||||||
|
|
||||||
import { getContentFromSlice } from '../../utils';
|
import { getContentFromSlice } from '../../utils';
|
||||||
import { AIChatBlockModel } from '../blocks';
|
import { AIChatBlockModel } from '../blocks';
|
||||||
import { type AIError, AIProvider } from '../provider';
|
import { type AIError } from '../provider';
|
||||||
|
import { getAIRequestService } from '../runtime/request';
|
||||||
import { reportResponse } from '../utils/action-reporter';
|
import { reportResponse } from '../utils/action-reporter';
|
||||||
import { getAIPanelWidget } from '../utils/ai-widgets';
|
import { getAIPanelWidget } from '../utils/ai-widgets';
|
||||||
import { AIContext } from '../utils/context';
|
import { AIContext } from '../utils/context';
|
||||||
@@ -178,10 +179,6 @@ function actionToStream<T extends keyof BlockSuitePresets.AIActions>(
|
|||||||
trackerOptions?: BlockSuitePresets.TrackerOptions,
|
trackerOptions?: BlockSuitePresets.TrackerOptions,
|
||||||
panelInput?: string
|
panelInput?: string
|
||||||
) {
|
) {
|
||||||
const action = AIProvider.actions[id];
|
|
||||||
|
|
||||||
if (!action || typeof action !== 'function') return;
|
|
||||||
|
|
||||||
if (extract && typeof extract === 'function') {
|
if (extract && typeof extract === 'function') {
|
||||||
return (host: EditorHost, ctx: AIContext): BlockSuitePresets.TextStream => {
|
return (host: EditorHost, ctx: AIContext): BlockSuitePresets.TextStream => {
|
||||||
let stream: BlockSuitePresets.TextStream | undefined;
|
let stream: BlockSuitePresets.TextStream | undefined;
|
||||||
@@ -201,7 +198,7 @@ function actionToStream<T extends keyof BlockSuitePresets.AIActions>(
|
|||||||
host,
|
host,
|
||||||
docId: host.store.id,
|
docId: host.store.id,
|
||||||
workspaceId: host.store.workspace.id,
|
workspaceId: host.store.workspace.id,
|
||||||
} as Parameters<typeof action>[0];
|
} as BlockSuitePresets.AITextActionOptions & Record<string, unknown>;
|
||||||
|
|
||||||
const content = ctx.get().content;
|
const content = ctx.get().content;
|
||||||
if (typeof content === 'string' && !content.length && panelInput) {
|
if (typeof content === 'string' && !content.length && panelInput) {
|
||||||
@@ -214,8 +211,10 @@ function actionToStream<T extends keyof BlockSuitePresets.AIActions>(
|
|||||||
Object.assign(options, data);
|
Object.assign(options, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-expect-error TODO(@Peng): maybe fix this
|
stream = (await getAIRequestService().executeAction(
|
||||||
stream = await action(options);
|
id,
|
||||||
|
options
|
||||||
|
)) as BlockSuitePresets.TextStream;
|
||||||
if (!stream) return;
|
if (!stream) return;
|
||||||
yield* stream;
|
yield* stream;
|
||||||
},
|
},
|
||||||
@@ -242,10 +241,12 @@ function actionToStream<T extends keyof BlockSuitePresets.AIActions>(
|
|||||||
host,
|
host,
|
||||||
docId: host.store.id,
|
docId: host.store.id,
|
||||||
workspaceId: host.store.workspace.id,
|
workspaceId: host.store.workspace.id,
|
||||||
} as Parameters<typeof action>[0];
|
} as BlockSuitePresets.AITextActionOptions & Record<string, unknown>;
|
||||||
|
|
||||||
// @ts-expect-error TODO(@Peng): maybe fix this
|
stream = (await getAIRequestService().executeAction(
|
||||||
stream = await action(options);
|
id,
|
||||||
|
options
|
||||||
|
)) as BlockSuitePresets.TextStream;
|
||||||
if (!stream) return;
|
if (!stream) return;
|
||||||
yield* stream;
|
yield* stream;
|
||||||
},
|
},
|
||||||
@@ -351,7 +352,7 @@ function updateEdgelessAIPanelConfig<
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
config.discardCallback = () => {
|
config.discardCallback = () => {
|
||||||
reportResponse('result:discard');
|
reportResponse('result:discard', host);
|
||||||
};
|
};
|
||||||
config.hideCallback = () => {
|
config.hideCallback = () => {
|
||||||
aiPanel.updateComplete
|
aiPanel.updateComplete
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { styleMap } from 'lit/directives/style-map.js';
|
|||||||
import { insertFromMarkdown } from '../../utils';
|
import { insertFromMarkdown } from '../../utils';
|
||||||
import type { ChatContextValue } from '../components/ai-chat-content/type';
|
import type { ChatContextValue } from '../components/ai-chat-content/type';
|
||||||
import type { AIItemConfig } from '../components/ai-item/types';
|
import type { AIItemConfig } from '../components/ai-item/types';
|
||||||
import { AIProvider } from '../provider';
|
import { AIAppEvents } from '../provider';
|
||||||
import { reportResponse } from '../utils/action-reporter';
|
import { reportResponse } from '../utils/action-reporter';
|
||||||
import { getAIPanelWidget } from '../utils/ai-widgets';
|
import { getAIPanelWidget } from '../utils/ai-widgets';
|
||||||
import type { AIContext } from '../utils/context';
|
import type { AIContext } from '../utils/context';
|
||||||
@@ -98,7 +98,7 @@ export function retry(panel: AffineAIPanelWidget): AIItemConfig {
|
|||||||
icon: ResetIcon(),
|
icon: ResetIcon(),
|
||||||
testId: 'answer-retry',
|
testId: 'answer-retry',
|
||||||
handler: () => {
|
handler: () => {
|
||||||
reportResponse('result:retry');
|
reportResponse('result:retry', panel.host);
|
||||||
panel.generate();
|
panel.generate();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -152,7 +152,7 @@ export function createInsertItems<T extends keyof BlockSuitePresets.AIActions>(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
handler: () => {
|
handler: () => {
|
||||||
reportResponse('result:insert');
|
reportResponse('result:insert', host);
|
||||||
edgelessResponseHandler(id, host, ctx).catch(console.error);
|
edgelessResponseHandler(id, host, ctx).catch(console.error);
|
||||||
const panel = getAIPanelWidget(host);
|
const panel = getAIPanelWidget(host);
|
||||||
panel.hide();
|
panel.hide();
|
||||||
@@ -201,7 +201,7 @@ export function asCaption<T extends keyof BlockSuitePresets.AIActions>(
|
|||||||
return id === 'generateCaption' && !!panel.answer;
|
return id === 'generateCaption' && !!panel.answer;
|
||||||
},
|
},
|
||||||
handler: () => {
|
handler: () => {
|
||||||
reportResponse('result:use-as-caption');
|
reportResponse('result:use-as-caption', host);
|
||||||
const panel = getAIPanelWidget(host);
|
const panel = getAIPanelWidget(host);
|
||||||
const caption = panel.answer;
|
const caption = panel.answer;
|
||||||
if (!caption) return;
|
if (!caption) return;
|
||||||
@@ -589,7 +589,7 @@ export function actionToResponse<T extends keyof BlockSuitePresets.AIActions>(
|
|||||||
testId: 'answer-continue-in-chat',
|
testId: 'answer-continue-in-chat',
|
||||||
icon: ChatWithAiIcon({}),
|
icon: ChatWithAiIcon({}),
|
||||||
handler: () => {
|
handler: () => {
|
||||||
reportResponse('result:continue-in-chat');
|
reportResponse('result:continue-in-chat', host);
|
||||||
edgelesContinueResponseHandler(id, host, ctx).catch(
|
edgelesContinueResponseHandler(id, host, ctx).catch(
|
||||||
console.error
|
console.error
|
||||||
);
|
);
|
||||||
@@ -700,7 +700,7 @@ async function edgelesContinueResponseHandler<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const panel = getAIPanelWidget(host);
|
const panel = getAIPanelWidget(host);
|
||||||
AIProvider.slots.requestOpenWithChat.next({
|
AIAppEvents.requestOpenWithChat.next({
|
||||||
host,
|
host,
|
||||||
context,
|
context,
|
||||||
fromAnswer: true,
|
fromAnswer: true,
|
||||||
@@ -732,11 +732,11 @@ export function actionToErrorResponse<
|
|||||||
): ErrorConfig {
|
): ErrorConfig {
|
||||||
return {
|
return {
|
||||||
upgrade: () => {
|
upgrade: () => {
|
||||||
AIProvider.slots.requestUpgradePlan.next({ host: panel.host });
|
AIAppEvents.requestUpgradePlan.next({ host: panel.host });
|
||||||
panel.hide();
|
panel.hide();
|
||||||
},
|
},
|
||||||
login: () => {
|
login: () => {
|
||||||
AIProvider.slots.requestLogin.next({ host: panel.host });
|
AIAppEvents.requestLogin.next({ host: panel.host });
|
||||||
panel.hide();
|
panel.hide();
|
||||||
},
|
},
|
||||||
cancel: () => {
|
cancel: () => {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
} from './actions/page-response';
|
} from './actions/page-response';
|
||||||
import type { AIItemConfig } from './components/ai-item/types';
|
import type { AIItemConfig } from './components/ai-item/types';
|
||||||
import { createAIScrollableTextRenderer } from './components/ai-scrollable-text-renderer';
|
import { createAIScrollableTextRenderer } from './components/ai-scrollable-text-renderer';
|
||||||
import { AIProvider } from './provider';
|
import { AIAppEvents } from './provider';
|
||||||
import { reportResponse } from './utils/action-reporter';
|
import { reportResponse } from './utils/action-reporter';
|
||||||
import { getAIPanelWidget } from './utils/ai-widgets';
|
import { getAIPanelWidget } from './utils/ai-widgets';
|
||||||
import { AIContext } from './utils/context';
|
import { AIContext } from './utils/context';
|
||||||
@@ -58,7 +58,7 @@ function asCaption<T extends keyof BlockSuitePresets.AIActions>(
|
|||||||
return id === 'generateCaption' && !!panel.answer;
|
return id === 'generateCaption' && !!panel.answer;
|
||||||
},
|
},
|
||||||
handler: () => {
|
handler: () => {
|
||||||
reportResponse('result:use-as-caption');
|
reportResponse('result:use-as-caption', host);
|
||||||
const panel = getAIPanelWidget(host);
|
const panel = getAIPanelWidget(host);
|
||||||
const caption = panel.answer;
|
const caption = panel.answer;
|
||||||
if (!caption) return;
|
if (!caption) return;
|
||||||
@@ -85,7 +85,7 @@ function createNewNote(host: EditorHost): AIItemConfig {
|
|||||||
return !!panel.answer && isInsideEdgelessEditor(host);
|
return !!panel.answer && isInsideEdgelessEditor(host);
|
||||||
},
|
},
|
||||||
handler: () => {
|
handler: () => {
|
||||||
reportResponse('result:add-note');
|
reportResponse('result:add-note', host);
|
||||||
// get the note block
|
// get the note block
|
||||||
const { selectedBlocks } = getSelections(host);
|
const { selectedBlocks } = getSelections(host);
|
||||||
if (!selectedBlocks || !selectedBlocks.length) return;
|
if (!selectedBlocks || !selectedBlocks.length) return;
|
||||||
@@ -157,7 +157,7 @@ function buildPageResponseConfig<T extends keyof BlockSuitePresets.AIActions>(
|
|||||||
showWhen: () =>
|
showWhen: () =>
|
||||||
!!panel.answer && (!id || !INSERT_ABOVE_ACTIONS.includes(id)),
|
!!panel.answer && (!id || !INSERT_ABOVE_ACTIONS.includes(id)),
|
||||||
handler: () => {
|
handler: () => {
|
||||||
reportResponse('result:insert');
|
reportResponse('result:insert', host);
|
||||||
pageResponseHandler(id, host, ctx, 'after').catch(console.error);
|
pageResponseHandler(id, host, ctx, 'after').catch(console.error);
|
||||||
panel.hide();
|
panel.hide();
|
||||||
},
|
},
|
||||||
@@ -169,7 +169,7 @@ function buildPageResponseConfig<T extends keyof BlockSuitePresets.AIActions>(
|
|||||||
showWhen: () =>
|
showWhen: () =>
|
||||||
!!panel.answer && !!id && INSERT_ABOVE_ACTIONS.includes(id),
|
!!panel.answer && !!id && INSERT_ABOVE_ACTIONS.includes(id),
|
||||||
handler: () => {
|
handler: () => {
|
||||||
reportResponse('result:insert');
|
reportResponse('result:insert', host);
|
||||||
pageResponseHandler(id, host, ctx, 'before').catch(console.error);
|
pageResponseHandler(id, host, ctx, 'before').catch(console.error);
|
||||||
panel.hide();
|
panel.hide();
|
||||||
},
|
},
|
||||||
@@ -182,7 +182,7 @@ function buildPageResponseConfig<T extends keyof BlockSuitePresets.AIActions>(
|
|||||||
showWhen: () =>
|
showWhen: () =>
|
||||||
!!panel.answer && !EXCLUDING_REPLACE_ACTIONS.includes(id),
|
!!panel.answer && !EXCLUDING_REPLACE_ACTIONS.includes(id),
|
||||||
handler: () => {
|
handler: () => {
|
||||||
reportResponse('result:replace');
|
reportResponse('result:replace', host);
|
||||||
replaceWithMarkdown(host).catch(console.error);
|
replaceWithMarkdown(host).catch(console.error);
|
||||||
panel.hide();
|
panel.hide();
|
||||||
},
|
},
|
||||||
@@ -199,8 +199,8 @@ function buildPageResponseConfig<T extends keyof BlockSuitePresets.AIActions>(
|
|||||||
icon: ChatWithAiIcon(),
|
icon: ChatWithAiIcon(),
|
||||||
testId: 'answer-continue-in-chat',
|
testId: 'answer-continue-in-chat',
|
||||||
handler: () => {
|
handler: () => {
|
||||||
reportResponse('result:continue-in-chat');
|
reportResponse('result:continue-in-chat', host);
|
||||||
AIProvider.slots.requestOpenWithChat.next({ host });
|
AIAppEvents.requestOpenWithChat.next({ host });
|
||||||
panel.hide();
|
panel.hide();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -209,7 +209,7 @@ function buildPageResponseConfig<T extends keyof BlockSuitePresets.AIActions>(
|
|||||||
icon: ResetIcon(),
|
icon: ResetIcon(),
|
||||||
testId: 'answer-regenerate',
|
testId: 'answer-regenerate',
|
||||||
handler: () => {
|
handler: () => {
|
||||||
reportResponse('result:retry');
|
reportResponse('result:retry', host);
|
||||||
panel.generate();
|
panel.generate();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -237,7 +237,7 @@ export function buildErrorResponseConfig(panel: AffineAIPanelWidget) {
|
|||||||
testId: 'error-retry',
|
testId: 'error-retry',
|
||||||
showWhen: () => true,
|
showWhen: () => true,
|
||||||
handler: () => {
|
handler: () => {
|
||||||
reportResponse('result:retry');
|
reportResponse('result:retry', panel.host);
|
||||||
panel.generate();
|
panel.generate();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -269,11 +269,11 @@ export function buildFinishConfig<T extends keyof BlockSuitePresets.AIActions>(
|
|||||||
export function buildErrorConfig(panel: AffineAIPanelWidget) {
|
export function buildErrorConfig(panel: AffineAIPanelWidget) {
|
||||||
return {
|
return {
|
||||||
upgrade: () => {
|
upgrade: () => {
|
||||||
AIProvider.slots.requestUpgradePlan.next({ host: panel.host });
|
AIAppEvents.requestUpgradePlan.next({ host: panel.host });
|
||||||
panel.hide();
|
panel.hide();
|
||||||
},
|
},
|
||||||
login: () => {
|
login: () => {
|
||||||
AIProvider.slots.requestLogin.next({ host: panel.host });
|
AIAppEvents.requestLogin.next({ host: panel.host });
|
||||||
panel.hide();
|
panel.hide();
|
||||||
},
|
},
|
||||||
cancel: () => {
|
cancel: () => {
|
||||||
|
|||||||
@@ -10,14 +10,7 @@ import type {
|
|||||||
SubscriptionService,
|
SubscriptionService,
|
||||||
} from '@affine/core/modules/cloud';
|
} from '@affine/core/modules/cloud';
|
||||||
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||||
import type {
|
import type { CopilotChatHistoryFragment } from '@affine/graphql';
|
||||||
ContextEmbedStatus,
|
|
||||||
ContextWorkspaceEmbeddingStatus,
|
|
||||||
CopilotChatHistoryFragment,
|
|
||||||
CopilotContextBlob,
|
|
||||||
CopilotContextDoc,
|
|
||||||
CopilotContextFile,
|
|
||||||
} from '@affine/graphql';
|
|
||||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||||
import type { EditorHost } from '@blocksuite/affine/std';
|
import type { EditorHost } from '@blocksuite/affine/std';
|
||||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||||
@@ -30,14 +23,16 @@ import { css, html, type PropertyValues } from 'lit';
|
|||||||
import { property, state } from 'lit/decorators.js';
|
import { property, state } from 'lit/decorators.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AIAppEvents,
|
||||||
type AIChatParams,
|
type AIChatParams,
|
||||||
AIProvider,
|
|
||||||
type AISendParams,
|
type AISendParams,
|
||||||
} from '../../provider';
|
} from '../../provider';
|
||||||
|
import type { AIChatRuntime, AIChatSnapshot } from '../../runtime/chat';
|
||||||
import type { SearchMenuConfig } from '../ai-chat-add-context';
|
import type { SearchMenuConfig } from '../ai-chat-add-context';
|
||||||
import type {
|
import type {
|
||||||
AttachmentChip,
|
AttachmentChip,
|
||||||
ChatChip,
|
ChatChip,
|
||||||
|
ChipState,
|
||||||
CollectionChip,
|
CollectionChip,
|
||||||
DocChip,
|
DocChip,
|
||||||
DocDisplayConfig,
|
DocDisplayConfig,
|
||||||
@@ -58,8 +53,6 @@ import {
|
|||||||
import type { AIChatInputContext, AIReasoningConfig } from '../ai-chat-input';
|
import type { AIChatInputContext, AIReasoningConfig } from '../ai-chat-input';
|
||||||
import { MAX_IMAGE_COUNT } from '../ai-chat-input/const';
|
import { MAX_IMAGE_COUNT } from '../ai-chat-input/const';
|
||||||
|
|
||||||
export const EMBEDDING_STATUS_CHECK_INTERVAL = 10000;
|
|
||||||
|
|
||||||
export class AIChatComposer extends SignalWatcher(
|
export class AIChatComposer extends SignalWatcher(
|
||||||
WithDisposable(ShadowlessElement)
|
WithDisposable(ShadowlessElement)
|
||||||
) {
|
) {
|
||||||
@@ -91,9 +84,10 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
accessor session!: CopilotChatHistoryFragment | null | undefined;
|
accessor session!: CopilotChatHistoryFragment | null | undefined;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor createSession!: () => Promise<
|
accessor runtime: AIChatRuntime | null | undefined;
|
||||||
CopilotChatHistoryFragment | undefined
|
|
||||||
>;
|
@property({ attribute: false })
|
||||||
|
accessor runtimeSnapshot: AIChatSnapshot | null | undefined;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor chatContextValue!: AIChatInputContext;
|
accessor chatContextValue!: AIChatInputContext;
|
||||||
@@ -101,11 +95,6 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor updateContext!: (context: Partial<AIChatInputContext>) => void;
|
accessor updateContext!: (context: Partial<AIChatInputContext>) => void;
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor onEmbeddingProgressChange:
|
|
||||||
| ((count: Record<ContextEmbedStatus, number>) => void)
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor docDisplayConfig!: DocDisplayConfig;
|
accessor docDisplayConfig!: DocDisplayConfig;
|
||||||
|
|
||||||
@@ -160,12 +149,6 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
@state()
|
@state()
|
||||||
accessor embeddingCompleted = false;
|
accessor embeddingCompleted = false;
|
||||||
|
|
||||||
private _contextId: string | undefined = undefined;
|
|
||||||
|
|
||||||
private _pollAbortController: AbortController | null = null;
|
|
||||||
|
|
||||||
private _pollEmbeddingStatusAbortController: AbortController | null = null;
|
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
return html`
|
return html`
|
||||||
<chat-panel-chips
|
<chat-panel-chips
|
||||||
@@ -186,10 +169,11 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
.workspaceId=${this.workspaceId}
|
.workspaceId=${this.workspaceId}
|
||||||
.docId=${this.docId}
|
.docId=${this.docId}
|
||||||
.session=${this.session}
|
.session=${this.session}
|
||||||
|
.runtime=${this.runtime}
|
||||||
|
.runtimeSnapshot=${this.runtimeSnapshot}
|
||||||
.chips=${this.chips}
|
.chips=${this.chips}
|
||||||
.addChip=${this.addChip}
|
.addChip=${this.addChip}
|
||||||
.addImages=${this.addImages}
|
.addImages=${this.addImages}
|
||||||
.createSession=${this.createSession}
|
|
||||||
.chatContextValue=${this.chatContextValue}
|
.chatContextValue=${this.chatContextValue}
|
||||||
.updateContext=${this.updateContext}
|
.updateContext=${this.updateContext}
|
||||||
.reasoningConfig=${this.reasoningConfig}
|
.reasoningConfig=${this.reasoningConfig}
|
||||||
@@ -222,31 +206,40 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
AIProvider.slots.requestOpenWithChat.subscribe(this.beforeChatContextSend)
|
AIAppEvents.requestOpenWithChat.subscribe(this.beforeChatContextSend)
|
||||||
);
|
);
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
AIProvider.slots.requestSendWithChat.subscribe(this.beforeChatContextSend)
|
AIAppEvents.requestSendWithChat.subscribe(this.beforeChatContextSend)
|
||||||
);
|
);
|
||||||
this.initComposer().catch(console.error);
|
this.initComposer().catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
this._abortPoll();
|
this.runtime?.dispatch({ type: 'stopContextPolling' }).catch(console.error);
|
||||||
this._abortPollEmbeddingStatus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override willUpdate(changedProperties: PropertyValues): void {
|
protected override willUpdate(changedProperties: PropertyValues): void {
|
||||||
|
const previousSnapshot = changedProperties.get('runtimeSnapshot') as
|
||||||
|
| AIChatSnapshot
|
||||||
|
| null
|
||||||
|
| undefined;
|
||||||
if (
|
if (
|
||||||
changedProperties.has('chatContextValue') &&
|
changedProperties.has('runtimeSnapshot') &&
|
||||||
changedProperties.get('chatContextValue')?.status !== 'loading' &&
|
previousSnapshot?.status !== 'loading' &&
|
||||||
this.chatContextValue.status === 'loading' &&
|
this.runtimeSnapshot?.status === 'loading' &&
|
||||||
this.isChipsCollapsed === false
|
this.isChipsCollapsed === false
|
||||||
) {
|
) {
|
||||||
this.isChipsCollapsed = true;
|
this.isChipsCollapsed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override updated(changedProperties: PropertyValues): void {
|
||||||
|
if (changedProperties.has('runtimeSnapshot')) {
|
||||||
|
this.syncChipsFromRuntimeSnapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private readonly beforeChatContextSend = (
|
private readonly beforeChatContextSend = (
|
||||||
params: AISendParams | AIChatParams | null
|
params: AISendParams | AIChatParams | null
|
||||||
) => {
|
) => {
|
||||||
@@ -276,100 +269,115 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
return this.chips.some(chip => chip.state === 'processing');
|
return this.chips.some(chip => chip.state === 'processing');
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly _getContextId = async () => {
|
private readonly toChipState = (state?: string): ChipState => {
|
||||||
if (this._contextId) {
|
if (state === 'finished' || state === 'processing' || state === 'failed') {
|
||||||
return this._contextId;
|
return state;
|
||||||
}
|
}
|
||||||
|
return 'processing';
|
||||||
const sessionId = this.session?.sessionId;
|
|
||||||
if (!sessionId) return;
|
|
||||||
|
|
||||||
const contextId = await AIProvider.context?.getContextId(
|
|
||||||
this.workspaceId,
|
|
||||||
sessionId
|
|
||||||
);
|
|
||||||
this._contextId = contextId;
|
|
||||||
return this._contextId;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly createContextId = async () => {
|
private readonly runtimeItemToChip = (
|
||||||
if (this._contextId) {
|
item: AIChatSnapshot['composer']['context']['items'][number]
|
||||||
return this._contextId;
|
): ChatChip => {
|
||||||
|
switch (item.kind) {
|
||||||
|
case 'doc':
|
||||||
|
return {
|
||||||
|
docId: item.docId,
|
||||||
|
state: this.toChipState(item.state),
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
tooltip: item.tooltip,
|
||||||
|
};
|
||||||
|
case 'file':
|
||||||
|
return {
|
||||||
|
file: item.file,
|
||||||
|
fileId: item.fileId,
|
||||||
|
blobId: item.blobId,
|
||||||
|
state: this.toChipState(item.state),
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
tooltip: item.tooltip,
|
||||||
|
};
|
||||||
|
case 'tag':
|
||||||
|
return {
|
||||||
|
tagId: item.tagId,
|
||||||
|
state: this.toChipState(item.state),
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
tooltip: item.tooltip,
|
||||||
|
};
|
||||||
|
case 'collection':
|
||||||
|
return {
|
||||||
|
collectionId: item.collectionId,
|
||||||
|
state: this.toChipState(item.state),
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
tooltip: item.tooltip,
|
||||||
|
};
|
||||||
|
case 'blob':
|
||||||
|
return {
|
||||||
|
sourceId: item.blobId,
|
||||||
|
name: item.blobId,
|
||||||
|
state: this.toChipState(item.state),
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
tooltip: item.tooltip,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const sessionId = (await this.createSession())?.sessionId;
|
private readonly syncChipsFromRuntimeSnapshot = (
|
||||||
if (!sessionId) return;
|
snapshot = this.runtimeSnapshot
|
||||||
|
) => {
|
||||||
|
const context = snapshot?.composer.context;
|
||||||
|
if (!context) return;
|
||||||
|
this.embeddingCompleted = context.embeddingCompleted;
|
||||||
|
const selectedChips = this.chips.filter(isSelectedContextChip);
|
||||||
|
this.updateChips([
|
||||||
|
...context.items.map(this.runtimeItemToChip),
|
||||||
|
...selectedChips,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
this._contextId = await AIProvider.context?.createContext(
|
private readonly syncChipsFromRuntime = () => {
|
||||||
this.workspaceId,
|
this.syncChipsFromRuntimeSnapshot(this.runtime?.getSnapshot());
|
||||||
sessionId
|
};
|
||||||
);
|
|
||||||
return this._contextId;
|
private readonly chipToContextItem = (
|
||||||
|
chip: ChatChip
|
||||||
|
): AIChatSnapshot['composer']['context']['items'][number] | null => {
|
||||||
|
if (isDocChip(chip)) {
|
||||||
|
return { kind: 'doc', docId: chip.docId, state: chip.state };
|
||||||
|
}
|
||||||
|
if (isFileChip(chip)) {
|
||||||
|
return {
|
||||||
|
kind: 'file',
|
||||||
|
file: chip.file,
|
||||||
|
fileId: chip.fileId ?? undefined,
|
||||||
|
blobId: chip.blobId ?? undefined,
|
||||||
|
state: chip.state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (isTagChip(chip)) {
|
||||||
|
return {
|
||||||
|
kind: 'tag',
|
||||||
|
tagId: chip.tagId,
|
||||||
|
docIds: this.docDisplayConfig.getTagPageIds(chip.tagId),
|
||||||
|
state: chip.state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (isCollectionChip(chip)) {
|
||||||
|
return {
|
||||||
|
kind: 'collection',
|
||||||
|
collectionId: chip.collectionId,
|
||||||
|
docIds: this.docDisplayConfig.getCollectionPageIds(chip.collectionId),
|
||||||
|
state: chip.state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (isAttachmentChip(chip)) {
|
||||||
|
return { kind: 'blob', blobId: chip.sourceId, state: chip.state };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly initChips = async () => {
|
private readonly initChips = async () => {
|
||||||
// context not initialized
|
await this.runtime?.dispatch({ type: 'loadContext' });
|
||||||
const sessionId = this.session?.sessionId;
|
this.syncChipsFromRuntime();
|
||||||
const contextId = await this._getContextId();
|
|
||||||
if (!sessionId || !contextId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// context initialized, show the chips
|
|
||||||
const {
|
|
||||||
docs = [],
|
|
||||||
files = [],
|
|
||||||
tags = [],
|
|
||||||
collections = [],
|
|
||||||
} = (await AIProvider.context?.getContextDocsAndFiles(
|
|
||||||
this.workspaceId,
|
|
||||||
sessionId,
|
|
||||||
contextId
|
|
||||||
)) || {};
|
|
||||||
|
|
||||||
const docChips: DocChip[] = docs.map(doc => ({
|
|
||||||
docId: doc.id,
|
|
||||||
state: doc.status || 'processing',
|
|
||||||
createdAt: doc.createdAt,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const fileChips: FileChip[] = await Promise.all(
|
|
||||||
files.map(async file => {
|
|
||||||
return {
|
|
||||||
file: new File([], file.name),
|
|
||||||
blobId: file.blobId,
|
|
||||||
fileId: file.id,
|
|
||||||
state: file.status,
|
|
||||||
tooltip: file.error,
|
|
||||||
createdAt: file.createdAt,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const tagChips: TagChip[] = tags.map(tag => ({
|
|
||||||
tagId: tag.id,
|
|
||||||
state: 'finished',
|
|
||||||
createdAt: tag.createdAt,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const collectionChips: CollectionChip[] = collections.map(collection => ({
|
|
||||||
collectionId: collection.id,
|
|
||||||
state: 'finished',
|
|
||||||
createdAt: collection.createdAt,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const chips: ChatChip[] = [
|
|
||||||
...docChips,
|
|
||||||
...fileChips,
|
|
||||||
...tagChips,
|
|
||||||
...collectionChips,
|
|
||||||
].sort((a, b) => {
|
|
||||||
const aTime = a.createdAt ?? Date.now();
|
|
||||||
const bTime = b.createdAt ?? Date.now();
|
|
||||||
return aTime - bTime;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.updateChips(chips);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly updateChips = (chips: ChatChip[]) => {
|
private readonly updateChips = (chips: ChatChip[]) => {
|
||||||
@@ -487,14 +495,11 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
|
|
||||||
private readonly addDocToContext = async (chip: DocChip) => {
|
private readonly addDocToContext = async (chip: DocChip) => {
|
||||||
try {
|
try {
|
||||||
const contextId = await this.createContextId();
|
await this.runtime?.dispatch({
|
||||||
if (!contextId || !AIProvider.context) {
|
type: 'addContextItem',
|
||||||
throw new Error('Context not found');
|
item: { kind: 'doc', docId: chip.docId, state: chip.state },
|
||||||
}
|
|
||||||
await AIProvider.context.addContextDoc({
|
|
||||||
contextId,
|
|
||||||
docId: chip.docId,
|
|
||||||
});
|
});
|
||||||
|
this.syncChipsFromRuntime();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.updateChip(chip, {
|
this.updateChip(chip, {
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
@@ -505,18 +510,17 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
|
|
||||||
private readonly addFileToContext = async (chip: FileChip) => {
|
private readonly addFileToContext = async (chip: FileChip) => {
|
||||||
try {
|
try {
|
||||||
const contextId = await this.createContextId();
|
await this.runtime?.dispatch({
|
||||||
if (!contextId || !AIProvider.context) {
|
type: 'addContextItem',
|
||||||
throw new Error('Context not found');
|
item: {
|
||||||
}
|
kind: 'file',
|
||||||
const contextFile = await AIProvider.context.addContextFile(chip.file, {
|
file: chip.file,
|
||||||
contextId,
|
fileId: chip.fileId ?? undefined,
|
||||||
});
|
blobId: chip.blobId ?? undefined,
|
||||||
this.updateChip(chip, {
|
state: chip.state,
|
||||||
state: contextFile.status,
|
},
|
||||||
blobId: contextFile.blobId,
|
|
||||||
fileId: contextFile.id,
|
|
||||||
});
|
});
|
||||||
|
this.syncChipsFromRuntime();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.updateChip(chip, {
|
this.updateChip(chip, {
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
@@ -527,20 +531,16 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
|
|
||||||
private readonly addTagToContext = async (chip: TagChip) => {
|
private readonly addTagToContext = async (chip: TagChip) => {
|
||||||
try {
|
try {
|
||||||
const contextId = await this.createContextId();
|
await this.runtime?.dispatch({
|
||||||
if (!contextId || !AIProvider.context) {
|
type: 'addContextItem',
|
||||||
throw new Error('Context not found');
|
item: {
|
||||||
}
|
kind: 'tag',
|
||||||
// TODO: server side docIds calculation
|
|
||||||
const docIds = this.docDisplayConfig.getTagPageIds(chip.tagId);
|
|
||||||
await AIProvider.context.addContextTag({
|
|
||||||
contextId,
|
|
||||||
tagId: chip.tagId,
|
tagId: chip.tagId,
|
||||||
docIds,
|
docIds: this.docDisplayConfig.getTagPageIds(chip.tagId),
|
||||||
});
|
state: chip.state,
|
||||||
this.updateChip(chip, {
|
},
|
||||||
state: 'finished',
|
|
||||||
});
|
});
|
||||||
|
this.syncChipsFromRuntime();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.updateChip(chip, {
|
this.updateChip(chip, {
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
@@ -551,22 +551,16 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
|
|
||||||
private readonly addCollectionToContext = async (chip: CollectionChip) => {
|
private readonly addCollectionToContext = async (chip: CollectionChip) => {
|
||||||
try {
|
try {
|
||||||
const contextId = await this.createContextId();
|
await this.runtime?.dispatch({
|
||||||
if (!contextId || !AIProvider.context) {
|
type: 'addContextItem',
|
||||||
throw new Error('Context not found');
|
item: {
|
||||||
}
|
kind: 'collection',
|
||||||
// TODO: server side docIds calculation
|
|
||||||
const docIds = this.docDisplayConfig.getCollectionPageIds(
|
|
||||||
chip.collectionId
|
|
||||||
);
|
|
||||||
await AIProvider.context.addContextCollection({
|
|
||||||
contextId,
|
|
||||||
collectionId: chip.collectionId,
|
collectionId: chip.collectionId,
|
||||||
docIds,
|
docIds: this.docDisplayConfig.getCollectionPageIds(chip.collectionId),
|
||||||
});
|
state: chip.state,
|
||||||
this.updateChip(chip, {
|
},
|
||||||
state: 'finished',
|
|
||||||
});
|
});
|
||||||
|
this.syncChipsFromRuntime();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.updateChip(chip, {
|
this.updateChip(chip, {
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
@@ -579,19 +573,12 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
private readonly addAttachmentChipToContext = async (
|
private readonly addAttachmentChipToContext = async (
|
||||||
chip: AttachmentChip
|
chip: AttachmentChip
|
||||||
) => {
|
) => {
|
||||||
const contextId = await this.createContextId();
|
|
||||||
if (!contextId || !AIProvider.context) {
|
|
||||||
throw new Error('Context not found');
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const contextBlob = await AIProvider.context.addContextBlob({
|
await this.runtime?.dispatch({
|
||||||
blobId: chip.sourceId,
|
type: 'addContextItem',
|
||||||
contextId,
|
item: { kind: 'blob', blobId: chip.sourceId, state: chip.state },
|
||||||
});
|
|
||||||
this.updateChip(chip, {
|
|
||||||
state: contextBlob.status || 'processing',
|
|
||||||
blobId: chip.sourceId,
|
|
||||||
});
|
});
|
||||||
|
this.syncChipsFromRuntime();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.updateChip(chip, {
|
this.updateChip(chip, {
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
@@ -604,48 +591,19 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
private readonly removeFromContext = async (
|
private readonly removeFromContext = async (
|
||||||
chip: ChatChip
|
chip: ChatChip
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
try {
|
|
||||||
const contextId = await this.createContextId();
|
|
||||||
if (!contextId || !AIProvider.context) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (isDocChip(chip)) {
|
|
||||||
return await AIProvider.context.removeContextDoc({
|
|
||||||
contextId,
|
|
||||||
docId: chip.docId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (isFileChip(chip) && chip.fileId) {
|
|
||||||
return await AIProvider.context.removeContextFile({
|
|
||||||
contextId,
|
|
||||||
fileId: chip.fileId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (isTagChip(chip)) {
|
|
||||||
return await AIProvider.context.removeContextTag({
|
|
||||||
contextId,
|
|
||||||
tagId: chip.tagId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (isCollectionChip(chip)) {
|
|
||||||
return await AIProvider.context.removeContextCollection({
|
|
||||||
contextId,
|
|
||||||
collectionId: chip.collectionId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (isAttachmentChip(chip)) {
|
|
||||||
return await AIProvider.context.removeContextBlob({
|
|
||||||
contextId,
|
|
||||||
blobId: chip.sourceId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (isSelectedContextChip(chip)) {
|
if (isSelectedContextChip(chip)) {
|
||||||
this.updateContext({
|
this.updateContext({
|
||||||
...this.chatContextValue,
|
...this.chatContextValue,
|
||||||
snapshot: null,
|
snapshot: null,
|
||||||
combinedElementsMarkdown: null,
|
combinedElementsMarkdown: null,
|
||||||
});
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
const item = this.chipToContextItem(chip);
|
||||||
|
if (!item) return true;
|
||||||
|
try {
|
||||||
|
await this.runtime?.dispatch({ type: 'removeContextItem', item });
|
||||||
|
this.syncChipsFromRuntime();
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return true;
|
||||||
@@ -669,136 +627,19 @@ export class AIChatComposer extends SignalWatcher(
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly pollContextDocsAndFiles = async () => {
|
private readonly pollContextDocsAndFiles = async () => {
|
||||||
const sessionId = this.session?.sessionId;
|
if (!this.runtime) return;
|
||||||
const contextId = await this._getContextId();
|
await this.runtime.dispatch({ type: 'startContextPolling' });
|
||||||
if (!sessionId || !contextId || !AIProvider.context) {
|
this.syncChipsFromRuntime();
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this._pollAbortController) {
|
|
||||||
// already polling, reset timer
|
|
||||||
this._abortPoll();
|
|
||||||
}
|
|
||||||
this._pollAbortController = new AbortController();
|
|
||||||
await AIProvider.context.pollContextDocsAndFiles(
|
|
||||||
this.workspaceId,
|
|
||||||
sessionId,
|
|
||||||
contextId,
|
|
||||||
this._onPoll,
|
|
||||||
this._pollAbortController.signal
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly pollEmbeddingStatus = async () => {
|
private readonly pollEmbeddingStatus = async () => {
|
||||||
if (this._pollEmbeddingStatusAbortController) {
|
if (!this.runtime) return;
|
||||||
this._pollEmbeddingStatusAbortController.abort();
|
await this.runtime.dispatch({ type: 'pollEmbeddingStatus' });
|
||||||
}
|
this.syncChipsFromRuntime();
|
||||||
this._pollEmbeddingStatusAbortController = new AbortController();
|
|
||||||
const signal = this._pollEmbeddingStatusAbortController.signal;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await AIProvider.context?.pollEmbeddingStatus(
|
|
||||||
this.workspaceId,
|
|
||||||
(status: ContextWorkspaceEmbeddingStatus) => {
|
|
||||||
if (!status) {
|
|
||||||
this.embeddingCompleted = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const prevCompleted = this.embeddingCompleted;
|
|
||||||
const completed = status.embedded === status.total;
|
|
||||||
this.embeddingCompleted = completed;
|
|
||||||
if (prevCompleted !== completed) {
|
|
||||||
this.requestUpdate();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
signal
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
this.embeddingCompleted = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly _onPoll = (
|
|
||||||
result?: BlockSuitePresets.AIDocsAndFilesContext
|
|
||||||
) => {
|
|
||||||
if (!result) {
|
|
||||||
this._abortPoll();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const {
|
|
||||||
docs: sDocs = [],
|
|
||||||
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 | CopilotContextBlob
|
|
||||||
>();
|
|
||||||
const count: Record<ContextEmbedStatus, number> = {
|
|
||||||
finished: 0,
|
|
||||||
processing: 0,
|
|
||||||
failed: 0,
|
|
||||||
};
|
|
||||||
docs.forEach(doc => {
|
|
||||||
hashMap.set(doc.id, doc);
|
|
||||||
doc.status && count[doc.status]++;
|
|
||||||
});
|
|
||||||
files.forEach(file => {
|
|
||||||
hashMap.set(file.id, file);
|
|
||||||
file.status && count[file.status]++;
|
|
||||||
});
|
|
||||||
blobs.forEach(blob => {
|
|
||||||
hashMap.set(blob.id, blob);
|
|
||||||
blob.status && count[blob.status]++;
|
|
||||||
});
|
|
||||||
const nextChips = this.chips.map(chip => {
|
|
||||||
if (isTagChip(chip) || isCollectionChip(chip)) {
|
|
||||||
return chip;
|
|
||||||
}
|
|
||||||
const id = isDocChip(chip)
|
|
||||||
? chip.docId
|
|
||||||
: isFileChip(chip)
|
|
||||||
? chip.fileId
|
|
||||||
: isAttachmentChip(chip)
|
|
||||||
? chip.sourceId
|
|
||||||
: isSelectedContextChip(chip)
|
|
||||||
? chip.uuid
|
|
||||||
: undefined;
|
|
||||||
const item = id && hashMap.get(id);
|
|
||||||
if (item && item.status) {
|
|
||||||
return {
|
|
||||||
...chip,
|
|
||||||
state: item.status,
|
|
||||||
tooltip: 'error' in item ? item.error : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return chip;
|
|
||||||
});
|
|
||||||
this.updateChips(nextChips);
|
|
||||||
this.onEmbeddingProgressChange?.(count);
|
|
||||||
if (count.processing === 0) {
|
|
||||||
this._abortPoll();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly _abortPoll = () => {
|
|
||||||
this._pollAbortController?.abort();
|
|
||||||
this._pollAbortController = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly _abortPollEmbeddingStatus = () => {
|
|
||||||
this._pollEmbeddingStatusAbortController?.abort();
|
|
||||||
this._pollEmbeddingStatusAbortController = null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly initComposer = async () => {
|
private readonly initComposer = async () => {
|
||||||
const userId = (await AIProvider.userInfo)?.id;
|
const userId = AIAppEvents.userInfo.value?.id;
|
||||||
if (!userId || !this.session) return;
|
if (!userId || !this.session) return;
|
||||||
|
|
||||||
await this.initChips();
|
await this.initChips();
|
||||||
|
|||||||
@@ -1,17 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* @vitest-environment happy-dom
|
* @vitest-environment happy-dom
|
||||||
*/
|
*/
|
||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import { AIProvider } from '../../provider';
|
|
||||||
import { AIChatContent } from './ai-chat-content';
|
import { AIChatContent } from './ai-chat-content';
|
||||||
|
|
||||||
const originalHistories = AIProvider.histories;
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
AIProvider.provide('histories', originalHistories as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AIChatContent pinned scroll tracking', () => {
|
describe('AIChatContent pinned scroll tracking', () => {
|
||||||
test('records scroll position from the chat messages host', async () => {
|
test('records scroll position from the chat messages host', async () => {
|
||||||
let scrollEndHandler: (() => void) | undefined;
|
let scrollEndHandler: (() => void) | undefined;
|
||||||
@@ -47,103 +40,44 @@ describe('AIChatContent pinned scroll tracking', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('AIChatContent history loading', () => {
|
describe('AIChatContent runtime snapshot sync', () => {
|
||||||
test('replaces messages when the active session changes', async () => {
|
test('derives messages and loading state from runtime snapshot', () => {
|
||||||
const histories = {
|
const runtimeMessage = {
|
||||||
chats: vi.fn(async (_workspaceId: string, sessionId: string) => [
|
id: 'message-1',
|
||||||
{
|
|
||||||
messages: [
|
|
||||||
{
|
|
||||||
id: `${sessionId}-message`,
|
|
||||||
role: 'user',
|
|
||||||
content: sessionId,
|
|
||||||
createdAt: '2026-01-01T00:00:00.000Z',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
actions: vi.fn(async () => []),
|
|
||||||
cleanup: vi.fn(),
|
|
||||||
ids: vi.fn(),
|
|
||||||
};
|
|
||||||
AIProvider.provide('histories', histories as any);
|
|
||||||
|
|
||||||
const content: {
|
|
||||||
updateHistoryCounter: number;
|
|
||||||
historyKey: string | undefined;
|
|
||||||
workspaceId: string;
|
|
||||||
docId: string;
|
|
||||||
session: { sessionId: string };
|
|
||||||
chatContextValue: { messages: unknown[]; status?: string };
|
|
||||||
updateContext: (context: { messages: unknown[] }) => void;
|
|
||||||
} = {
|
|
||||||
updateHistoryCounter: 0,
|
|
||||||
historyKey: undefined,
|
|
||||||
workspaceId: 'ws-1',
|
|
||||||
docId: 'doc-1',
|
|
||||||
session: { sessionId: 'session-1' },
|
|
||||||
chatContextValue: { messages: [] },
|
|
||||||
updateContext(context: { messages: unknown[] }) {
|
|
||||||
this.chatContextValue = {
|
|
||||||
...this.chatContextValue,
|
|
||||||
...context,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await (AIChatContent.prototype as any).updateHistory.call(content);
|
|
||||||
expect(
|
|
||||||
content.chatContextValue.messages.map((message: any) => message.id)
|
|
||||||
).toEqual(['session-1-message']);
|
|
||||||
|
|
||||||
content.session = { sessionId: 'session-2' };
|
|
||||||
await (AIChatContent.prototype as any).updateHistory.call(content);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
content.chatContextValue.messages.map((message: any) => message.id)
|
|
||||||
).toEqual(['session-2-message']);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not overwrite in-flight optimistic messages when a session is created', async () => {
|
|
||||||
const histories = {
|
|
||||||
chats: vi.fn(async () => [{ messages: [] }]),
|
|
||||||
actions: vi.fn(async () => []),
|
|
||||||
cleanup: vi.fn(),
|
|
||||||
ids: vi.fn(),
|
|
||||||
};
|
|
||||||
AIProvider.provide('histories', histories as any);
|
|
||||||
|
|
||||||
const optimisticMessages = [
|
|
||||||
{
|
|
||||||
id: '',
|
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: 'hello',
|
content: 'hello',
|
||||||
createdAt: '2026-01-01T00:00:00.000Z',
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '',
|
|
||||||
role: 'assistant',
|
|
||||||
content: '',
|
|
||||||
createdAt: '2026-01-01T00:00:01.000Z',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const updateContext = vi.fn();
|
|
||||||
const content = {
|
|
||||||
updateHistoryCounter: 0,
|
|
||||||
historyKey: 'ws-1:doc-1:',
|
|
||||||
workspaceId: 'ws-1',
|
|
||||||
docId: 'doc-1',
|
|
||||||
session: { sessionId: 'session-1' },
|
|
||||||
chatContextValue: {
|
|
||||||
messages: optimisticMessages,
|
|
||||||
status: 'loading',
|
|
||||||
},
|
|
||||||
updateContext,
|
|
||||||
};
|
};
|
||||||
|
const content = Object.create(AIChatContent.prototype) as AIChatContent;
|
||||||
|
Object.defineProperty(content, 'runtimeSnapshot', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
messages: [runtimeMessage],
|
||||||
|
status: 'loading',
|
||||||
|
error: null,
|
||||||
|
readiness: 'initializing',
|
||||||
|
history: { loading: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(content, 'chatContextValue', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
messages: [],
|
||||||
|
status: 'idle',
|
||||||
|
error: null,
|
||||||
|
abortController: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(content, '_initializeScrollListeners', {
|
||||||
|
configurable: true,
|
||||||
|
value: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
await (AIChatContent.prototype as any).updateHistory.call(content);
|
(content as any).updated(new Map([['runtimeSnapshot', null]]));
|
||||||
|
|
||||||
expect(updateContext).not.toHaveBeenCalled();
|
expect(content.messages).toEqual([runtimeMessage]);
|
||||||
expect(content.chatContextValue.messages).toBe(optimisticMessages);
|
expect(content.isHistoryLoading).toBe(true);
|
||||||
|
expect(content.chatContextValue.messages).toEqual([]);
|
||||||
|
expect(content.chatContextValue.status).toBe('idle');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,10 +12,7 @@ import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
|||||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||||
import type { PeekViewService } from '@affine/core/modules/peek-view';
|
import type { PeekViewService } from '@affine/core/modules/peek-view';
|
||||||
import type { AppThemeService } from '@affine/core/modules/theme';
|
import type { AppThemeService } from '@affine/core/modules/theme';
|
||||||
import type {
|
import type { CopilotChatHistoryFragment } from '@affine/graphql';
|
||||||
ContextEmbedStatus,
|
|
||||||
CopilotChatHistoryFragment,
|
|
||||||
} from '@affine/graphql';
|
|
||||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||||
import { type EditorHost, ShadowlessElement } from '@blocksuite/affine/std';
|
import { type EditorHost, ShadowlessElement } from '@blocksuite/affine/std';
|
||||||
import type { ExtensionType } from '@blocksuite/affine/store';
|
import type { ExtensionType } from '@blocksuite/affine/store';
|
||||||
@@ -28,7 +25,9 @@ import { createRef, type Ref, ref } from 'lit/directives/ref.js';
|
|||||||
import { styleMap } from 'lit/directives/style-map.js';
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
import { pick } from 'lodash-es';
|
import { pick } from 'lodash-es';
|
||||||
|
|
||||||
import { type AIChatParams, AIProvider } from '../../provider/ai-provider';
|
import { AIAppEvents } from '../../provider/ai-app-events';
|
||||||
|
import type { AIChatParams } from '../../provider/ai-provider';
|
||||||
|
import type { AIChatRuntime, AIChatSnapshot } from '../../runtime/chat';
|
||||||
import { extractSelectedContent } from '../../utils/extract';
|
import { extractSelectedContent } from '../../utils/extract';
|
||||||
import { HISTORY_IMAGE_ACTIONS } from '../../utils/history-image-actions';
|
import { HISTORY_IMAGE_ACTIONS } from '../../utils/history-image-actions';
|
||||||
import type { SearchMenuConfig } from '../ai-chat-add-context';
|
import type { SearchMenuConfig } from '../ai-chat-add-context';
|
||||||
@@ -36,8 +35,6 @@ import type { DocDisplayConfig } from '../ai-chat-chips';
|
|||||||
import type { AIReasoningConfig } from '../ai-chat-input';
|
import type { AIReasoningConfig } from '../ai-chat-input';
|
||||||
import {
|
import {
|
||||||
type AIChatMessages,
|
type AIChatMessages,
|
||||||
type ChatAction,
|
|
||||||
type ChatMessage,
|
|
||||||
type HistoryMessage,
|
type HistoryMessage,
|
||||||
isChatMessage,
|
isChatMessage,
|
||||||
} from '../ai-chat-messages';
|
} from '../ai-chat-messages';
|
||||||
@@ -126,9 +123,10 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
accessor session!: CopilotChatHistoryFragment | null | undefined;
|
accessor session!: CopilotChatHistoryFragment | null | undefined;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor createSession!: () => Promise<
|
accessor runtime: AIChatRuntime | null | undefined;
|
||||||
CopilotChatHistoryFragment | undefined
|
|
||||||
>;
|
@property({ attribute: false })
|
||||||
|
accessor runtimeSnapshot: AIChatSnapshot | null | undefined;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor workspaceId!: string;
|
accessor workspaceId!: string;
|
||||||
@@ -172,14 +170,6 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor aiModelService!: AIModelService;
|
accessor aiModelService!: AIModelService;
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor onEmbeddingProgressChange:
|
|
||||||
| ((count: Record<ContextEmbedStatus, number>) => void)
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor onContextChange!: (context: Partial<ChatContextValue>) => void;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor onOpenDoc!: (docId: string, sessionId?: string) => void;
|
accessor onOpenDoc!: (docId: string, sessionId?: string) => void;
|
||||||
|
|
||||||
@@ -198,9 +188,6 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
@state()
|
@state()
|
||||||
accessor chatContextValue: ChatContextValue = DEFAULT_CHAT_CONTEXT_VALUE;
|
accessor chatContextValue: ChatContextValue = DEFAULT_CHAT_CONTEXT_VALUE;
|
||||||
|
|
||||||
@state()
|
|
||||||
accessor isHistoryLoading = false;
|
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private accessor showPreviewPanel = false;
|
private accessor showPreviewPanel = false;
|
||||||
|
|
||||||
@@ -210,15 +197,13 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
private readonly chatMessagesRef: Ref<AIChatMessages> =
|
private readonly chatMessagesRef: Ref<AIChatMessages> =
|
||||||
createRef<AIChatMessages>();
|
createRef<AIChatMessages>();
|
||||||
|
|
||||||
// request counter to track the latest request
|
|
||||||
private updateHistoryCounter = 0;
|
|
||||||
|
|
||||||
private historyKey: string | undefined;
|
|
||||||
|
|
||||||
private lastScrollTop: number | undefined;
|
private lastScrollTop: number | undefined;
|
||||||
|
|
||||||
get messages() {
|
get messages() {
|
||||||
return this.chatContextValue.messages.filter(item => {
|
const messages =
|
||||||
|
(this.runtimeSnapshot?.messages as HistoryMessage[] | undefined) ??
|
||||||
|
this.chatContextValue.messages;
|
||||||
|
return messages.filter(item => {
|
||||||
return (
|
return (
|
||||||
isChatMessage(item) ||
|
isChatMessage(item) ||
|
||||||
item.messages?.length === 3 ||
|
item.messages?.length === 3 ||
|
||||||
@@ -232,86 +217,15 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateHistory() {
|
get isHistoryLoading() {
|
||||||
const currentRequest = ++this.updateHistoryCounter;
|
const snapshot = this.runtimeSnapshot;
|
||||||
if (!AIProvider.histories) {
|
return (
|
||||||
return;
|
snapshot?.readiness === 'initializing' || !!snapshot?.history.loading
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = this.session?.sessionId;
|
|
||||||
const nextHistoryKey = `${this.workspaceId}:${this.docId ?? ''}:${
|
|
||||||
sessionId ?? ''
|
|
||||||
}`;
|
|
||||||
const previousHistoryKey = this.historyKey;
|
|
||||||
const preserveCurrentMessages = previousHistoryKey === nextHistoryKey;
|
|
||||||
this.historyKey = nextHistoryKey;
|
|
||||||
const [histories, actions] = await Promise.all([
|
|
||||||
sessionId
|
|
||||||
? AIProvider.histories.chats(this.workspaceId, sessionId)
|
|
||||||
: Promise.resolve([]),
|
|
||||||
this.docId && this.showActions
|
|
||||||
? AIProvider.histories.actions(this.workspaceId, this.docId)
|
|
||||||
: Promise.resolve([]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Check if this is still the latest request
|
|
||||||
if (currentRequest !== this.updateHistoryCounter) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!preserveCurrentMessages &&
|
|
||||||
(this.chatContextValue.status === 'loading' ||
|
|
||||||
this.chatContextValue.status === 'transmitting') &&
|
|
||||||
this.chatContextValue.messages.length
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages: HistoryMessage[] = preserveCurrentMessages
|
|
||||||
? this.chatContextValue.messages.slice().filter(isChatMessage)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const chatActions = (actions || []) as ChatAction[];
|
|
||||||
messages.push(...chatActions);
|
|
||||||
|
|
||||||
const chatMessages = (histories?.[0]?.messages || []) as ChatMessage[];
|
|
||||||
messages.push(...chatMessages);
|
|
||||||
|
|
||||||
this.updateContext({
|
|
||||||
messages: messages.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly updateActions = async () => {
|
|
||||||
if (!this.docId || !AIProvider.histories || !this.showActions) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const actions = await AIProvider.histories.actions(
|
|
||||||
this.workspaceId,
|
|
||||||
this.docId
|
|
||||||
);
|
);
|
||||||
if (actions && actions.length) {
|
|
||||||
const chatMessages = this.chatContextValue.messages.filter(message =>
|
|
||||||
isChatMessage(message)
|
|
||||||
);
|
|
||||||
const chatActions = actions as ChatAction[];
|
|
||||||
const messages: HistoryMessage[] = [...chatMessages, ...chatActions];
|
|
||||||
this.updateContext({
|
|
||||||
messages: messages.sort(
|
|
||||||
(a, b) =>
|
|
||||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
private readonly updateContext = (context: Partial<ChatContextValue>) => {
|
private readonly updateContext = (context: Partial<ChatContextValue>) => {
|
||||||
this.chatContextValue = { ...this.chatContextValue, ...context };
|
this.chatContextValue = { ...this.chatContextValue, ...context };
|
||||||
this.onContextChange?.(context);
|
|
||||||
this.updateDraft(context).catch(console.error);
|
this.updateDraft(context).catch(console.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -331,9 +245,7 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly initChatContent = async () => {
|
private readonly initChatContent = async () => {
|
||||||
this.isHistoryLoading = true;
|
this._initializeScrollListeners();
|
||||||
await this.updateHistory();
|
|
||||||
this.isHistoryLoading = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
protected override firstUpdated(): void {}
|
protected override firstUpdated(): void {}
|
||||||
@@ -382,13 +294,13 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
public openPreviewPanel(content?: TemplateResult<1>) {
|
public openPreviewPanel(content?: TemplateResult<1>) {
|
||||||
this.showPreviewPanel = true;
|
this.showPreviewPanel = true;
|
||||||
if (content) this.previewPanelContent = content;
|
if (content) this.previewPanelContent = content;
|
||||||
AIProvider.slots.previewPanelOpenChange.next(true);
|
AIAppEvents.previewPanelOpenChange.next(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public closePreviewPanel(destroyContent: boolean = false) {
|
public closePreviewPanel(destroyContent: boolean = false) {
|
||||||
this.showPreviewPanel = false;
|
this.showPreviewPanel = false;
|
||||||
if (destroyContent) this.previewPanelContent = null;
|
if (destroyContent) this.previewPanelContent = null;
|
||||||
AIProvider.slots.previewPanelOpenChange.next(false);
|
AIAppEvents.previewPanelOpenChange.next(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get isPreviewPanelOpen() {
|
public get isPreviewPanelOpen() {
|
||||||
@@ -416,19 +328,7 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
this.subscriptionService.subscription.revalidate();
|
this.subscriptionService.subscription.revalidate();
|
||||||
|
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
AIProvider.slots.actions.subscribe(({ event }) => {
|
AIAppEvents.requestOpenWithChat.subscribe(
|
||||||
const { status } = this.chatContextValue;
|
|
||||||
if (
|
|
||||||
event === 'finished' &&
|
|
||||||
(status === 'idle' || status === 'success')
|
|
||||||
) {
|
|
||||||
this.updateActions().catch(console.error);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
this._disposables.add(
|
|
||||||
AIProvider.slots.requestOpenWithChat.subscribe(
|
|
||||||
(params: AIChatParams | null) => {
|
(params: AIChatParams | null) => {
|
||||||
if (!params) {
|
if (!params) {
|
||||||
return;
|
return;
|
||||||
@@ -445,7 +345,7 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AIProvider.slots.requestOpenWithChat.next(null);
|
AIAppEvents.requestOpenWithChat.next(null);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -463,7 +363,8 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
.workspaceId=${this.workspaceId}
|
.workspaceId=${this.workspaceId}
|
||||||
.docId=${this.docId}
|
.docId=${this.docId}
|
||||||
.session=${this.session}
|
.session=${this.session}
|
||||||
.createSession=${this.createSession}
|
.runtime=${this.runtime}
|
||||||
|
.runtimeSnapshot=${this.runtimeSnapshot}
|
||||||
.chatContextValue=${this.chatContextValue}
|
.chatContextValue=${this.chatContextValue}
|
||||||
.updateContext=${this.updateContext}
|
.updateContext=${this.updateContext}
|
||||||
.isHistoryLoading=${this.isHistoryLoading}
|
.isHistoryLoading=${this.isHistoryLoading}
|
||||||
@@ -491,10 +392,10 @@ export class AIChatContent extends SignalWatcher(
|
|||||||
.workspaceId=${this.workspaceId}
|
.workspaceId=${this.workspaceId}
|
||||||
.docId=${this.docId}
|
.docId=${this.docId}
|
||||||
.session=${this.session}
|
.session=${this.session}
|
||||||
.createSession=${this.createSession}
|
.runtime=${this.runtime}
|
||||||
|
.runtimeSnapshot=${this.runtimeSnapshot}
|
||||||
.chatContextValue=${this.chatContextValue}
|
.chatContextValue=${this.chatContextValue}
|
||||||
.updateContext=${this.updateContext}
|
.updateContext=${this.updateContext}
|
||||||
.onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
|
|
||||||
.reasoningConfig=${this.reasoningConfig}
|
.reasoningConfig=${this.reasoningConfig}
|
||||||
.docDisplayConfig=${this.docDisplayConfig}
|
.docDisplayConfig=${this.docDisplayConfig}
|
||||||
.searchMenuConfig=${this.searchMenuConfig}
|
.searchMenuConfig=${this.searchMenuConfig}
|
||||||
|
|||||||
@@ -24,19 +24,14 @@ import { repeat } from 'lit/directives/repeat.js';
|
|||||||
import { styleMap } from 'lit/directives/style-map.js';
|
import { styleMap } from 'lit/directives/style-map.js';
|
||||||
|
|
||||||
import { ChatAbortIcon } from '../../_common/icons';
|
import { ChatAbortIcon } from '../../_common/icons';
|
||||||
import { type AIError, AIProvider, type AISendParams } from '../../provider';
|
import { AIAppEvents, type AISendParams } from '../../provider';
|
||||||
|
import type { AIChatRuntime, AIChatSnapshot } from '../../runtime/chat';
|
||||||
import { reportResponse } from '../../utils/action-reporter';
|
import { reportResponse } from '../../utils/action-reporter';
|
||||||
import { readBlobAsURL } from '../../utils/image';
|
import { readBlobAsURL } from '../../utils/image';
|
||||||
import { mergeStreamObjects } from '../../utils/stream-objects';
|
|
||||||
import type { SearchMenuConfig } from '../ai-chat-add-context';
|
import type { SearchMenuConfig } from '../ai-chat-add-context';
|
||||||
import { addFilesToChat } from '../ai-chat-chips/attachment-utils';
|
import { addFilesToChat } from '../ai-chat-chips/attachment-utils';
|
||||||
import type { ChatChip, DocDisplayConfig } from '../ai-chat-chips/type';
|
import type { ChatChip, DocDisplayConfig } from '../ai-chat-chips/type';
|
||||||
import { isDocChip } from '../ai-chat-chips/utils';
|
import { isDocChip } from '../ai-chat-chips/utils';
|
||||||
import {
|
|
||||||
type ChatMessage,
|
|
||||||
isChatMessage,
|
|
||||||
StreamObjectSchema,
|
|
||||||
} from '../ai-chat-messages';
|
|
||||||
import type { AIChatInputContext, AIReasoningConfig } from './type';
|
import type { AIChatInputContext, AIReasoningConfig } from './type';
|
||||||
|
|
||||||
function getFirstTwoLines(text: string) {
|
function getFirstTwoLines(text: string) {
|
||||||
@@ -340,6 +335,12 @@ export class AIChatInput extends SignalWatcher(
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor session!: CopilotChatHistoryFragment | null | undefined;
|
accessor session!: CopilotChatHistoryFragment | null | undefined;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor runtime: AIChatRuntime | null | undefined;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor runtimeSnapshot: AIChatSnapshot | null | undefined;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor isContextProcessing!: boolean | undefined;
|
accessor isContextProcessing!: boolean | undefined;
|
||||||
|
|
||||||
@@ -371,11 +372,6 @@ export class AIChatInput extends SignalWatcher(
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor chips: ChatChip[] = [];
|
accessor chips: ChatChip[] = [];
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor createSession!: () => Promise<
|
|
||||||
CopilotChatHistoryFragment | undefined
|
|
||||||
>;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor updateContext!: (context: Partial<AIChatInputContext>) => void;
|
accessor updateContext!: (context: Partial<AIChatInputContext>) => void;
|
||||||
|
|
||||||
@@ -441,7 +437,7 @@ export class AIChatInput extends SignalWatcher(
|
|||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
AIProvider.slots.requestSendWithChat.subscribe(
|
AIAppEvents.requestSendWithChat.subscribe(
|
||||||
(params: AISendParams | null) => {
|
(params: AISendParams | null) => {
|
||||||
if (!params) {
|
if (!params) {
|
||||||
return;
|
return;
|
||||||
@@ -455,13 +451,13 @@ export class AIChatInput extends SignalWatcher(
|
|||||||
this.send(input).catch(console.error);
|
this.send(input).catch(console.error);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
AIProvider.slots.requestSendWithChat.next(null);
|
AIAppEvents.requestSendWithChat.next(null);
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
this._disposables.add(
|
this._disposables.add(
|
||||||
AIProvider.slots.requestOpenWithChat.subscribe(params => {
|
AIAppEvents.requestOpenWithChat.subscribe(params => {
|
||||||
if (!params) return;
|
if (!params) return;
|
||||||
|
|
||||||
const { input, host } = params;
|
const { input, host } = params;
|
||||||
@@ -552,7 +548,8 @@ export class AIChatInput extends SignalWatcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected override render() {
|
protected override render() {
|
||||||
const { images, status } = this.chatContextValue;
|
const { images } = this.chatContextValue;
|
||||||
|
const status = this.runtimeSnapshot?.status ?? this.chatContextValue.status;
|
||||||
const hasImages = images.length > 0;
|
const hasImages = images.length > 0;
|
||||||
const maxHeight = hasImages ? 272 + 2 : 200 + 2;
|
const maxHeight = hasImages ? 272 + 2 : 200 + 2;
|
||||||
|
|
||||||
@@ -664,6 +661,10 @@ export class AIChatInput extends SignalWatcher(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.runtimeSnapshot && !this.runtimeSnapshot.uiPolicy.canSend) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isContextProcessing) {
|
if (this.isContextProcessing) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -782,9 +783,11 @@ export class AIChatInput extends SignalWatcher(
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly _handleAbort = () => {
|
private readonly _handleAbort = () => {
|
||||||
this.chatContextValue.abortController?.abort();
|
if (this.runtime) {
|
||||||
this.updateContext({ status: 'success' });
|
this.runtime.dispatch({ type: 'stop' }).catch(console.error);
|
||||||
reportResponse('aborted:stop');
|
reportResponse('aborted:stop', this.host);
|
||||||
|
return;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly _toggleReasoning = (extendedThinking: boolean) => {
|
private readonly _toggleReasoning = (extendedThinking: boolean) => {
|
||||||
@@ -817,51 +820,26 @@ export class AIChatInput extends SignalWatcher(
|
|||||||
};
|
};
|
||||||
|
|
||||||
send = async (text: string) => {
|
send = async (text: string) => {
|
||||||
try {
|
if (!this.runtime) return;
|
||||||
const {
|
const { markdown, images, snapshot, combinedElementsMarkdown, html } =
|
||||||
status,
|
this.chatContextValue;
|
||||||
markdown,
|
const userInput = (markdown ? `${markdown}\n` : '') + text;
|
||||||
images,
|
|
||||||
snapshot,
|
|
||||||
combinedElementsMarkdown,
|
|
||||||
html,
|
|
||||||
} = this.chatContextValue;
|
|
||||||
|
|
||||||
if (status === 'loading' || status === 'transmitting') return;
|
|
||||||
if (!text) return;
|
|
||||||
if (!AIProvider.actions.chat) return;
|
|
||||||
|
|
||||||
const abortController = new AbortController();
|
|
||||||
this.updateContext({
|
|
||||||
images: [],
|
|
||||||
status: 'loading',
|
|
||||||
error: null,
|
|
||||||
quote: '',
|
|
||||||
markdown: '',
|
|
||||||
abortController,
|
|
||||||
});
|
|
||||||
|
|
||||||
const imageAttachments = await Promise.all(
|
const imageAttachments = await Promise.all(
|
||||||
images?.map(image => readBlobAsURL(image))
|
images?.map(image => readBlobAsURL(image))
|
||||||
);
|
);
|
||||||
const userInput = (markdown ? `${markdown}\n` : '') + text;
|
const contexts = await this._getMatchedContexts();
|
||||||
|
|
||||||
// optimistic update messages
|
|
||||||
await this._preUpdateMessages(userInput, imageAttachments);
|
|
||||||
|
|
||||||
const sessionId = (await this.createSession())?.sessionId;
|
|
||||||
let contexts = await this._getMatchedContexts();
|
|
||||||
if (abortController.signal.aborted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const enableSendDetailedObject =
|
const enableSendDetailedObject =
|
||||||
this.affineFeatureFlagService.flags.enable_send_detailed_object_to_ai
|
this.affineFeatureFlagService.flags.enable_send_detailed_object_to_ai
|
||||||
.value;
|
.value;
|
||||||
|
const userInfo = AIAppEvents.userInfo.value;
|
||||||
|
|
||||||
const modelId = this.aiModelService.modelId.value;
|
this.updateContext({
|
||||||
const stream = await AIProvider.actions.chat({
|
images: [],
|
||||||
sessionId,
|
quote: '',
|
||||||
|
markdown: '',
|
||||||
|
});
|
||||||
|
await this.runtime.dispatch({
|
||||||
|
type: 'send',
|
||||||
input: userInput,
|
input: userInput,
|
||||||
contexts: {
|
contexts: {
|
||||||
...contexts,
|
...contexts,
|
||||||
@@ -873,97 +851,21 @@ export class AIChatInput extends SignalWatcher(
|
|||||||
: undefined,
|
: undefined,
|
||||||
html: html || undefined,
|
html: html || undefined,
|
||||||
},
|
},
|
||||||
docId: this.docId,
|
|
||||||
attachments: images,
|
attachments: images,
|
||||||
workspaceId: this.workspaceId,
|
attachmentPreviews: imageAttachments,
|
||||||
stream: true,
|
|
||||||
signal: abortController.signal,
|
|
||||||
isRootSession: this.isRootSession,
|
isRootSession: this.isRootSession,
|
||||||
where: this.trackOptions?.where,
|
where: this.trackOptions?.where,
|
||||||
control: this.trackOptions?.control,
|
control: this.trackOptions?.control,
|
||||||
reasoning: this._isReasoningActive,
|
reasoning: this._isReasoningActive,
|
||||||
toolsConfig: this.aiToolsConfigService.config.value,
|
toolsConfig: this.aiToolsConfigService.config.value,
|
||||||
modelId,
|
modelId: this.aiModelService.modelId.value,
|
||||||
});
|
userInfo: {
|
||||||
|
|
||||||
for await (const text of stream) {
|
|
||||||
const messages = this.chatContextValue.messages.slice(0);
|
|
||||||
const last = messages.at(-1);
|
|
||||||
if (last && isChatMessage(last)) {
|
|
||||||
try {
|
|
||||||
const parsed = StreamObjectSchema.parse(JSON.parse(text));
|
|
||||||
const streamObjects = mergeStreamObjects([
|
|
||||||
...(last.streamObjects ?? []),
|
|
||||||
parsed,
|
|
||||||
]);
|
|
||||||
messages[messages.length - 1] = {
|
|
||||||
...last,
|
|
||||||
streamObjects,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
messages[messages.length - 1] = {
|
|
||||||
...last,
|
|
||||||
content: last.content + text,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
this.updateContext({ messages, status: 'transmitting' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateContext({ status: 'success' });
|
|
||||||
this.onChatSuccess?.();
|
|
||||||
// update message id from server
|
|
||||||
await this._postUpdateMessages();
|
|
||||||
} catch (error) {
|
|
||||||
this.updateContext({ status: 'error', error: error as AIError });
|
|
||||||
} finally {
|
|
||||||
this.updateContext({ abortController: null });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly _preUpdateMessages = async (
|
|
||||||
userInput: string,
|
|
||||||
attachments: string[]
|
|
||||||
) => {
|
|
||||||
const userInfo = await AIProvider.userInfo;
|
|
||||||
this.updateContext({
|
|
||||||
messages: [
|
|
||||||
...this.chatContextValue.messages,
|
|
||||||
{
|
|
||||||
id: '',
|
|
||||||
role: 'user',
|
|
||||||
content: userInput,
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
attachments,
|
|
||||||
userId: userInfo?.id,
|
userId: userInfo?.id,
|
||||||
userName: userInfo?.name,
|
userName: userInfo?.name,
|
||||||
avatarUrl: userInfo?.avatarUrl ?? undefined,
|
avatarUrl: userInfo?.avatarUrl ?? undefined,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: '',
|
|
||||||
role: 'assistant',
|
|
||||||
content: '',
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
};
|
this.onChatSuccess?.();
|
||||||
|
|
||||||
private readonly _postUpdateMessages = async () => {
|
|
||||||
const sessionId = this.session?.sessionId;
|
|
||||||
if (!sessionId || !AIProvider.histories) return;
|
|
||||||
|
|
||||||
const { messages } = this.chatContextValue;
|
|
||||||
const last = messages[messages.length - 1] as ChatMessage;
|
|
||||||
if (!last.id) {
|
|
||||||
const historyIds = await AIProvider.histories.ids(
|
|
||||||
this.workspaceId,
|
|
||||||
this.docId,
|
|
||||||
{ sessionId, withMessages: true }
|
|
||||||
);
|
|
||||||
if (!historyIds || !historyIds[0]) return;
|
|
||||||
last.id = historyIds[0].messages.at(-1)?.id ?? '';
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private async _getMatchedContexts() {
|
private async _getMatchedContexts() {
|
||||||
|
|||||||
@@ -93,4 +93,35 @@ describe('AIChatMessages scrolling', () => {
|
|||||||
expect(element.canScrollDown).toBe(false);
|
expect(element.canScrollDown).toBe(false);
|
||||||
expect(scrollToEnd).toHaveBeenCalled();
|
expect(scrollToEnd).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('message keys are scoped by active tab', () => {
|
||||||
|
const element = {} as AIChatMessages;
|
||||||
|
const message = {
|
||||||
|
id: 'message-1',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'reply',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
element.runtimeSnapshot = {
|
||||||
|
activeTabId: 'session-1',
|
||||||
|
} as AIChatMessages['runtimeSnapshot'];
|
||||||
|
const firstKey = (AIChatMessages.prototype as any)._getMessageKey.call(
|
||||||
|
element,
|
||||||
|
message,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
element.runtimeSnapshot = {
|
||||||
|
activeTabId: 'session-2',
|
||||||
|
} as AIChatMessages['runtimeSnapshot'];
|
||||||
|
const secondKey = (AIChatMessages.prototype as any)._getMessageKey.call(
|
||||||
|
element,
|
||||||
|
message,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(firstKey).toBe('session-1:message-1');
|
||||||
|
expect(secondKey).toBe('session-2:message-1');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,8 +19,13 @@ import { repeat } from 'lit/directives/repeat.js';
|
|||||||
import { debounce, throttle } from 'lodash-es';
|
import { debounce, throttle } from 'lodash-es';
|
||||||
|
|
||||||
import { AffineIcon } from '../../_common/icons';
|
import { AffineIcon } from '../../_common/icons';
|
||||||
import { type AIError, AIProvider, UnauthorizedError } from '../../provider';
|
import {
|
||||||
import { mergeStreamObjects } from '../../utils/stream-objects';
|
AIAppEvents,
|
||||||
|
type AIError,
|
||||||
|
GeneralNetworkError,
|
||||||
|
UnauthorizedError,
|
||||||
|
} from '../../provider';
|
||||||
|
import type { AIChatRuntime, AIChatSnapshot } from '../../runtime/chat';
|
||||||
import type { DocDisplayConfig } from '../ai-chat-chips';
|
import type { DocDisplayConfig } from '../ai-chat-chips';
|
||||||
import { type ChatContextValue } from '../ai-chat-content/type';
|
import { type ChatContextValue } from '../ai-chat-content/type';
|
||||||
import type { AIReasoningConfig } from '../ai-chat-input';
|
import type { AIReasoningConfig } from '../ai-chat-input';
|
||||||
@@ -30,12 +35,7 @@ import {
|
|||||||
AI_CHAT_SCROLL_DOWN_INDICATOR_THRESHOLD,
|
AI_CHAT_SCROLL_DOWN_INDICATOR_THRESHOLD,
|
||||||
} from './auto-scroll';
|
} from './auto-scroll';
|
||||||
import { AIPreloadConfig } from './preload-config';
|
import { AIPreloadConfig } from './preload-config';
|
||||||
import {
|
import { type HistoryMessage, isChatAction, isChatMessage } from './type';
|
||||||
type HistoryMessage,
|
|
||||||
isChatAction,
|
|
||||||
isChatMessage,
|
|
||||||
StreamObjectSchema,
|
|
||||||
} from './type';
|
|
||||||
|
|
||||||
export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
@@ -179,9 +179,10 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
|||||||
accessor session!: CopilotChatHistoryFragment | null | undefined;
|
accessor session!: CopilotChatHistoryFragment | null | undefined;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor createSession!: () => Promise<
|
accessor runtime: AIChatRuntime | null | undefined;
|
||||||
CopilotChatHistoryFragment | undefined
|
|
||||||
>;
|
@property({ attribute: false })
|
||||||
|
accessor runtimeSnapshot: AIChatSnapshot | null | undefined;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor updateContext!: (context: Partial<ChatContextValue>) => void;
|
accessor updateContext!: (context: Partial<ChatContextValue>) => void;
|
||||||
@@ -227,8 +228,21 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
|||||||
|
|
||||||
private _lastObservedScrollTop = 0;
|
private _lastObservedScrollTop = 0;
|
||||||
|
|
||||||
private get _isReasoningActive() {
|
private get chatStatus() {
|
||||||
return !!this.reasoningConfig.enabled.value;
|
return this.runtimeSnapshot?.status ?? this.chatContextValue.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get chatError(): AIError | null {
|
||||||
|
return this.toAIError(
|
||||||
|
this.runtimeSnapshot?.error ?? this.chatContextValue.error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private toAIError(error: Error | null): AIError | null {
|
||||||
|
if (!error) return null;
|
||||||
|
return 'type' in error
|
||||||
|
? (error as AIError)
|
||||||
|
: new GeneralNetworkError(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _renderAIOnboarding() {
|
private _renderAIOnboarding() {
|
||||||
@@ -296,8 +310,20 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
|||||||
return scrollHeight - scrollTop - clientHeight;
|
return scrollHeight - scrollTop - clientHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _getMessageKey(item: HistoryMessage, index: number) {
|
||||||
|
const tabKey =
|
||||||
|
this.runtimeSnapshot?.activeTabId ??
|
||||||
|
this.runtimeSnapshot?.activeSessionId ??
|
||||||
|
'legacy';
|
||||||
|
if (isChatMessage(item)) {
|
||||||
|
return `${tabKey}:${item.id || index}`;
|
||||||
|
}
|
||||||
|
return `${tabKey}:${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
protected override render() {
|
protected override render() {
|
||||||
const { status, error } = this.chatContextValue;
|
const status = this.chatStatus;
|
||||||
|
const error = this.chatError;
|
||||||
const { isHistoryLoading } = this;
|
const { isHistoryLoading } = this;
|
||||||
const filteredItems = this.messages;
|
const filteredItems = this.messages;
|
||||||
|
|
||||||
@@ -337,7 +363,7 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
|||||||
</div> `
|
</div> `
|
||||||
: repeat(
|
: repeat(
|
||||||
filteredItems,
|
filteredItems,
|
||||||
(_, index) => index,
|
(item, index) => this._getMessageKey(item, index),
|
||||||
(item, index) => {
|
(item, index) => {
|
||||||
const isLast = index === filteredItems.length - 1;
|
const isLast = index === filteredItems.length - 1;
|
||||||
if (isChatMessage(item) && item.role === 'user') {
|
if (isChatMessage(item) && item.role === 'user') {
|
||||||
@@ -389,21 +415,19 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
|||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
const { disposables } = this;
|
const { disposables } = this;
|
||||||
|
|
||||||
Promise.resolve(AIProvider.userInfo)
|
this.avatarUrl = AIAppEvents.userInfo.value?.avatarUrl ?? '';
|
||||||
.then(res => {
|
|
||||||
this.avatarUrl = res?.avatarUrl ?? '';
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
|
|
||||||
disposables.add(
|
disposables.add(
|
||||||
AIProvider.slots.userInfo.subscribe(userInfo => {
|
AIAppEvents.userInfo.subscribe(userInfo => {
|
||||||
const { status, error } = this.chatContextValue;
|
const status = this.chatStatus;
|
||||||
|
const error = this.chatError;
|
||||||
this.avatarUrl = userInfo?.avatarUrl ?? '';
|
this.avatarUrl = userInfo?.avatarUrl ?? '';
|
||||||
if (
|
if (
|
||||||
status === 'error' &&
|
status === 'error' &&
|
||||||
error instanceof UnauthorizedError &&
|
error instanceof UnauthorizedError &&
|
||||||
userInfo
|
userInfo
|
||||||
) {
|
) {
|
||||||
|
this.runtime?.dispatch({ type: 'clearError' }).catch(console.error);
|
||||||
this.updateContext({ status: 'idle', error: null });
|
this.updateContext({ status: 'idle', error: null });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -452,7 +476,7 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
|||||||
if (changedProperties.has('messages')) {
|
if (changedProperties.has('messages')) {
|
||||||
this._onScroll();
|
this._onScroll();
|
||||||
|
|
||||||
if (this.chatContextValue.status === 'transmitting') {
|
if (this.chatStatus === 'transmitting') {
|
||||||
this._throttledScrollToEnd();
|
this._throttledScrollToEnd();
|
||||||
} else if (this._autoScrollEnabled) {
|
} else if (this._autoScrollEnabled) {
|
||||||
this.scrollToEnd();
|
this.scrollToEnd();
|
||||||
@@ -460,9 +484,9 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
changedProperties.has('chatContextValue') &&
|
(changedProperties.has('chatContextValue') ||
|
||||||
(this.chatContextValue.status === 'success' ||
|
changedProperties.has('runtimeSnapshot')) &&
|
||||||
this.chatContextValue.status === 'error')
|
(this.chatStatus === 'success' || this.chatStatus === 'error')
|
||||||
) {
|
) {
|
||||||
this._onScroll();
|
this._onScroll();
|
||||||
}
|
}
|
||||||
@@ -484,69 +508,11 @@ export class AIChatMessages extends WithDisposable(ShadowlessElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
retry = async () => {
|
retry = async () => {
|
||||||
try {
|
if (!this.runtime) return;
|
||||||
const sessionId = (await this.createSession())?.sessionId;
|
const last = this.messages.at(-1);
|
||||||
if (!sessionId) return;
|
await this.runtime.dispatch({
|
||||||
if (!AIProvider.actions.chat) return;
|
type: 'retry',
|
||||||
|
messageId: last && isChatMessage(last) ? last.id : '',
|
||||||
const abortController = new AbortController();
|
|
||||||
const messages = [...this.chatContextValue.messages];
|
|
||||||
const last = messages[messages.length - 1];
|
|
||||||
if ('content' in last) {
|
|
||||||
last.content = '';
|
|
||||||
last.streamObjects = [];
|
|
||||||
last.createdAt = new Date().toISOString();
|
|
||||||
}
|
|
||||||
this.updateContext({
|
|
||||||
messages,
|
|
||||||
status: 'loading',
|
|
||||||
error: null,
|
|
||||||
abortController,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const stream = await AIProvider.actions.chat({
|
|
||||||
sessionId,
|
|
||||||
retry: true,
|
|
||||||
docId: this.docId,
|
|
||||||
workspaceId: this.workspaceId,
|
|
||||||
stream: true,
|
|
||||||
signal: abortController.signal,
|
|
||||||
where: 'chat-panel',
|
|
||||||
control: 'chat-send',
|
|
||||||
isRootSession: true,
|
|
||||||
reasoning: this._isReasoningActive,
|
|
||||||
toolsConfig: this.aiToolsConfigService.config.value,
|
|
||||||
});
|
|
||||||
|
|
||||||
for await (const text of stream) {
|
|
||||||
const messages = this.chatContextValue.messages.slice(0);
|
|
||||||
const last = messages.at(-1);
|
|
||||||
if (last && isChatMessage(last)) {
|
|
||||||
try {
|
|
||||||
const parsed = StreamObjectSchema.parse(JSON.parse(text));
|
|
||||||
const streamObjects = mergeStreamObjects([
|
|
||||||
...(last.streamObjects ?? []),
|
|
||||||
parsed,
|
|
||||||
]);
|
|
||||||
messages[messages.length - 1] = {
|
|
||||||
...last,
|
|
||||||
streamObjects,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
messages[messages.length - 1] = {
|
|
||||||
...last,
|
|
||||||
content: last.content + text,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
this.updateContext({ messages, status: 'transmitting' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateContext({ status: 'success' });
|
|
||||||
} catch (error) {
|
|
||||||
this.updateContext({ status: 'error', error: error as AIError });
|
|
||||||
} finally {
|
|
||||||
this.updateContext({ abortController: null });
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
SendIcon,
|
SendIcon,
|
||||||
} from '@blocksuite/icons/lit';
|
} from '@blocksuite/icons/lit';
|
||||||
|
|
||||||
import { AIProvider } from '../../provider/ai-provider.js';
|
import { AIAppEvents } from '../../provider/ai-app-events.js';
|
||||||
import completeWritingWithAI from './templates/completeWritingWithAI.zip';
|
import completeWritingWithAI from './templates/completeWritingWithAI.zip';
|
||||||
import freelyCommunicateWithAI from './templates/freelyCommunicateWithAI.zip';
|
import freelyCommunicateWithAI from './templates/freelyCommunicateWithAI.zip';
|
||||||
import readAforeign from './templates/readAforeign.zip';
|
import readAforeign from './templates/readAforeign.zip';
|
||||||
@@ -19,7 +19,7 @@ export const AIPreloadConfig = [
|
|||||||
text: 'Read a foreign language article with AI',
|
text: 'Read a foreign language article with AI',
|
||||||
testId: 'read-foreign-language-article-with-ai',
|
testId: 'read-foreign-language-article-with-ai',
|
||||||
handler: () => {
|
handler: () => {
|
||||||
AIProvider.slots.requestInsertTemplate.next({
|
AIAppEvents.requestInsertTemplate.next({
|
||||||
template: readAforeign,
|
template: readAforeign,
|
||||||
mode: 'edgeless',
|
mode: 'edgeless',
|
||||||
});
|
});
|
||||||
@@ -30,7 +30,7 @@ export const AIPreloadConfig = [
|
|||||||
text: 'Tidy an article with AI MindMap Action',
|
text: 'Tidy an article with AI MindMap Action',
|
||||||
testId: 'tidy-an-article-with-ai-mindmap-action',
|
testId: 'tidy-an-article-with-ai-mindmap-action',
|
||||||
handler: () => {
|
handler: () => {
|
||||||
AIProvider.slots.requestInsertTemplate.next({
|
AIAppEvents.requestInsertTemplate.next({
|
||||||
template: TidyMindMapV3,
|
template: TidyMindMapV3,
|
||||||
mode: 'edgeless',
|
mode: 'edgeless',
|
||||||
});
|
});
|
||||||
@@ -41,7 +41,7 @@ export const AIPreloadConfig = [
|
|||||||
text: 'Add illustrations to the article',
|
text: 'Add illustrations to the article',
|
||||||
testId: 'add-illustrations-to-the-article',
|
testId: 'add-illustrations-to-the-article',
|
||||||
handler: () => {
|
handler: () => {
|
||||||
AIProvider.slots.requestInsertTemplate.next({
|
AIAppEvents.requestInsertTemplate.next({
|
||||||
template: redHat,
|
template: redHat,
|
||||||
mode: 'edgeless',
|
mode: 'edgeless',
|
||||||
});
|
});
|
||||||
@@ -52,7 +52,7 @@ export const AIPreloadConfig = [
|
|||||||
text: 'Complete writing with AI',
|
text: 'Complete writing with AI',
|
||||||
testId: 'complete-writing-with-ai',
|
testId: 'complete-writing-with-ai',
|
||||||
handler: () => {
|
handler: () => {
|
||||||
AIProvider.slots.requestInsertTemplate.next({
|
AIAppEvents.requestInsertTemplate.next({
|
||||||
template: completeWritingWithAI,
|
template: completeWritingWithAI,
|
||||||
mode: 'edgeless',
|
mode: 'edgeless',
|
||||||
});
|
});
|
||||||
@@ -63,7 +63,7 @@ export const AIPreloadConfig = [
|
|||||||
text: 'Freely communicate with AI',
|
text: 'Freely communicate with AI',
|
||||||
testId: 'freely-communicate-with-ai',
|
testId: 'freely-communicate-with-ai',
|
||||||
handler: () => {
|
handler: () => {
|
||||||
AIProvider.slots.requestInsertTemplate.next({
|
AIAppEvents.requestInsertTemplate.next({
|
||||||
template: freelyCommunicateWithAI,
|
template: freelyCommunicateWithAI,
|
||||||
mode: 'edgeless',
|
mode: 'edgeless',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ import { css, html, type PropertyValues } from 'lit';
|
|||||||
import { property } from 'lit/decorators.js';
|
import { property } from 'lit/decorators.js';
|
||||||
import { repeat } from 'lit/directives/repeat.js';
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
|
|
||||||
|
import type { AIChatRuntime, AIChatSnapshot } from '../../runtime/chat';
|
||||||
|
|
||||||
const DEFAULT_TAB_TITLE = 'New chat';
|
const DEFAULT_TAB_TITLE = 'New chat';
|
||||||
const TITLE_MAX_LENGTH = 28;
|
const TITLE_MAX_LENGTH = 28;
|
||||||
|
|
||||||
|
type RenderTabItem =
|
||||||
|
| { kind: 'draft' }
|
||||||
|
| { kind: 'session'; session: CopilotChatHistoryFragment };
|
||||||
|
|
||||||
function truncate(text: string): string {
|
function truncate(text: string): string {
|
||||||
if (text.length <= TITLE_MAX_LENGTH) return text;
|
if (text.length <= TITLE_MAX_LENGTH) return text;
|
||||||
return `${text.slice(0, TITLE_MAX_LENGTH).trimEnd()}…`;
|
return `${text.slice(0, TITLE_MAX_LENGTH).trimEnd()}…`;
|
||||||
@@ -27,19 +33,10 @@ function deriveTabTitle(session: CopilotChatHistoryFragment): string {
|
|||||||
|
|
||||||
export class AIChatTabs extends WithDisposable(ShadowlessElement) {
|
export class AIChatTabs extends WithDisposable(ShadowlessElement) {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor sessions: CopilotChatHistoryFragment[] = [];
|
accessor runtimeSnapshot: AIChatSnapshot | null | undefined;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor activeSessionId: string | undefined;
|
accessor runtime!: AIChatRuntime;
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor showDraftTab = false;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor onSelectTab!: (sessionId: string) => void;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor onCloseTab!: (sessionId: string) => void;
|
|
||||||
|
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
ai-chat-tabs {
|
ai-chat-tabs {
|
||||||
@@ -100,6 +97,10 @@ export class AIChatTabs extends WithDisposable(ShadowlessElement) {
|
|||||||
color: ${unsafeCSSVarV2('text/primary')};
|
color: ${unsafeCSSVarV2('text/primary')};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab[data-kind='draft'] {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.tab-title {
|
.tab-title {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -132,21 +133,46 @@ export class AIChatTabs extends WithDisposable(ShadowlessElement) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
if (!this.sessions.length && !this.showDraftTab) return html``;
|
const items = this._getRenderItems();
|
||||||
|
if (!items.length) return html``;
|
||||||
return html`
|
return html`
|
||||||
<div class="ai-chat-tabs" data-testid="ai-chat-tabs">
|
<div class="ai-chat-tabs" data-testid="ai-chat-tabs">
|
||||||
<div class="tabs-scroll" @wheel=${this._handleWheel}>
|
<div class="tabs-scroll" @wheel=${this._handleWheel}>
|
||||||
${this.showDraftTab ? this._renderDraftTab() : null}
|
|
||||||
${repeat(
|
${repeat(
|
||||||
this.sessions,
|
items,
|
||||||
session => session.sessionId,
|
item => (item.kind === 'draft' ? 'draft' : item.session.sessionId),
|
||||||
session => this._renderTab(session)
|
item =>
|
||||||
|
item.kind === 'draft'
|
||||||
|
? this._renderDraftTab()
|
||||||
|
: this._renderTab(item.session)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _getRenderItems(): RenderTabItem[] {
|
||||||
|
const snapshot = this.runtimeSnapshot;
|
||||||
|
if (!snapshot) return [];
|
||||||
|
const sessions = new Map(
|
||||||
|
snapshot.sessions.map(session => [session.sessionId, session])
|
||||||
|
);
|
||||||
|
const items: RenderTabItem[] = [];
|
||||||
|
for (const tab of snapshot.tabs) {
|
||||||
|
if (tab.kind === 'draft') {
|
||||||
|
if (snapshot.uiPolicy.showDraftTab) {
|
||||||
|
items.push({ kind: 'draft' });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const session = sessions.get(tab.sessionId);
|
||||||
|
if (session) {
|
||||||
|
items.push({ kind: 'session', session });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
private readonly _handleWheel = (e: WheelEvent) => {
|
private readonly _handleWheel = (e: WheelEvent) => {
|
||||||
const el = e.currentTarget as HTMLElement;
|
const el = e.currentTarget as HTMLElement;
|
||||||
if (el.scrollWidth <= el.clientWidth) return;
|
if (el.scrollWidth <= el.clientWidth) return;
|
||||||
@@ -157,7 +183,7 @@ export class AIChatTabs extends WithDisposable(ShadowlessElement) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private _renderTab(session: CopilotChatHistoryFragment) {
|
private _renderTab(session: CopilotChatHistoryFragment) {
|
||||||
const active = session.sessionId === this.activeSessionId;
|
const active = session.sessionId === this.runtimeSnapshot?.activeSessionId;
|
||||||
const title = deriveTabTitle(session);
|
const title = deriveTabTitle(session);
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
@@ -182,10 +208,12 @@ export class AIChatTabs extends WithDisposable(ShadowlessElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _renderDraftTab() {
|
private _renderDraftTab() {
|
||||||
|
const active = this.runtimeSnapshot?.activeTabId?.startsWith('draft:');
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="tab"
|
class="tab"
|
||||||
data-active="true"
|
data-kind="draft"
|
||||||
|
data-active=${active}
|
||||||
data-testid="ai-chat-draft-tab"
|
data-testid="ai-chat-draft-tab"
|
||||||
title=${DEFAULT_TAB_TITLE}
|
title=${DEFAULT_TAB_TITLE}
|
||||||
>
|
>
|
||||||
@@ -195,23 +223,27 @@ export class AIChatTabs extends WithDisposable(ShadowlessElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private readonly _handleSelect = (sessionId: string) => {
|
private readonly _handleSelect = (sessionId: string) => {
|
||||||
if (sessionId === this.activeSessionId) return;
|
if (sessionId === this.runtimeSnapshot?.activeSessionId) return;
|
||||||
this.onSelectTab(sessionId);
|
this.runtime
|
||||||
|
.dispatch({ type: 'openSession', sessionId })
|
||||||
|
.catch(console.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly _handleClose = (e: Event, sessionId: string) => {
|
private readonly _handleClose = (e: Event, sessionId: string) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
this.onCloseTab(sessionId);
|
this.runtime
|
||||||
|
.dispatch({ type: 'closeTab', tabId: sessionId })
|
||||||
|
.catch(console.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
override updated(changedProps: PropertyValues) {
|
override updated(changedProps: PropertyValues) {
|
||||||
super.updated(changedProps);
|
super.updated(changedProps);
|
||||||
if (
|
if (changedProps.has('runtimeSnapshot')) {
|
||||||
(changedProps.has('activeSessionId') || changedProps.has('sessions')) &&
|
const activeSessionId = this.runtimeSnapshot?.activeSessionId;
|
||||||
this.activeSessionId
|
|
||||||
) {
|
|
||||||
const activeTab = this.renderRoot.querySelector(
|
const activeTab = this.renderRoot.querySelector(
|
||||||
`[data-session-id="${this.activeSessionId}"]`
|
activeSessionId
|
||||||
|
? `[data-session-id="${activeSessionId}"]`
|
||||||
|
: `[data-testid="ai-chat-draft-tab"]`
|
||||||
);
|
);
|
||||||
activeTab?.scrollIntoView({
|
activeTab?.scrollIntoView({
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
|
|||||||
@@ -14,34 +14,22 @@ import { flip, offset } from '@floating-ui/dom';
|
|||||||
import { css, html } from 'lit';
|
import { css, html } from 'lit';
|
||||||
import { property, query } from 'lit/decorators.js';
|
import { property, query } from 'lit/decorators.js';
|
||||||
|
|
||||||
|
import type { AIChatRuntime, AIChatSnapshot } from '../../runtime/chat';
|
||||||
import type { DocDisplayConfig } from '../ai-chat-chips';
|
import type { DocDisplayConfig } from '../ai-chat-chips';
|
||||||
import type { ChatStatus } from '../ai-chat-messages';
|
|
||||||
|
|
||||||
export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor session!: CopilotChatHistoryFragment | null | undefined;
|
accessor session!: CopilotChatHistoryFragment | null | undefined;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor workspaceId!: string;
|
accessor runtime!: AIChatRuntime;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor runtimeSnapshot!: AIChatSnapshot;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor docId: string | undefined;
|
accessor docId: string | undefined;
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor status!: ChatStatus;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor onNewSession!: () => void;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor canCreateNewSession = true;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor onTogglePin!: () => Promise<void>;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
|
||||||
accessor onOpenSession!: (sessionId: string) => void;
|
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor onOpenDoc!: (docId: string, sessionId: string) => void;
|
accessor onOpenDoc!: (docId: string, sessionId: string) => void;
|
||||||
|
|
||||||
@@ -62,7 +50,12 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
|||||||
private abortController: AbortController | null = null;
|
private abortController: AbortController | null = null;
|
||||||
|
|
||||||
get isGenerating() {
|
get isGenerating() {
|
||||||
return this.status === 'transmitting' || this.status === 'loading';
|
const status = this.runtimeSnapshot.status;
|
||||||
|
return status === 'transmitting' || status === 'loading';
|
||||||
|
}
|
||||||
|
|
||||||
|
get canCreateNewSession() {
|
||||||
|
return this.runtimeSnapshot.uiPolicy.canCreateNewSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
@@ -141,7 +134,7 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.onTogglePin();
|
await this.runtime.dispatch({ type: 'togglePinActiveSession' });
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly unpinConfirm = async () => {
|
private readonly unpinConfirm = async () => {
|
||||||
@@ -157,7 +150,7 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
|||||||
if (!confirm) {
|
if (!confirm) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
await this.onTogglePin();
|
await this.runtime.dispatch({ type: 'togglePinActiveSession' });
|
||||||
} catch {
|
} catch {
|
||||||
this.notificationService.toast('Failed to unpin the chat');
|
this.notificationService.toast('Failed to unpin the chat');
|
||||||
}
|
}
|
||||||
@@ -168,7 +161,7 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
|||||||
private readonly onPlusClick = async () => {
|
private readonly onPlusClick = async () => {
|
||||||
const confirm = await this.unpinConfirm();
|
const confirm = await this.unpinConfirm();
|
||||||
if (confirm) {
|
if (confirm) {
|
||||||
this.onNewSession();
|
await this.runtime.dispatch({ type: 'createNewSession' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -179,7 +172,11 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
|||||||
}
|
}
|
||||||
const confirm = await this.unpinConfirm();
|
const confirm = await this.unpinConfirm();
|
||||||
if (confirm) {
|
if (confirm) {
|
||||||
this.onOpenSession(sessionId);
|
await this.runtime.dispatch({
|
||||||
|
type: 'openSession',
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
this.closeHistoryMenu();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -191,7 +188,7 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
|||||||
this.onOpenDoc(docId, sessionId);
|
this.onOpenDoc(docId, sessionId);
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly toggleHistoryMenu = () => {
|
private readonly toggleHistoryMenu = async () => {
|
||||||
if (this.abortController) {
|
if (this.abortController) {
|
||||||
this.abortController.abort();
|
this.abortController.abort();
|
||||||
return;
|
return;
|
||||||
@@ -202,12 +199,23 @@ export class AIChatToolbar extends WithDisposable(ShadowlessElement) {
|
|||||||
this.abortController = null;
|
this.abortController = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.runtime.dispatch({ type: 'refreshHistory' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
if (this.abortController.signal.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
createLitPortal({
|
createLitPortal({
|
||||||
template: html`
|
template: html`
|
||||||
<ai-session-history
|
<ai-session-history
|
||||||
.session=${this.session}
|
.session=${this.session}
|
||||||
.workspaceId=${this.workspaceId}
|
|
||||||
.docId=${this.docId}
|
.docId=${this.docId}
|
||||||
|
.recentSessions=${this.runtime.getSnapshot().history.recent}
|
||||||
|
.currentDocSessions=${this.runtime.getSnapshot().history.currentDoc}
|
||||||
|
.loading=${this.runtime.getSnapshot().history.loading}
|
||||||
.docDisplayConfig=${this.docDisplayConfig}
|
.docDisplayConfig=${this.docDisplayConfig}
|
||||||
.onSessionClick=${this.onSessionClick}
|
.onSessionClick=${this.onSessionClick}
|
||||||
.onSessionDelete=${this.onSessionDelete}
|
.onSessionDelete=${this.onSessionDelete}
|
||||||
|
|||||||
@@ -5,9 +5,8 @@ import { unsafeCSSVar, unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
|||||||
import { ShadowlessElement } from '@blocksuite/affine/std';
|
import { ShadowlessElement } from '@blocksuite/affine/std';
|
||||||
import { DeleteIcon } from '@blocksuite/icons/lit';
|
import { DeleteIcon } from '@blocksuite/icons/lit';
|
||||||
import { css, html, nothing, type PropertyValues } from 'lit';
|
import { css, html, nothing, type PropertyValues } from 'lit';
|
||||||
import { property, query, state } from 'lit/decorators.js';
|
import { property, state } from 'lit/decorators.js';
|
||||||
|
|
||||||
import { AIProvider } from '../../provider';
|
|
||||||
import type { DocDisplayConfig } from '../ai-chat-chips';
|
import type { DocDisplayConfig } from '../ai-chat-chips';
|
||||||
|
|
||||||
interface GroupedSessions {
|
interface GroupedSessions {
|
||||||
@@ -17,6 +16,31 @@ interface GroupedSessions {
|
|||||||
older: BlockSuitePresets.AIRecentSession[];
|
older: BlockSuitePresets.AIRecentSession[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HistorySessionWithMessages = BlockSuitePresets.AIRecentSession &
|
||||||
|
Partial<Pick<CopilotChatHistoryFragment, 'messages'>>;
|
||||||
|
|
||||||
|
const DEFAULT_SESSION_TITLE = 'New chat';
|
||||||
|
const TITLE_MAX_LENGTH = 28;
|
||||||
|
|
||||||
|
function truncateSessionTitle(text: string) {
|
||||||
|
if (text.length <= TITLE_MAX_LENGTH) return text;
|
||||||
|
return `${text.slice(0, TITLE_MAX_LENGTH).trimEnd()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveSessionTitle(session: HistorySessionWithMessages) {
|
||||||
|
const explicit = session.title?.trim();
|
||||||
|
if (explicit) return truncateSessionTitle(explicit);
|
||||||
|
const firstUserMessage = session.messages?.find(
|
||||||
|
message => message.role === 'user'
|
||||||
|
);
|
||||||
|
const raw = firstUserMessage?.content?.trim();
|
||||||
|
if (!raw) return DEFAULT_SESSION_TITLE;
|
||||||
|
const newlineIdx = raw.indexOf('\n');
|
||||||
|
return truncateSessionTitle(
|
||||||
|
newlineIdx === -1 ? raw : raw.slice(0, newlineIdx)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
.ai-session-history {
|
.ai-session-history {
|
||||||
@@ -157,10 +181,16 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
|||||||
accessor session!: CopilotChatHistoryFragment | null | undefined;
|
accessor session!: CopilotChatHistoryFragment | null | undefined;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor workspaceId!: string;
|
accessor docId: string | undefined;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor docId: string | undefined;
|
accessor recentSessions: BlockSuitePresets.AIRecentSession[] = [];
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor currentDocSessions: BlockSuitePresets.AIRecentSession[] = [];
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor loading = false;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor docDisplayConfig!: DocDisplayConfig;
|
accessor docDisplayConfig!: DocDisplayConfig;
|
||||||
@@ -176,30 +206,9 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor onDocClick!: (docId: string, sessionId: string) => void;
|
accessor onDocClick!: (docId: string, sessionId: string) => void;
|
||||||
|
|
||||||
@query('.ai-session-history')
|
|
||||||
accessor scrollContainer!: HTMLElement;
|
|
||||||
|
|
||||||
@state()
|
|
||||||
private accessor sessions: BlockSuitePresets.AIRecentSession[] | undefined;
|
|
||||||
|
|
||||||
@state()
|
|
||||||
private accessor currentDocSessions:
|
|
||||||
| BlockSuitePresets.AIRecentSession[]
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
@state()
|
|
||||||
private accessor loadingMore = false;
|
|
||||||
|
|
||||||
@state()
|
|
||||||
private accessor hasMore = true;
|
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
private accessor selectedSessionId: string | undefined;
|
private accessor selectedSessionId: string | undefined;
|
||||||
|
|
||||||
private accessor currentOffset = 0;
|
|
||||||
|
|
||||||
private readonly pageSize = 10;
|
|
||||||
|
|
||||||
private groupSessionsByTime(
|
private groupSessionsByTime(
|
||||||
sessions: BlockSuitePresets.AIRecentSession[]
|
sessions: BlockSuitePresets.AIRecentSession[]
|
||||||
): GroupedSessions {
|
): GroupedSessions {
|
||||||
@@ -248,51 +257,9 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
|||||||
return grouped;
|
return grouped;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getRecentSessions() {
|
|
||||||
this.loadingMore = true;
|
|
||||||
|
|
||||||
const moreSessions =
|
|
||||||
(await AIProvider.session?.getRecentSessions(
|
|
||||||
this.workspaceId,
|
|
||||||
this.pageSize,
|
|
||||||
this.currentOffset
|
|
||||||
)) || [];
|
|
||||||
this.sessions = [...(this.sessions || []), ...moreSessions];
|
|
||||||
|
|
||||||
this.currentOffset += moreSessions.length;
|
|
||||||
this.hasMore = moreSessions.length === this.pageSize;
|
|
||||||
this.loadingMore = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getCurrentDocSessions() {
|
|
||||||
if (!this.docId) {
|
|
||||||
this.currentDocSessions = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.currentDocSessions =
|
|
||||||
(await AIProvider.session?.getSessions(this.workspaceId, this.docId, {
|
|
||||||
action: false,
|
|
||||||
fork: false,
|
|
||||||
})) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly onScroll = () => {
|
|
||||||
if (!this.hasMore || this.loadingMore) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// load more when within 50px of bottom
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = this.scrollContainer;
|
|
||||||
const threshold = 50;
|
|
||||||
if (scrollTop + clientHeight >= scrollHeight - threshold) {
|
|
||||||
this.getRecentSessions().catch(console.error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
this.selectedSessionId = this.session?.sessionId ?? undefined;
|
this.selectedSessionId = this.session?.sessionId ?? undefined;
|
||||||
this.getCurrentDocSessions().catch(console.error);
|
|
||||||
this.getRecentSessions().catch(console.error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override willUpdate(changedProperties: PropertyValues) {
|
protected override willUpdate(changedProperties: PropertyValues) {
|
||||||
@@ -301,14 +268,6 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override firstUpdated(changedProperties: PropertyValues) {
|
|
||||||
super.firstUpdated(changedProperties);
|
|
||||||
this.disposables.add(() => {
|
|
||||||
this.scrollContainer.removeEventListener('scroll', this.onScroll);
|
|
||||||
});
|
|
||||||
this.scrollContainer.addEventListener('scroll', this.onScroll);
|
|
||||||
}
|
|
||||||
|
|
||||||
private renderSessionGroup(
|
private renderSessionGroup(
|
||||||
title: string,
|
title: string,
|
||||||
sessions: BlockSuitePresets.AIRecentSession[]
|
sessions: BlockSuitePresets.AIRecentSession[]
|
||||||
@@ -320,6 +279,7 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
|||||||
<div class="ai-session-group">
|
<div class="ai-session-group">
|
||||||
<div class="ai-session-group-title">${title}</div>
|
<div class="ai-session-group-title">${title}</div>
|
||||||
${sessions.map(session => {
|
${sessions.map(session => {
|
||||||
|
const sessionTitle = deriveSessionTitle(session);
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class="ai-session-item"
|
class="ai-session-item"
|
||||||
@@ -336,7 +296,7 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
|||||||
data-session-id=${session.sessionId}
|
data-session-id=${session.sessionId}
|
||||||
>
|
>
|
||||||
<div class="ai-session-title">
|
<div class="ai-session-title">
|
||||||
${session.title || 'New chat'}
|
${sessionTitle}
|
||||||
<affine-tooltip .offsetX=${60}>
|
<affine-tooltip .offsetX=${60}>
|
||||||
Click to open this chat
|
Click to open this chat
|
||||||
</affine-tooltip>
|
</affine-tooltip>
|
||||||
@@ -395,15 +355,15 @@ export class AISessionHistory extends WithDisposable(ShadowlessElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderHistory() {
|
private renderHistory() {
|
||||||
if (!this.sessions) {
|
if (this.loading) {
|
||||||
return this.renderLoading();
|
return this.renderLoading();
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentDocSessions = this.currentDocSessions ?? [];
|
const currentDocSessions = this.currentDocSessions;
|
||||||
const currentDocSessionIds = new Set(
|
const currentDocSessionIds = new Set(
|
||||||
currentDocSessions.map(session => session.sessionId)
|
currentDocSessions.map(session => session.sessionId)
|
||||||
);
|
);
|
||||||
const otherSessions = this.sessions.filter(
|
const otherSessions = this.recentSessions.filter(
|
||||||
session =>
|
session =>
|
||||||
!currentDocSessionIds.has(session.sessionId) &&
|
!currentDocSessionIds.has(session.sessionId) &&
|
||||||
(!this.docId || session.docId !== this.docId)
|
(!this.docId || session.docId !== this.docId)
|
||||||
|
|||||||
@@ -1,21 +1,17 @@
|
|||||||
import type { CopilotChatHistoryFragment } from '@affine/graphql';
|
import type { CopilotChatHistoryFragment } from '@affine/graphql';
|
||||||
import type { NotificationService } from '@blocksuite/affine/shared/services';
|
import type { NotificationService } from '@blocksuite/affine/shared/services';
|
||||||
|
|
||||||
|
import type { AIChatRuntime, AIChatSnapshot } from '../../runtime/chat';
|
||||||
import type { DocDisplayConfig } from '../ai-chat-chips';
|
import type { DocDisplayConfig } from '../ai-chat-chips';
|
||||||
import type { ChatStatus } from '../ai-chat-messages';
|
|
||||||
import { AIChatToolbar } from './ai-chat-toolbar';
|
import { AIChatToolbar } from './ai-chat-toolbar';
|
||||||
|
|
||||||
export type ConfigureAIChatToolbarOptions = {
|
export type ConfigureAIChatToolbarOptions = {
|
||||||
session: CopilotChatHistoryFragment | null | undefined;
|
session: CopilotChatHistoryFragment | null | undefined;
|
||||||
workspaceId: string;
|
runtime: AIChatRuntime;
|
||||||
|
runtimeSnapshot: AIChatSnapshot;
|
||||||
docId?: string;
|
docId?: string;
|
||||||
status: ChatStatus;
|
|
||||||
docDisplayConfig: DocDisplayConfig;
|
docDisplayConfig: DocDisplayConfig;
|
||||||
notificationService: NotificationService;
|
notificationService: NotificationService;
|
||||||
onNewSession: () => void;
|
|
||||||
canCreateNewSession?: boolean;
|
|
||||||
onTogglePin: () => Promise<void>;
|
|
||||||
onOpenSession: (sessionId: string) => void;
|
|
||||||
onOpenDoc: (docId: string, sessionId: string) => void;
|
onOpenDoc: (docId: string, sessionId: string) => void;
|
||||||
onSessionDelete: (session: BlockSuitePresets.AIRecentSession) => void;
|
onSessionDelete: (session: BlockSuitePresets.AIRecentSession) => void;
|
||||||
};
|
};
|
||||||
@@ -31,15 +27,11 @@ export function configureAIChatToolbar(
|
|||||||
options: ConfigureAIChatToolbarOptions
|
options: ConfigureAIChatToolbarOptions
|
||||||
): AIChatToolbar {
|
): AIChatToolbar {
|
||||||
tool.session = options.session;
|
tool.session = options.session;
|
||||||
tool.workspaceId = options.workspaceId;
|
tool.runtime = options.runtime;
|
||||||
|
tool.runtimeSnapshot = options.runtimeSnapshot;
|
||||||
tool.docId = options.docId;
|
tool.docId = options.docId;
|
||||||
tool.status = options.status;
|
|
||||||
tool.docDisplayConfig = options.docDisplayConfig;
|
tool.docDisplayConfig = options.docDisplayConfig;
|
||||||
tool.notificationService = options.notificationService;
|
tool.notificationService = options.notificationService;
|
||||||
tool.onNewSession = options.onNewSession;
|
|
||||||
tool.canCreateNewSession = options.canCreateNewSession ?? true;
|
|
||||||
tool.onTogglePin = options.onTogglePin;
|
|
||||||
tool.onOpenSession = options.onOpenSession;
|
|
||||||
tool.onOpenDoc = options.onOpenDoc;
|
tool.onOpenDoc = options.onOpenDoc;
|
||||||
tool.onSessionDelete = options.onSessionDelete;
|
tool.onSessionDelete = options.onSessionDelete;
|
||||||
return tool;
|
return tool;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type { Store } from '@blocksuite/affine/store';
|
|||||||
import { css, html } from 'lit';
|
import { css, html } from 'lit';
|
||||||
import { property } from 'lit/decorators.js';
|
import { property } from 'lit/decorators.js';
|
||||||
|
|
||||||
import { AIProvider } from '../../provider';
|
|
||||||
import type { ChatContextValue } from '../ai-chat-content';
|
import type { ChatContextValue } from '../ai-chat-content';
|
||||||
|
|
||||||
export class AIHistoryClear extends WithDisposable(ShadowlessElement) {
|
export class AIHistoryClear extends WithDisposable(ShadowlessElement) {
|
||||||
@@ -26,6 +25,9 @@ export class AIHistoryClear extends WithDisposable(ShadowlessElement) {
|
|||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor onHistoryCleared!: () => void;
|
accessor onHistoryCleared!: () => void;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
accessor onClearHistory!: (sessionIds: string[]) => Promise<void> | void;
|
||||||
|
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
.chat-history-clear {
|
.chat-history-clear {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -64,11 +66,10 @@ export class AIHistoryClear extends WithDisposable(ShadowlessElement) {
|
|||||||
const actionIds = this.chatContextValue.messages
|
const actionIds = this.chatContextValue.messages
|
||||||
.filter(item => 'sessionId' in item)
|
.filter(item => 'sessionId' in item)
|
||||||
.map(item => item.sessionId);
|
.map(item => item.sessionId);
|
||||||
await AIProvider.histories?.cleanup(
|
await this.onClearHistory([
|
||||||
this.doc.workspace.id,
|
...(sessionId ? [sessionId] : []),
|
||||||
this.doc.id,
|
...(actionIds || []),
|
||||||
[...(sessionId ? [sessionId] : []), ...(actionIds || [])]
|
]);
|
||||||
);
|
|
||||||
this.notificationService.toast('History cleared');
|
this.notificationService.toast('History cleared');
|
||||||
this.onHistoryCleared?.();
|
this.onHistoryCleared?.();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { flip, offset } from '@floating-ui/dom';
|
|||||||
import { css, html, LitElement } from 'lit';
|
import { css, html, LitElement } from 'lit';
|
||||||
import { property } from 'lit/decorators.js';
|
import { property } from 'lit/decorators.js';
|
||||||
|
|
||||||
import { AIProvider } from '../provider';
|
import { AIAppEvents } from '../provider';
|
||||||
import { getAIPanelWidget } from '../utils/ai-widgets';
|
import { getAIPanelWidget } from '../utils/ai-widgets';
|
||||||
import { extractSelectedContent } from '../utils/extract';
|
import { extractSelectedContent } from '../utils/extract';
|
||||||
import type { AffineAIPanelWidgetConfig } from '../widgets/ai-panel/type';
|
import type { AffineAIPanelWidgetConfig } from '../widgets/ai-panel/type';
|
||||||
@@ -75,7 +75,7 @@ export class AskAIToolbarButton extends WithDisposable(LitElement) {
|
|||||||
aiPanel.hide();
|
aiPanel.hide();
|
||||||
extractSelectedContent(this.host)
|
extractSelectedContent(this.host)
|
||||||
.then(context => {
|
.then(context => {
|
||||||
AIProvider.slots.requestSendWithChat.next({
|
AIAppEvents.requestSendWithChat.next({
|
||||||
input,
|
input,
|
||||||
context,
|
context,
|
||||||
host: this.host,
|
host: this.host,
|
||||||
|
|||||||
@@ -7,10 +7,7 @@ import type {
|
|||||||
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||||
import type { AppThemeService } from '@affine/core/modules/theme';
|
import type { AppThemeService } from '@affine/core/modules/theme';
|
||||||
import type {
|
import type { CopilotChatHistoryFragment } from '@affine/graphql';
|
||||||
ContextEmbedStatus,
|
|
||||||
CopilotChatHistoryFragment,
|
|
||||||
} from '@affine/graphql';
|
|
||||||
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit';
|
||||||
import { type NotificationService } from '@blocksuite/affine/shared/services';
|
import { type NotificationService } from '@blocksuite/affine/shared/services';
|
||||||
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
import { unsafeCSSVarV2 } from '@blocksuite/affine/shared/theme';
|
||||||
@@ -24,7 +21,13 @@ import { createRef, type Ref, ref } from 'lit/directives/ref.js';
|
|||||||
import { throttle } from 'lodash-es';
|
import { throttle } from 'lodash-es';
|
||||||
|
|
||||||
import type { AppSidebarConfig } from '../../chat-panel/chat-config';
|
import type { AppSidebarConfig } from '../../chat-panel/chat-config';
|
||||||
import { AIProvider } from '../../provider';
|
import { AIAppEvents, type AIError } from '../../provider';
|
||||||
|
import {
|
||||||
|
AIChatRuntime,
|
||||||
|
type AIChatSnapshot,
|
||||||
|
PlaygroundAIChatSessionStrategy,
|
||||||
|
} from '../../runtime/chat';
|
||||||
|
import { getAIRequestService } from '../../runtime/request';
|
||||||
import { HISTORY_IMAGE_ACTIONS } from '../../utils/history-image-actions';
|
import { HISTORY_IMAGE_ACTIONS } from '../../utils/history-image-actions';
|
||||||
import type { SearchMenuConfig } from '../ai-chat-add-context';
|
import type { SearchMenuConfig } from '../ai-chat-add-context';
|
||||||
import type { DocDisplayConfig } from '../ai-chat-chips';
|
import type { DocDisplayConfig } from '../ai-chat-chips';
|
||||||
@@ -32,8 +35,6 @@ import type { ChatContextValue } from '../ai-chat-content';
|
|||||||
import type { AIPlaygroundConfig, AIReasoningConfig } from '../ai-chat-input';
|
import type { AIPlaygroundConfig, AIReasoningConfig } from '../ai-chat-input';
|
||||||
import {
|
import {
|
||||||
type AIChatMessages,
|
type AIChatMessages,
|
||||||
type ChatAction,
|
|
||||||
type ChatMessage,
|
|
||||||
type HistoryMessage,
|
type HistoryMessage,
|
||||||
isChatMessage,
|
isChatMessage,
|
||||||
} from '../ai-chat-messages';
|
} from '../ai-chat-messages';
|
||||||
@@ -202,16 +203,20 @@ export class PlaygroundChat extends SignalWatcher(
|
|||||||
accessor chatContextValue: ChatContextValue = DEFAULT_CHAT_CONTEXT_VALUE;
|
accessor chatContextValue: ChatContextValue = DEFAULT_CHAT_CONTEXT_VALUE;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor embeddingProgress: [number, number] = [0, 0];
|
accessor runtimeSnapshot: AIChatSnapshot | null = null;
|
||||||
|
|
||||||
private readonly _chatMessagesRef: Ref<AIChatMessages> =
|
private readonly _chatMessagesRef: Ref<AIChatMessages> =
|
||||||
createRef<AIChatMessages>();
|
createRef<AIChatMessages>();
|
||||||
|
|
||||||
// request counter to track the latest request
|
private runtime: AIChatRuntime | null = null;
|
||||||
private _updateHistoryCounter = 0;
|
|
||||||
|
private disposeRuntime: (() => void) | null = null;
|
||||||
|
|
||||||
get messages() {
|
get messages() {
|
||||||
return this.chatContextValue.messages.filter(item => {
|
const messages =
|
||||||
|
(this.runtimeSnapshot?.messages as HistoryMessage[] | undefined) ??
|
||||||
|
this.chatContextValue.messages;
|
||||||
|
return messages.filter(item => {
|
||||||
return (
|
return (
|
||||||
isChatMessage(item) ||
|
isChatMessage(item) ||
|
||||||
item.messages?.length === 3 ||
|
item.messages?.length === 3 ||
|
||||||
@@ -226,66 +231,46 @@ export class PlaygroundChat extends SignalWatcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private readonly _initPanel = async () => {
|
private readonly _initPanel = async () => {
|
||||||
const userId = (await AIProvider.userInfo)?.id;
|
const userId = AIAppEvents.userInfo.value?.id;
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
this.isLoading = true;
|
this.ensureRuntime();
|
||||||
await this._updateHistory();
|
|
||||||
this.isLoading = false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly _createSession = async () => {
|
private readonly syncContextFromRuntime = () => {
|
||||||
return this.session;
|
const snapshot = this.runtimeSnapshot;
|
||||||
};
|
if (!snapshot) return;
|
||||||
|
|
||||||
private readonly _updateHistory = async () => {
|
|
||||||
if (!AIProvider.histories) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentRequest = ++this._updateHistoryCounter;
|
|
||||||
|
|
||||||
const sessionId = this.session?.sessionId;
|
|
||||||
const [histories, actions] = await Promise.all([
|
|
||||||
sessionId
|
|
||||||
? AIProvider.histories.chats(
|
|
||||||
this.doc.workspace.id,
|
|
||||||
sessionId,
|
|
||||||
this.doc.id
|
|
||||||
)
|
|
||||||
: Promise.resolve([]),
|
|
||||||
this.doc.id && this.showActions
|
|
||||||
? AIProvider.histories.actions(this.doc.workspace.id, this.doc.id)
|
|
||||||
: Promise.resolve([]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Check if this is still the latest request
|
|
||||||
if (currentRequest !== this._updateHistoryCounter) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chatActions = (actions || []) as ChatAction[];
|
|
||||||
const messages: HistoryMessage[] = chatActions;
|
|
||||||
|
|
||||||
const chatMessages = (histories?.[0]?.messages || []) as ChatMessage[];
|
|
||||||
messages.push(...chatMessages);
|
|
||||||
|
|
||||||
this.chatContextValue = {
|
this.chatContextValue = {
|
||||||
...this.chatContextValue,
|
...this.chatContextValue,
|
||||||
messages: messages.sort(
|
messages: snapshot.messages as HistoryMessage[],
|
||||||
(a, b) =>
|
status: snapshot.status,
|
||||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
error: snapshot.error as AIError | null,
|
||||||
),
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
this._scrollToEnd();
|
private readonly ensureRuntime = () => {
|
||||||
};
|
if (!this.session || this.runtime) return;
|
||||||
|
this.runtime = new AIChatRuntime({
|
||||||
private readonly onEmbeddingProgressChange = (
|
request: getAIRequestService(),
|
||||||
count: Record<ContextEmbedStatus, number>
|
scope: {
|
||||||
) => {
|
kind: 'fork',
|
||||||
const total = count.finished + count.processing + count.failed;
|
workspaceId: this.doc.workspace.id,
|
||||||
this.embeddingProgress = [count.finished, total];
|
docId: this.doc.id,
|
||||||
|
parentSessionId: this.session.parentSessionId ?? this.session.sessionId,
|
||||||
|
},
|
||||||
|
strategy: new PlaygroundAIChatSessionStrategy(),
|
||||||
|
});
|
||||||
|
this.disposeRuntime = this.runtime.subscribe(() => {
|
||||||
|
this.runtimeSnapshot = this.runtime?.getSnapshot() ?? null;
|
||||||
|
this.syncContextFromRuntime();
|
||||||
|
});
|
||||||
|
this.runtimeSnapshot = this.runtime.getSnapshot();
|
||||||
|
this.runtime
|
||||||
|
.dispatch({
|
||||||
|
type: 'openSessionObject',
|
||||||
|
session: this.session,
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly updateContext = (context: Partial<ChatContextValue>) => {
|
private readonly updateContext = (context: Partial<ChatContextValue>) => {
|
||||||
@@ -303,7 +288,23 @@ export class PlaygroundChat extends SignalWatcher(
|
|||||||
this._initPanel().catch(console.error);
|
this._initPanel().catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this.disposeRuntime?.();
|
||||||
|
this.runtime?.dispose();
|
||||||
|
this.runtime = null;
|
||||||
|
this.disposeRuntime = null;
|
||||||
|
}
|
||||||
|
|
||||||
protected override updated(_changedProperties: PropertyValues) {
|
protected override updated(_changedProperties: PropertyValues) {
|
||||||
|
if (_changedProperties.has('session')) {
|
||||||
|
this.disposeRuntime?.();
|
||||||
|
this.runtime?.dispose();
|
||||||
|
this.runtime = null;
|
||||||
|
this.disposeRuntime = null;
|
||||||
|
this.ensureRuntime();
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
_changedProperties.has('chatContextValue') &&
|
_changedProperties.has('chatContextValue') &&
|
||||||
(this.chatContextValue.status === 'loading' ||
|
(this.chatContextValue.status === 'loading' ||
|
||||||
@@ -322,7 +323,11 @@ export class PlaygroundChat extends SignalWatcher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const [done, total] = this.embeddingProgress;
|
const embeddingCount =
|
||||||
|
this.runtimeSnapshot?.composer.context.embeddingCount;
|
||||||
|
const done = embeddingCount?.finished ?? 0;
|
||||||
|
const total =
|
||||||
|
done + (embeddingCount?.processing ?? 0) + (embeddingCount?.failed ?? 0);
|
||||||
const isEmbedding = total > 0 && done < total;
|
const isEmbedding = total > 0 && done < total;
|
||||||
|
|
||||||
return html`<div class="chat-panel-container">
|
return html`<div class="chat-panel-container">
|
||||||
@@ -342,7 +347,23 @@ export class PlaygroundChat extends SignalWatcher(
|
|||||||
.doc=${this.doc}
|
.doc=${this.doc}
|
||||||
.session=${this.session}
|
.session=${this.session}
|
||||||
.notificationService=${this.notificationService}
|
.notificationService=${this.notificationService}
|
||||||
.onHistoryCleared=${this._updateHistory}
|
.onClearHistory=${async (sessionIds: string[]) => {
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
await this.runtime?.dispatch({
|
||||||
|
type: 'deleteSession',
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
.onHistoryCleared=${() =>
|
||||||
|
this.session
|
||||||
|
? this.runtime
|
||||||
|
?.dispatch({
|
||||||
|
type: 'openSessionObject',
|
||||||
|
session: this.session,
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
: undefined}
|
||||||
.chatContextValue=${this.chatContextValue}
|
.chatContextValue=${this.chatContextValue}
|
||||||
></ai-history-clear>
|
></ai-history-clear>
|
||||||
<div class="chat-panel-delete">${DeleteIcon()}</div>
|
<div class="chat-panel-delete">${DeleteIcon()}</div>
|
||||||
@@ -355,7 +376,8 @@ export class PlaygroundChat extends SignalWatcher(
|
|||||||
.isHistoryLoading=${this.isLoading}
|
.isHistoryLoading=${this.isLoading}
|
||||||
.chatContextValue=${this.chatContextValue}
|
.chatContextValue=${this.chatContextValue}
|
||||||
.session=${this.session}
|
.session=${this.session}
|
||||||
.createSession=${this._createSession}
|
.runtime=${this.runtime}
|
||||||
|
.runtimeSnapshot=${this.runtimeSnapshot}
|
||||||
.updateContext=${this.updateContext}
|
.updateContext=${this.updateContext}
|
||||||
.extensions=${this.extensions}
|
.extensions=${this.extensions}
|
||||||
.affineFeatureFlagService=${this.affineFeatureFlagService}
|
.affineFeatureFlagService=${this.affineFeatureFlagService}
|
||||||
@@ -370,10 +392,10 @@ export class PlaygroundChat extends SignalWatcher(
|
|||||||
.workspaceId=${this.doc.workspace.id}
|
.workspaceId=${this.doc.workspace.id}
|
||||||
.docId=${this.doc.id}
|
.docId=${this.doc.id}
|
||||||
.session=${this.session}
|
.session=${this.session}
|
||||||
.createSession=${this._createSession}
|
.runtime=${this.runtime}
|
||||||
|
.runtimeSnapshot=${this.runtimeSnapshot}
|
||||||
.chatContextValue=${this.chatContextValue}
|
.chatContextValue=${this.chatContextValue}
|
||||||
.updateContext=${this.updateContext}
|
.updateContext=${this.updateContext}
|
||||||
.onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
|
|
||||||
.reasoningConfig=${this.reasoningConfig}
|
.reasoningConfig=${this.reasoningConfig}
|
||||||
.playgroundConfig=${this.playgroundConfig}
|
.playgroundConfig=${this.playgroundConfig}
|
||||||
.docDisplayConfig=${this.docDisplayConfig}
|
.docDisplayConfig=${this.docDisplayConfig}
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ import { property, state } from 'lit/decorators.js';
|
|||||||
import { repeat } from 'lit/directives/repeat.js';
|
import { repeat } from 'lit/directives/repeat.js';
|
||||||
|
|
||||||
import type { AppSidebarConfig } from '../../chat-panel/chat-config';
|
import type { AppSidebarConfig } from '../../chat-panel/chat-config';
|
||||||
import { AIProvider } from '../../provider';
|
import {
|
||||||
|
AIChatRuntime,
|
||||||
|
type AIChatScope,
|
||||||
|
PlaygroundAIChatSessionStrategy,
|
||||||
|
} from '../../runtime/chat';
|
||||||
|
import { getAIRequestService } from '../../runtime/request';
|
||||||
import type { SearchMenuConfig } from '../ai-chat-add-context';
|
import type { SearchMenuConfig } from '../ai-chat-add-context';
|
||||||
import type { DocDisplayConfig } from '../ai-chat-chips';
|
import type { DocDisplayConfig } from '../ai-chat-chips';
|
||||||
import type { AIPlaygroundConfig, AIReasoningConfig } from '../ai-chat-input';
|
import type { AIPlaygroundConfig, AIReasoningConfig } from '../ai-chat-input';
|
||||||
@@ -119,28 +124,40 @@ export class PlaygroundContent extends SignalWatcher(
|
|||||||
|
|
||||||
private isSending = false;
|
private isSending = false;
|
||||||
|
|
||||||
|
private createSessionRuntime(scope: AIChatScope) {
|
||||||
|
return new AIChatRuntime({
|
||||||
|
request: getAIRequestService(),
|
||||||
|
scope,
|
||||||
|
strategy: new PlaygroundAIChatSessionStrategy(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private readonly getSessions = async () => {
|
private readonly getSessions = async () => {
|
||||||
const sessions =
|
const sessions =
|
||||||
(await AIProvider.session?.getSessions(
|
(await getAIRequestService().getSessions(
|
||||||
this.doc.workspace.id,
|
this.doc.workspace.id,
|
||||||
this.doc.id,
|
this.doc.id,
|
||||||
{ action: false }
|
{ action: false }
|
||||||
)) || [];
|
)) || [];
|
||||||
const rootSession = sessions?.findLast(session => !session.parentSessionId);
|
const rootSession = sessions?.findLast(session => !session.parentSessionId);
|
||||||
if (!rootSession) {
|
if (!rootSession) {
|
||||||
// Create a new session
|
const runtime = this.createSessionRuntime({
|
||||||
const rootSessionId = await AIProvider.session?.createSession({
|
kind: 'playground',
|
||||||
docId: this.doc.id,
|
|
||||||
workspaceId: this.doc.workspace.id,
|
workspaceId: this.doc.workspace.id,
|
||||||
promptName: 'Chat With AFFiNE AI',
|
docId: this.doc.id,
|
||||||
});
|
});
|
||||||
if (rootSessionId) {
|
try {
|
||||||
this.rootSessionId = rootSessionId;
|
const rootSession = await runtime.createSession();
|
||||||
const forkSession = await this.forkSession(rootSessionId);
|
if (rootSession) {
|
||||||
|
this.rootSessionId = rootSession.sessionId;
|
||||||
|
const forkSession = await this.forkSession(rootSession.sessionId);
|
||||||
if (forkSession) {
|
if (forkSession) {
|
||||||
this.sessions = [forkSession];
|
this.sessions = [forkSession];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
runtime.dispose();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.rootSessionId = rootSession.sessionId;
|
this.rootSessionId = rootSession.sessionId;
|
||||||
const childSessions = sessions.filter(
|
const childSessions = sessions.filter(
|
||||||
@@ -158,19 +175,17 @@ export class PlaygroundContent extends SignalWatcher(
|
|||||||
};
|
};
|
||||||
|
|
||||||
private readonly forkSession = async (parentSessionId: string) => {
|
private readonly forkSession = async (parentSessionId: string) => {
|
||||||
const forkSessionId = await AIProvider.forkChat?.({
|
const runtime = this.createSessionRuntime({
|
||||||
|
kind: 'playground',
|
||||||
workspaceId: this.doc.workspace.id,
|
workspaceId: this.doc.workspace.id,
|
||||||
docId: this.doc.id,
|
docId: this.doc.id,
|
||||||
sessionId: parentSessionId,
|
parentSessionId,
|
||||||
latestMessageId: '',
|
|
||||||
});
|
});
|
||||||
if (!forkSessionId) {
|
try {
|
||||||
return;
|
return (await runtime.createSession()) ?? undefined;
|
||||||
|
} finally {
|
||||||
|
runtime.dispose();
|
||||||
}
|
}
|
||||||
return await AIProvider.session?.getSession(
|
|
||||||
this.doc.workspace.id,
|
|
||||||
forkSessionId
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly addChat = async () => {
|
private readonly addChat = async () => {
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import {
|
|||||||
translateLangs,
|
translateLangs,
|
||||||
} from '../../actions/types';
|
} from '../../actions/types';
|
||||||
import type { AIItemGroupConfig } from '../../components/ai-item/types';
|
import type { AIItemGroupConfig } from '../../components/ai-item/types';
|
||||||
import { AIProvider } from '../../provider';
|
import { AIAppEvents } from '../../provider';
|
||||||
import { getAIPanelWidget } from '../../utils/ai-widgets';
|
import { getAIPanelWidget } from '../../utils/ai-widgets';
|
||||||
import {
|
import {
|
||||||
getEdgelessCopilotWidget,
|
getEdgelessCopilotWidget,
|
||||||
@@ -121,7 +121,7 @@ const othersGroup: AIItemGroupConfig = {
|
|||||||
const edgelessCopilot = getEdgelessCopilotWidget(host);
|
const edgelessCopilot = getEdgelessCopilotWidget(host);
|
||||||
extractSelectedContent(host)
|
extractSelectedContent(host)
|
||||||
.then(context => {
|
.then(context => {
|
||||||
AIProvider.slots.requestOpenWithChat.next({
|
AIAppEvents.requestOpenWithChat.next({
|
||||||
host,
|
host,
|
||||||
mode: 'edgeless',
|
mode: 'edgeless',
|
||||||
autoSelect: true,
|
autoSelect: true,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import { html } from 'lit';
|
import { html } from 'lit';
|
||||||
|
|
||||||
import type { AIItemGroupConfig } from '../../components/ai-item/types';
|
import type { AIItemGroupConfig } from '../../components/ai-item/types';
|
||||||
import { AIProvider } from '../../provider';
|
import { AIAppEvents } from '../../provider';
|
||||||
import { getAIPanelWidget } from '../../utils/ai-widgets';
|
import { getAIPanelWidget } from '../../utils/ai-widgets';
|
||||||
import { getEdgelessCopilotWidget } from '../../utils/edgeless';
|
import { getEdgelessCopilotWidget } from '../../utils/edgeless';
|
||||||
import { extractSelectedContent } from '../../utils/extract';
|
import { extractSelectedContent } from '../../utils/extract';
|
||||||
@@ -58,14 +58,14 @@ export function edgelessToolbarAIEntryConfig(): ToolbarModuleConfig {
|
|||||||
extractSelectedContent(host)
|
extractSelectedContent(host)
|
||||||
.then(context => {
|
.then(context => {
|
||||||
if (context?.attachments?.length || context?.docs?.length) {
|
if (context?.attachments?.length || context?.docs?.length) {
|
||||||
AIProvider.slots.requestOpenWithChat.next({
|
AIAppEvents.requestOpenWithChat.next({
|
||||||
input,
|
input,
|
||||||
host,
|
host,
|
||||||
context,
|
context,
|
||||||
autoSelect: true,
|
autoSelect: true,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
AIProvider.slots.requestSendWithChat.next({
|
AIAppEvents.requestSendWithChat.next({
|
||||||
input,
|
input,
|
||||||
context,
|
context,
|
||||||
host,
|
host,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { RichText } from '@blocksuite/affine/rich-text';
|
|||||||
import { type EditorHost, TextSelection } from '@blocksuite/affine/std';
|
import { type EditorHost, TextSelection } from '@blocksuite/affine/std';
|
||||||
|
|
||||||
import { handleInlineAskAIAction } from '../../actions/doc-handler';
|
import { handleInlineAskAIAction } from '../../actions/doc-handler';
|
||||||
import { AIProvider } from '../../provider';
|
import { hasAIRequestService } from '../../runtime/request';
|
||||||
import type { AffineAIPanelWidget } from '../../widgets/ai-panel/ai-panel';
|
import type { AffineAIPanelWidget } from '../../widgets/ai-panel/ai-panel';
|
||||||
|
|
||||||
function isSpaceEvent(event: KeyboardEvent) {
|
function isSpaceEvent(event: KeyboardEvent) {
|
||||||
@@ -43,7 +43,7 @@ export function setupSpaceAIEntry(panel: AffineAIPanelWidget) {
|
|||||||
const host = panel.host;
|
const host = panel.host;
|
||||||
const keyboardState = ctx.get('keyboardState');
|
const keyboardState = ctx.get('keyboardState');
|
||||||
const event = keyboardState.raw;
|
const event = keyboardState.raw;
|
||||||
if (AIProvider.actions.chat && isSpaceEvent(event)) {
|
if (hasAIRequestService() && isSpaceEvent(event)) {
|
||||||
// If the AI panel is in the input state and the input content is empty,
|
// If the AI panel is in the input state and the input content is empty,
|
||||||
// insert a space back into the editor.
|
// insert a space back into the editor.
|
||||||
if (panel.state === 'input') {
|
if (panel.state === 'input') {
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ export * from './entries/edgeless/actions-config';
|
|||||||
export * from './messages';
|
export * from './messages';
|
||||||
export { AIChatBlockPeekViewTemplate } from './peek-view/chat-block-peek-view';
|
export { AIChatBlockPeekViewTemplate } from './peek-view/chat-block-peek-view';
|
||||||
export * from './provider';
|
export * from './provider';
|
||||||
|
export * from './runtime/chat';
|
||||||
|
export * from './runtime/request';
|
||||||
export * from './utils/edgeless';
|
export * from './utils/edgeless';
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import { css, html, LitElement, nothing, unsafeCSS } from 'lit';
|
|||||||
import { property } from 'lit/decorators.js';
|
import { property } from 'lit/decorators.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AIAppEvents,
|
||||||
type AIError,
|
type AIError,
|
||||||
AIProvider,
|
|
||||||
PaymentRequiredError,
|
PaymentRequiredError,
|
||||||
UnauthorizedError,
|
UnauthorizedError,
|
||||||
} from '../provider';
|
} from '../provider';
|
||||||
@@ -190,7 +190,7 @@ const PaymentRequiredErrorRenderer = (host?: EditorHost | null) => html`
|
|||||||
<ai-error-wrapper
|
<ai-error-wrapper
|
||||||
.text=${"You've reached the current usage cap for AFFiNE AI. You can subscribe to AFFiNE AI(with free 7-day-trial) to continue the AI experience!"}
|
.text=${"You've reached the current usage cap for AFFiNE AI. You can subscribe to AFFiNE AI(with free 7-day-trial) to continue the AI experience!"}
|
||||||
.actionText=${'Upgrade'}
|
.actionText=${'Upgrade'}
|
||||||
.onClick=${() => AIProvider.slots.requestUpgradePlan.next({ host })}
|
.onClick=${() => AIAppEvents.requestUpgradePlan.next({ host })}
|
||||||
></ai-error-wrapper>
|
></ai-error-wrapper>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ const LoginRequiredErrorRenderer = (host?: EditorHost | null) => html`
|
|||||||
<ai-error-wrapper
|
<ai-error-wrapper
|
||||||
.text=${'You need to login to AFFiNE Cloud to continue using AFFiNE AI.'}
|
.text=${'You need to login to AFFiNE Cloud to continue using AFFiNE AI.'}
|
||||||
.actionText=${'Login'}
|
.actionText=${'Login'}
|
||||||
.onClick=${() => AIProvider.slots.requestLogin.next({ host })}
|
.onClick=${() => AIAppEvents.requestLogin.next({ host })}
|
||||||
></ai-error-wrapper>
|
></ai-error-wrapper>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ import type {
|
|||||||
} from '@affine/core/modules/cloud';
|
} from '@affine/core/modules/cloud';
|
||||||
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
import type { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||||
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
import type { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||||
import type {
|
import type { CopilotChatHistoryFragment } from '@affine/graphql';
|
||||||
ContextEmbedStatus,
|
|
||||||
CopilotChatHistoryFragment,
|
|
||||||
} from '@affine/graphql';
|
|
||||||
import {
|
import {
|
||||||
CanvasElementType,
|
CanvasElementType,
|
||||||
EdgelessCRUDIdentifier,
|
EdgelessCRUDIdentifier,
|
||||||
@@ -42,18 +39,17 @@ import type { SearchMenuConfig } from '../components/ai-chat-add-context';
|
|||||||
import type { DocDisplayConfig } from '../components/ai-chat-chips';
|
import type { DocDisplayConfig } from '../components/ai-chat-chips';
|
||||||
import type { AIReasoningConfig } from '../components/ai-chat-input';
|
import type { AIReasoningConfig } from '../components/ai-chat-input';
|
||||||
import type { ChatMessage } from '../components/ai-chat-messages';
|
import type { ChatMessage } from '../components/ai-chat-messages';
|
||||||
import {
|
import { ChatMessagesSchema } from '../components/ai-chat-messages';
|
||||||
ChatMessagesSchema,
|
|
||||||
isChatMessage,
|
|
||||||
StreamObjectSchema,
|
|
||||||
} from '../components/ai-chat-messages';
|
|
||||||
import type { TextRendererOptions } from '../components/text-renderer';
|
import type { TextRendererOptions } from '../components/text-renderer';
|
||||||
import { AIChatErrorRenderer } from '../messages/error';
|
import { AIChatErrorRenderer } from '../messages/error';
|
||||||
import { type AIError, AIProvider } from '../provider';
|
import { AIAppEvents, type AIError } from '../provider';
|
||||||
import {
|
import {
|
||||||
mergeStreamContent,
|
AIChatRuntime,
|
||||||
mergeStreamObjects,
|
type AIChatSnapshot,
|
||||||
} from '../utils/stream-objects';
|
ChatBlockAIChatSessionStrategy,
|
||||||
|
} from '../runtime/chat';
|
||||||
|
import { getAIRequestService } from '../runtime/request';
|
||||||
|
import { mergeStreamContent } from '../utils/stream-objects';
|
||||||
import { PeekViewStyles } from './styles';
|
import { PeekViewStyles } from './styles';
|
||||||
import type { ChatContext } from './types';
|
import type { ChatContext } from './types';
|
||||||
import { calcChildBound } from './utils';
|
import { calcChildBound } from './utils';
|
||||||
@@ -85,14 +81,14 @@ export class AIChatBlockPeekView extends LitElement {
|
|||||||
return this.blockModel.props.rootWorkspaceId;
|
return this.blockModel.props.rootWorkspaceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private get _isReasoningActive() {
|
|
||||||
return !!this.reasoningConfig.enabled.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _textRendererOptions: TextRendererOptions = {};
|
private _textRendererOptions: TextRendererOptions = {};
|
||||||
|
|
||||||
private _forkBlockId: string | undefined = undefined;
|
private _forkBlockId: string | undefined = undefined;
|
||||||
|
|
||||||
|
private runtime: AIChatRuntime | null = null;
|
||||||
|
|
||||||
|
private disposeRuntime: (() => void) | null = null;
|
||||||
|
|
||||||
private readonly _deserializeHistoryChatMessages = (
|
private readonly _deserializeHistoryChatMessages = (
|
||||||
historyMessagesString: string
|
historyMessagesString: string
|
||||||
) => {
|
) => {
|
||||||
@@ -115,7 +111,7 @@ export class AIChatBlockPeekView extends LitElement {
|
|||||||
forkSessionId: string,
|
forkSessionId: string,
|
||||||
docId?: string
|
docId?: string
|
||||||
) => {
|
) => {
|
||||||
const currentUserInfo = await AIProvider.userInfo;
|
const currentUserInfo = AIAppEvents.userInfo.value;
|
||||||
const forkMessages = (await queryHistoryMessages(
|
const forkMessages = (await queryHistoryMessages(
|
||||||
rootWorkspaceId,
|
rootWorkspaceId,
|
||||||
forkSessionId,
|
forkSessionId,
|
||||||
@@ -160,36 +156,28 @@ export class AIChatBlockPeekView extends LitElement {
|
|||||||
this._forkBlockId = undefined;
|
this._forkBlockId = undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly initSession = async () => {
|
private createRuntime() {
|
||||||
const session = await AIProvider.session?.getSession(
|
return new AIChatRuntime({
|
||||||
this.rootWorkspaceId,
|
request: getAIRequestService(),
|
||||||
this._sessionId
|
scope: {
|
||||||
);
|
kind: 'chat-block',
|
||||||
this.session = session ?? null;
|
workspaceId: this.rootWorkspaceId,
|
||||||
};
|
docId: this.rootDocId,
|
||||||
|
blockId: this.blockId,
|
||||||
private readonly createForkSession = async () => {
|
parentSessionId: this._sessionId,
|
||||||
if (this.forkSession) {
|
latestMessageId: this._historyMessages.at(-1)?.id,
|
||||||
return this.forkSession;
|
},
|
||||||
}
|
strategy: new ChatBlockAIChatSessionStrategy(),
|
||||||
const lastMessage = this._historyMessages.at(-1);
|
|
||||||
if (!lastMessage) return;
|
|
||||||
|
|
||||||
const { store } = this.host;
|
|
||||||
const forkSessionId = await AIProvider.forkChat?.({
|
|
||||||
workspaceId: store.workspace.id,
|
|
||||||
docId: store.id,
|
|
||||||
sessionId: this._sessionId,
|
|
||||||
latestMessageId: lastMessage.id,
|
|
||||||
});
|
});
|
||||||
if (forkSessionId) {
|
|
||||||
const session = await AIProvider.session?.getSession(
|
|
||||||
this.rootWorkspaceId,
|
|
||||||
forkSessionId
|
|
||||||
);
|
|
||||||
this.forkSession = session ?? null;
|
|
||||||
}
|
}
|
||||||
return this.forkSession;
|
|
||||||
|
private readonly initSession = async () => {
|
||||||
|
const runtime = this.createRuntime();
|
||||||
|
try {
|
||||||
|
this.session = (await runtime.loadInitialSession()) ?? null;
|
||||||
|
} finally {
|
||||||
|
runtime.dispose();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly _onChatSuccess = async () => {
|
private readonly _onChatSuccess = async () => {
|
||||||
@@ -312,11 +300,37 @@ export class AIChatBlockPeekView extends LitElement {
|
|||||||
this.chatContext = { ...this.chatContext, ...context };
|
this.chatContext = { ...this.chatContext, ...context };
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly onEmbeddingProgressChange = (
|
private readonly syncContextFromRuntime = () => {
|
||||||
count: Record<ContextEmbedStatus, number>
|
const snapshot = this.runtimeSnapshot;
|
||||||
) => {
|
if (!snapshot) return;
|
||||||
const total = count.finished + count.processing + count.failed;
|
const activeSession = snapshot.sessions.find(
|
||||||
this.embeddingProgress = [count.finished, total];
|
session => session.sessionId === snapshot.activeSessionId
|
||||||
|
);
|
||||||
|
this.forkSession = activeSession ?? this.forkSession;
|
||||||
|
this.chatContext = {
|
||||||
|
...this.chatContext,
|
||||||
|
messages: snapshot.messages as ChatMessage[],
|
||||||
|
status: snapshot.status,
|
||||||
|
error: snapshot.error as AIError | null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly ensureRuntime = () => {
|
||||||
|
if (this.runtime || !this.session || !this._historyMessages.length) return;
|
||||||
|
this.runtime = this.createRuntime();
|
||||||
|
this.disposeRuntime = this.runtime.subscribe(() => {
|
||||||
|
this.runtimeSnapshot = this.runtime?.getSnapshot() ?? null;
|
||||||
|
this.syncContextFromRuntime();
|
||||||
|
});
|
||||||
|
this.runtimeSnapshot = this.runtime.getSnapshot();
|
||||||
|
if (this.forkSession) {
|
||||||
|
this.runtime
|
||||||
|
.dispatch({
|
||||||
|
type: 'openSessionObject',
|
||||||
|
session: this.forkSession,
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -359,72 +373,16 @@ export class AIChatBlockPeekView extends LitElement {
|
|||||||
* Retry the last chat message
|
* Retry the last chat message
|
||||||
*/
|
*/
|
||||||
retry = async () => {
|
retry = async () => {
|
||||||
try {
|
if (this.runtime) {
|
||||||
const forkSessionId = this.forkSession?.sessionId;
|
const lastAssistantMessage = this.chatContext.messages.findLast(
|
||||||
if (!this._forkBlockId || !forkSessionId) return;
|
message => message.role === 'assistant'
|
||||||
if (!AIProvider.actions.chat) return;
|
);
|
||||||
|
await this.runtime.dispatch({
|
||||||
const abortController = new AbortController();
|
type: 'retry',
|
||||||
const messages = [...this.chatContext.messages];
|
messageId: lastAssistantMessage?.id ?? '',
|
||||||
const last = messages[messages.length - 1];
|
|
||||||
if ('content' in last) {
|
|
||||||
last.content = '';
|
|
||||||
last.streamObjects = [];
|
|
||||||
last.createdAt = new Date().toISOString();
|
|
||||||
}
|
|
||||||
this.updateContext({
|
|
||||||
messages,
|
|
||||||
status: 'loading',
|
|
||||||
error: null,
|
|
||||||
abortController,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { store } = this.host;
|
|
||||||
const stream = await AIProvider.actions.chat({
|
|
||||||
sessionId: forkSessionId,
|
|
||||||
retry: true,
|
|
||||||
docId: store.id,
|
|
||||||
workspaceId: store.workspace.id,
|
|
||||||
host: this.host,
|
|
||||||
stream: true,
|
|
||||||
signal: abortController.signal,
|
|
||||||
where: 'ai-chat-block',
|
|
||||||
control: 'chat-send',
|
|
||||||
reasoning: this._isReasoningActive,
|
|
||||||
toolsConfig: this.aiToolsConfigService.config.value,
|
|
||||||
});
|
|
||||||
|
|
||||||
for await (const text of stream) {
|
|
||||||
const messages = this.chatContext.messages.slice(0);
|
|
||||||
const last = messages.at(-1);
|
|
||||||
if (last && isChatMessage(last)) {
|
|
||||||
try {
|
|
||||||
const parsed = StreamObjectSchema.parse(JSON.parse(text));
|
|
||||||
const streamObjects = mergeStreamObjects([
|
|
||||||
...(last.streamObjects ?? []),
|
|
||||||
parsed,
|
|
||||||
]);
|
|
||||||
messages[messages.length - 1] = {
|
|
||||||
...last,
|
|
||||||
streamObjects,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
messages[messages.length - 1] = {
|
|
||||||
...last,
|
|
||||||
content: last.content + text,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
this.updateContext({ messages, status: 'transmitting' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateContext({ status: 'success' });
|
|
||||||
// Update new chat block messages if there are contents returned from AI
|
|
||||||
await this.updateChatBlockMessages();
|
await this.updateChatBlockMessages();
|
||||||
} catch (error) {
|
return;
|
||||||
this.updateContext({ status: 'error', error: error as AIError });
|
|
||||||
} finally {
|
|
||||||
this.updateContext({ abortController: null });
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -526,17 +484,33 @@ export class AIChatBlockPeekView extends LitElement {
|
|||||||
attachments: messages[idx]?.attachments ?? [],
|
attachments: messages[idx]?.attachments ?? [],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
this.ensureRuntime();
|
||||||
})
|
})
|
||||||
.catch((err: Error) => {
|
.catch((err: Error) => {
|
||||||
console.error('Query history messages failed', err);
|
console.error('Query history messages failed', err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
this.disposeRuntime?.();
|
||||||
|
this.runtime?.dispose();
|
||||||
|
this.disposeRuntime = null;
|
||||||
|
this.runtime = null;
|
||||||
|
}
|
||||||
|
|
||||||
override firstUpdated() {
|
override firstUpdated() {
|
||||||
this._scrollToEnd();
|
this._scrollToEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override updated(changedProperties: PropertyValues) {
|
protected override updated(changedProperties: PropertyValues) {
|
||||||
|
if (
|
||||||
|
changedProperties.has('session') ||
|
||||||
|
changedProperties.has('_historyMessages')
|
||||||
|
) {
|
||||||
|
this.ensureRuntime();
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
changedProperties.has('chatContext') &&
|
changedProperties.has('chatContext') &&
|
||||||
(this.chatContext.status === 'loading' ||
|
(this.chatContext.status === 'loading' ||
|
||||||
@@ -572,6 +546,14 @@ export class AIChatBlockPeekView extends LitElement {
|
|||||||
<ai-history-clear
|
<ai-history-clear
|
||||||
.doc=${this.host.store}
|
.doc=${this.host.store}
|
||||||
.session=${this.forkSession}
|
.session=${this.forkSession}
|
||||||
|
.onClearHistory=${async (sessionIds: string[]) => {
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
await this.runtime?.dispatch({
|
||||||
|
type: 'deleteSession',
|
||||||
|
sessionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
.onHistoryCleared=${this._onHistoryCleared}
|
.onHistoryCleared=${this._onHistoryCleared}
|
||||||
.chatContextValue=${chatContext}
|
.chatContextValue=${chatContext}
|
||||||
.notificationService=${notificationService}
|
.notificationService=${notificationService}
|
||||||
@@ -593,10 +575,10 @@ export class AIChatBlockPeekView extends LitElement {
|
|||||||
.workspaceId=${this.rootWorkspaceId}
|
.workspaceId=${this.rootWorkspaceId}
|
||||||
.docId=${this.rootDocId}
|
.docId=${this.rootDocId}
|
||||||
.session=${this.forkSession ?? this.session}
|
.session=${this.forkSession ?? this.session}
|
||||||
.createSession=${this.createForkSession}
|
.runtime=${this.runtime}
|
||||||
|
.runtimeSnapshot=${this.runtimeSnapshot}
|
||||||
.chatContextValue=${chatContext}
|
.chatContextValue=${chatContext}
|
||||||
.updateContext=${updateContext}
|
.updateContext=${updateContext}
|
||||||
.onEmbeddingProgressChange=${this.onEmbeddingProgressChange}
|
|
||||||
.docDisplayConfig=${this.docDisplayConfig}
|
.docDisplayConfig=${this.docDisplayConfig}
|
||||||
.searchMenuConfig=${this.searchMenuConfig}
|
.searchMenuConfig=${this.searchMenuConfig}
|
||||||
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
|
.affineWorkspaceDialogService=${this.affineWorkspaceDialogService}
|
||||||
@@ -673,7 +655,7 @@ export class AIChatBlockPeekView extends LitElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor embeddingProgress: [number, number] = [0, 0];
|
accessor runtimeSnapshot: AIChatSnapshot | null = null;
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
accessor session: CopilotChatHistoryFragment | null | undefined;
|
accessor session: CopilotChatHistoryFragment | null | undefined;
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { EditorHost } from '@blocksuite/affine/std';
|
||||||
|
import { BehaviorSubject, Subject } from 'rxjs';
|
||||||
|
|
||||||
|
import type { AIChatParams, AISendParams, AIUserInfo } from './ai-provider';
|
||||||
|
|
||||||
|
export const AIAppEvents = {
|
||||||
|
/* eslint-disable rxjs/finnish */
|
||||||
|
requestOpenWithChat: new BehaviorSubject<AIChatParams | null>(null),
|
||||||
|
requestSendWithChat: new BehaviorSubject<AISendParams | null>(null),
|
||||||
|
requestInsertTemplate: new Subject<{
|
||||||
|
template: string;
|
||||||
|
mode: 'page' | 'edgeless';
|
||||||
|
}>(),
|
||||||
|
requestLogin: new Subject<{ host?: EditorHost | null }>(),
|
||||||
|
requestUpgradePlan: new Subject<{ host?: EditorHost | null }>(),
|
||||||
|
userInfo: new BehaviorSubject<AIUserInfo | null>(null),
|
||||||
|
previewPanelOpenChange: new Subject<boolean>(),
|
||||||
|
/* eslint-enable rxjs/finnish */
|
||||||
|
};
|
||||||
@@ -1,13 +1,6 @@
|
|||||||
import type { EditorHost } from '@blocksuite/affine/std';
|
import type { EditorHost } from '@blocksuite/affine/std';
|
||||||
import { captureException } from '@sentry/react';
|
|
||||||
import { BehaviorSubject, Subject } from 'rxjs';
|
|
||||||
|
|
||||||
import type { ChatContextValue } from '../components/ai-chat-content';
|
import type { ChatContextValue } from '../components/ai-chat-content';
|
||||||
import {
|
|
||||||
PaymentRequiredError,
|
|
||||||
RequestTimeoutError,
|
|
||||||
UnauthorizedError,
|
|
||||||
} from './error';
|
|
||||||
|
|
||||||
export interface AIUserInfo {
|
export interface AIUserInfo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -64,14 +57,6 @@ export type ActionEventType =
|
|||||||
* TODO: breakdown into different parts?
|
* TODO: breakdown into different parts?
|
||||||
*/
|
*/
|
||||||
export class AIProvider {
|
export class AIProvider {
|
||||||
static get slots() {
|
|
||||||
return AIProvider.instance.slots;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get actions() {
|
|
||||||
return AIProvider.instance.actions;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get userInfo() {
|
static get userInfo() {
|
||||||
return AIProvider.instance.userInfoFn();
|
return AIProvider.instance.userInfoFn();
|
||||||
}
|
}
|
||||||
@@ -80,275 +65,34 @@ export class AIProvider {
|
|||||||
return AIProvider.instance.photoEngine;
|
return AIProvider.instance.photoEngine;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get histories() {
|
|
||||||
return AIProvider.instance.histories;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get session() {
|
|
||||||
return AIProvider.instance.session;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get context() {
|
|
||||||
return AIProvider.instance.context;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get actionHistory() {
|
|
||||||
return AIProvider.instance.actionHistory;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get toggleGeneralAIOnboarding() {
|
static get toggleGeneralAIOnboarding() {
|
||||||
return AIProvider.instance.toggleGeneralAIOnboarding;
|
return AIProvider.instance.toggleGeneralAIOnboarding;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get forkChat() {
|
|
||||||
return AIProvider.instance.forkChat;
|
|
||||||
}
|
|
||||||
|
|
||||||
static get embedding() {
|
|
||||||
return AIProvider.instance.embedding;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static readonly instance = new AIProvider();
|
private static readonly instance = new AIProvider();
|
||||||
|
|
||||||
static LAST_ACTION_SESSIONID = '';
|
|
||||||
|
|
||||||
static MAX_LOCAL_HISTORY = 10;
|
|
||||||
|
|
||||||
private readonly actions: Partial<BlockSuitePresets.AIActions> = {};
|
|
||||||
|
|
||||||
private photoEngine: BlockSuitePresets.AIPhotoEngineService | null = null;
|
private photoEngine: BlockSuitePresets.AIPhotoEngineService | null = null;
|
||||||
|
|
||||||
private histories: BlockSuitePresets.AIHistoryService | null = null;
|
|
||||||
|
|
||||||
private session: BlockSuitePresets.AISessionService | null = null;
|
|
||||||
|
|
||||||
private context: BlockSuitePresets.AIContextService | null = null;
|
|
||||||
|
|
||||||
private toggleGeneralAIOnboarding: ((value: boolean) => void) | null = null;
|
private toggleGeneralAIOnboarding: ((value: boolean) => void) | null = null;
|
||||||
|
|
||||||
private forkChat:
|
|
||||||
| ((
|
|
||||||
options: BlockSuitePresets.AIForkChatSessionOptions
|
|
||||||
) => string | Promise<string>)
|
|
||||||
| null = null;
|
|
||||||
|
|
||||||
private readonly slots = {
|
|
||||||
// use case: when user selects "continue in chat" in an ask ai result panel
|
|
||||||
// do we need to pass the context to the chat panel?
|
|
||||||
/* eslint-disable rxjs/finnish */
|
|
||||||
requestOpenWithChat: new BehaviorSubject<AIChatParams | null>(null),
|
|
||||||
requestSendWithChat: new BehaviorSubject<AISendParams | null>(null),
|
|
||||||
requestInsertTemplate: new Subject<{
|
|
||||||
template: string;
|
|
||||||
mode: 'page' | 'edgeless';
|
|
||||||
}>(),
|
|
||||||
requestLogin: new Subject<{ host?: EditorHost | null }>(),
|
|
||||||
requestUpgradePlan: new Subject<{ host?: EditorHost | null }>(),
|
|
||||||
// stream of AI actions triggered by users
|
|
||||||
actions: new Subject<{
|
|
||||||
action: keyof BlockSuitePresets.AIActions;
|
|
||||||
options: BlockSuitePresets.AITextActionOptions;
|
|
||||||
event: ActionEventType;
|
|
||||||
}>(),
|
|
||||||
// downstream can emit this slot to notify ai presets that user info has been updated
|
|
||||||
userInfo: new Subject<AIUserInfo | null>(),
|
|
||||||
sessionReady: new BehaviorSubject<boolean>(false),
|
|
||||||
previewPanelOpenChange: new Subject<boolean>(),
|
|
||||||
/* eslint-enable rxjs/finnish */
|
|
||||||
};
|
|
||||||
|
|
||||||
// track the history of triggered actions (in memory only)
|
|
||||||
private readonly actionHistory: {
|
|
||||||
action: keyof BlockSuitePresets.AIActions;
|
|
||||||
options: BlockSuitePresets.AITextActionOptions;
|
|
||||||
}[] = [];
|
|
||||||
|
|
||||||
private userInfoFn: () => AIUserInfo | Promise<AIUserInfo> | null = () =>
|
private userInfoFn: () => AIUserInfo | Promise<AIUserInfo> | null = () =>
|
||||||
null;
|
null;
|
||||||
|
|
||||||
private embedding: BlockSuitePresets.AIEmbeddingService | null = null;
|
|
||||||
|
|
||||||
private provideAction<T extends keyof BlockSuitePresets.AIActions>(
|
|
||||||
id: T,
|
|
||||||
action: (
|
|
||||||
...options: Parameters<BlockSuitePresets.AIActions[T]>
|
|
||||||
) => Promise<ReturnType<BlockSuitePresets.AIActions[T]>>
|
|
||||||
): void {
|
|
||||||
// @ts-expect-error TODO: maybe fix this
|
|
||||||
this.actions[id] = async (
|
|
||||||
...args: Parameters<BlockSuitePresets.AIActions[T]>
|
|
||||||
) => {
|
|
||||||
const options = args[0];
|
|
||||||
const slots = this.slots;
|
|
||||||
slots.actions.next({
|
|
||||||
action: id,
|
|
||||||
options,
|
|
||||||
event: 'started',
|
|
||||||
});
|
|
||||||
this.actionHistory.push({ action: id, options });
|
|
||||||
if (this.actionHistory.length > AIProvider.MAX_LOCAL_HISTORY) {
|
|
||||||
this.actionHistory.shift();
|
|
||||||
}
|
|
||||||
// wrap the action with slot actions
|
|
||||||
const result: BlockSuitePresets.TextStream | Promise<string> =
|
|
||||||
await action(...args);
|
|
||||||
const isTextStream = (
|
|
||||||
m: BlockSuitePresets.TextStream | Promise<string>
|
|
||||||
): m is BlockSuitePresets.TextStream =>
|
|
||||||
Reflect.has(m, Symbol.asyncIterator);
|
|
||||||
if (isTextStream(result)) {
|
|
||||||
return {
|
|
||||||
[Symbol.asyncIterator]: async function* () {
|
|
||||||
let user = null;
|
|
||||||
try {
|
|
||||||
user = await AIProvider.userInfo;
|
|
||||||
yield* result;
|
|
||||||
slots.actions.next({
|
|
||||||
action: id,
|
|
||||||
options,
|
|
||||||
event: 'finished',
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
slots.actions.next({
|
|
||||||
action: id,
|
|
||||||
options,
|
|
||||||
event: 'error',
|
|
||||||
});
|
|
||||||
if (err instanceof RequestTimeoutError) {
|
|
||||||
slots.actions.next({
|
|
||||||
action: id,
|
|
||||||
options,
|
|
||||||
event: 'aborted:timeout',
|
|
||||||
});
|
|
||||||
} else if (err instanceof PaymentRequiredError) {
|
|
||||||
slots.actions.next({
|
|
||||||
action: id,
|
|
||||||
options,
|
|
||||||
event: 'aborted:paywall',
|
|
||||||
});
|
|
||||||
} else if (err instanceof UnauthorizedError) {
|
|
||||||
slots.actions.next({
|
|
||||||
action: id,
|
|
||||||
options,
|
|
||||||
event: 'aborted:login-required',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
slots.actions.next({
|
|
||||||
action: id,
|
|
||||||
options,
|
|
||||||
event: 'aborted:server-error',
|
|
||||||
});
|
|
||||||
captureException(err, {
|
|
||||||
user: { id: user?.id },
|
|
||||||
extra: {
|
|
||||||
action: id,
|
|
||||||
session: AIProvider.LAST_ACTION_SESSIONID,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
let user: any = null;
|
|
||||||
return result
|
|
||||||
.then(async result => {
|
|
||||||
user = await AIProvider.userInfo;
|
|
||||||
slots.actions.next({
|
|
||||||
action: id,
|
|
||||||
options,
|
|
||||||
event: 'finished',
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
slots.actions.next({
|
|
||||||
action: id,
|
|
||||||
options,
|
|
||||||
event: 'error',
|
|
||||||
});
|
|
||||||
if (err instanceof PaymentRequiredError) {
|
|
||||||
slots.actions.next({
|
|
||||||
action: id,
|
|
||||||
options,
|
|
||||||
event: 'aborted:paywall',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
captureException(err, {
|
|
||||||
user: { id: user?.id },
|
|
||||||
extra: {
|
|
||||||
action: id,
|
|
||||||
session: AIProvider.LAST_ACTION_SESSIONID,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static provide(
|
static provide(
|
||||||
id: 'userInfo',
|
id: 'userInfo',
|
||||||
fn: () => AIUserInfo | Promise<AIUserInfo> | null
|
fn: () => AIUserInfo | Promise<AIUserInfo> | null
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
static provide(
|
|
||||||
id: 'session',
|
|
||||||
service: BlockSuitePresets.AISessionService
|
|
||||||
): void;
|
|
||||||
|
|
||||||
static provide(
|
|
||||||
id: 'context',
|
|
||||||
service: BlockSuitePresets.AIContextService
|
|
||||||
): void;
|
|
||||||
|
|
||||||
static provide(
|
|
||||||
id: 'histories',
|
|
||||||
service: BlockSuitePresets.AIHistoryService
|
|
||||||
): void;
|
|
||||||
|
|
||||||
static provide(
|
static provide(
|
||||||
id: 'photoEngine',
|
id: 'photoEngine',
|
||||||
engine: BlockSuitePresets.AIPhotoEngineService
|
engine: BlockSuitePresets.AIPhotoEngineService
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
static provide(
|
|
||||||
id: 'forkChat',
|
|
||||||
fn: (
|
|
||||||
options: BlockSuitePresets.AIForkChatSessionOptions
|
|
||||||
) => string | Promise<string>
|
|
||||||
): void;
|
|
||||||
|
|
||||||
static provide(id: 'onboarding', fn: (value: boolean) => void): void;
|
static provide(id: 'onboarding', fn: (value: boolean) => void): void;
|
||||||
|
|
||||||
static provide(
|
|
||||||
id: 'embedding',
|
|
||||||
service: BlockSuitePresets.AIEmbeddingService
|
|
||||||
): void;
|
|
||||||
|
|
||||||
// actions:
|
|
||||||
static provide<T extends keyof BlockSuitePresets.AIActions>(
|
|
||||||
id: T,
|
|
||||||
action: (
|
|
||||||
...options: Parameters<BlockSuitePresets.AIActions[T]>
|
|
||||||
) => Promise<ReturnType<BlockSuitePresets.AIActions[T]>>
|
|
||||||
): void;
|
|
||||||
|
|
||||||
static provide(id: unknown, action: unknown) {
|
static provide(id: unknown, action: unknown) {
|
||||||
if (id === 'userInfo') {
|
if (id === 'userInfo') {
|
||||||
AIProvider.instance.userInfoFn = action as () => AIUserInfo;
|
AIProvider.instance.userInfoFn = action as () => AIUserInfo;
|
||||||
} else if (id === 'histories') {
|
|
||||||
AIProvider.instance.histories =
|
|
||||||
action as BlockSuitePresets.AIHistoryService;
|
|
||||||
} else if (id === 'session') {
|
|
||||||
AIProvider.instance.session =
|
|
||||||
action as BlockSuitePresets.AISessionService;
|
|
||||||
AIProvider.instance.slots.sessionReady.next(true);
|
|
||||||
} else if (id === 'context') {
|
|
||||||
AIProvider.instance.context =
|
|
||||||
action as BlockSuitePresets.AIContextService;
|
|
||||||
} else if (id === 'photoEngine') {
|
} else if (id === 'photoEngine') {
|
||||||
AIProvider.instance.photoEngine =
|
AIProvider.instance.photoEngine =
|
||||||
action as BlockSuitePresets.AIPhotoEngineService;
|
action as BlockSuitePresets.AIPhotoEngineService;
|
||||||
@@ -356,15 +100,6 @@ export class AIProvider {
|
|||||||
AIProvider.instance.toggleGeneralAIOnboarding = action as (
|
AIProvider.instance.toggleGeneralAIOnboarding = action as (
|
||||||
value: boolean
|
value: boolean
|
||||||
) => void;
|
) => void;
|
||||||
} else if (id === 'forkChat') {
|
|
||||||
AIProvider.instance.forkChat = action as (
|
|
||||||
options: BlockSuitePresets.AIForkChatSessionOptions
|
|
||||||
) => string | Promise<string>;
|
|
||||||
} else if (id === 'embedding') {
|
|
||||||
AIProvider.instance.embedding =
|
|
||||||
action as BlockSuitePresets.AIEmbeddingService;
|
|
||||||
} else {
|
|
||||||
AIProvider.instance.provideAction(id as any, action as any);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { handleError } from './copilot-client';
|
import { handleError } from '../runtime/request/copilot-client';
|
||||||
import { RequestTimeoutError } from './error';
|
import { RequestTimeoutError } from './error';
|
||||||
|
|
||||||
export function delay(ms: number) {
|
export function delay(ms: number) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
|
export * from './ai-app-events';
|
||||||
export * from './ai-provider';
|
export * from './ai-provider';
|
||||||
export * from './copilot-client';
|
|
||||||
export * from './error';
|
export * from './error';
|
||||||
export * from './setup-provider';
|
export * from './setup-provider';
|
||||||
|
|||||||
@@ -1,284 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment happy-dom
|
|
||||||
*/
|
|
||||||
import { UserFriendlyError } from '@affine/error';
|
|
||||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { type CopilotClient, Endpoint } from './copilot-client';
|
|
||||||
import { textToText, toImage } from './request';
|
|
||||||
|
|
||||||
const electronApis = vi.hoisted(() => ({
|
|
||||||
byokStorage: undefined as
|
|
||||||
| {
|
|
||||||
isSupported: () => Promise<boolean>;
|
|
||||||
getWorkspaceLeaseProviders: (workspaceId: string) => Promise<
|
|
||||||
Array<{
|
|
||||||
provider: string;
|
|
||||||
name: string;
|
|
||||||
apiKey: string;
|
|
||||||
description?: string | null;
|
|
||||||
endpoint?: string | null;
|
|
||||||
sortOrder?: number | null;
|
|
||||||
enabled?: boolean | null;
|
|
||||||
}>
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
| undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const createWorkspaceByokLocalLeaseMutation = vi.hoisted(() =>
|
|
||||||
Symbol('createWorkspaceByokLocalLeaseMutation')
|
|
||||||
);
|
|
||||||
|
|
||||||
vi.mock('@affine/electron-api', () => ({
|
|
||||||
apis: electronApis,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('@affine/graphql', () => ({
|
|
||||||
ByokProvider: {
|
|
||||||
openai: 'openai',
|
|
||||||
anthropic: 'anthropic',
|
|
||||||
gemini: 'gemini',
|
|
||||||
fal: 'fal',
|
|
||||||
},
|
|
||||||
createWorkspaceByokLocalLeaseMutation,
|
|
||||||
}));
|
|
||||||
|
|
||||||
function createClient(
|
|
||||||
overrides: Partial<
|
|
||||||
Pick<
|
|
||||||
CopilotClient,
|
|
||||||
'gql' | 'createMessage' | 'chatTextStream' | 'imagesStream'
|
|
||||||
>
|
|
||||||
> = {}
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
gql: vi.fn().mockResolvedValue({
|
|
||||||
createWorkspaceByokLocalLease: { leaseId: 'lease-1' },
|
|
||||||
}),
|
|
||||||
createMessage: vi.fn().mockResolvedValue('message-1'),
|
|
||||||
chatTextStream: vi.fn(),
|
|
||||||
imagesStream: vi.fn(),
|
|
||||||
...overrides,
|
|
||||||
} as unknown as CopilotClient;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function drain(stream: AsyncIterable<unknown>) {
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
void chunk;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AI request BYOK local lease handling', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.stubGlobal('BUILD_CONFIG', { isElectron: true });
|
|
||||||
electronApis.byokStorage = {
|
|
||||||
isSupported: vi.fn().mockResolvedValue(true),
|
|
||||||
getWorkspaceLeaseProviders: vi.fn().mockResolvedValue([
|
|
||||||
{
|
|
||||||
provider: 'openai',
|
|
||||||
name: 'OpenAI',
|
|
||||||
apiKey: 'sk-local',
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
test('fails closed when local BYOK providers exist but lease creation fails', async () => {
|
|
||||||
const client = createClient({
|
|
||||||
gql: vi.fn().mockRejectedValue(new Error('mutation failed')),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = textToText({
|
|
||||||
client,
|
|
||||||
sessionId: 'session-1',
|
|
||||||
workspaceId: 'workspace-1',
|
|
||||||
content: 'hello',
|
|
||||||
}) as Promise<string>;
|
|
||||||
|
|
||||||
await expect(result).rejects.toThrow('mutation failed');
|
|
||||||
await expect(result).rejects.toBeInstanceOf(UserFriendlyError);
|
|
||||||
expect(client.chatTextStream).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('wraps local BYOK storage support failures as user friendly errors', async () => {
|
|
||||||
electronApis.byokStorage = {
|
|
||||||
isSupported: vi.fn().mockRejectedValue(new Error('support check failed')),
|
|
||||||
getWorkspaceLeaseProviders: vi.fn(),
|
|
||||||
};
|
|
||||||
const client = createClient();
|
|
||||||
|
|
||||||
const result = textToText({
|
|
||||||
client,
|
|
||||||
sessionId: 'session-1',
|
|
||||||
workspaceId: 'workspace-1',
|
|
||||||
content: 'hello',
|
|
||||||
}) as Promise<string>;
|
|
||||||
|
|
||||||
await expect(result).rejects.toThrow('support check failed');
|
|
||||||
await expect(result).rejects.toBeInstanceOf(UserFriendlyError);
|
|
||||||
expect(client.chatTextStream).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('wraps local BYOK provider loading failures as user friendly errors', async () => {
|
|
||||||
electronApis.byokStorage = {
|
|
||||||
isSupported: vi.fn().mockResolvedValue(true),
|
|
||||||
getWorkspaceLeaseProviders: vi
|
|
||||||
.fn()
|
|
||||||
.mockRejectedValue(new Error('provider load failed')),
|
|
||||||
};
|
|
||||||
const client = createClient();
|
|
||||||
|
|
||||||
const result = textToText({
|
|
||||||
client,
|
|
||||||
sessionId: 'session-1',
|
|
||||||
workspaceId: 'workspace-1',
|
|
||||||
content: 'hello',
|
|
||||||
}) as Promise<string>;
|
|
||||||
|
|
||||||
await expect(result).rejects.toThrow('provider load failed');
|
|
||||||
await expect(result).rejects.toBeInstanceOf(UserFriendlyError);
|
|
||||||
expect(client.chatTextStream).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not create local BYOK lease after cancellation', async () => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const client = createClient({
|
|
||||||
createMessage: vi.fn().mockImplementation(async () => {
|
|
||||||
controller.abort();
|
|
||||||
return 'message-1';
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
textToText({
|
|
||||||
client,
|
|
||||||
sessionId: 'session-1',
|
|
||||||
workspaceId: 'workspace-1',
|
|
||||||
content: 'hello',
|
|
||||||
signal: controller.signal,
|
|
||||||
}) as Promise<string>
|
|
||||||
).resolves.toBe('');
|
|
||||||
expect(client.gql).not.toHaveBeenCalled();
|
|
||||||
expect(client.chatTextStream).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not create stream local BYOK lease after cancellation', async () => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const client = createClient({
|
|
||||||
createMessage: vi.fn().mockImplementation(async () => {
|
|
||||||
controller.abort();
|
|
||||||
return 'message-1';
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
await drain(
|
|
||||||
textToText({
|
|
||||||
client,
|
|
||||||
sessionId: 'session-1',
|
|
||||||
workspaceId: 'workspace-1',
|
|
||||||
content: 'hello',
|
|
||||||
stream: true,
|
|
||||||
signal: controller.signal,
|
|
||||||
}) as AsyncIterable<string>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(client.gql).not.toHaveBeenCalled();
|
|
||||||
expect(client.chatTextStream).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not create text stream when cancelled while creating local BYOK lease', async () => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const client = createClient({
|
|
||||||
gql: vi.fn().mockImplementation(async () => {
|
|
||||||
controller.abort();
|
|
||||||
return { createWorkspaceByokLocalLease: { leaseId: 'lease-1' } };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
await drain(
|
|
||||||
textToText({
|
|
||||||
client,
|
|
||||||
sessionId: 'session-1',
|
|
||||||
workspaceId: 'workspace-1',
|
|
||||||
content: 'hello',
|
|
||||||
stream: true,
|
|
||||||
signal: controller.signal,
|
|
||||||
}) as AsyncIterable<string>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(client.gql).toHaveBeenCalled();
|
|
||||||
expect(client.chatTextStream).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not create text request when cancelled while creating local BYOK lease', async () => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const client = createClient({
|
|
||||||
gql: vi.fn().mockImplementation(async () => {
|
|
||||||
controller.abort();
|
|
||||||
return { createWorkspaceByokLocalLease: { leaseId: 'lease-1' } };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
textToText({
|
|
||||||
client,
|
|
||||||
sessionId: 'session-1',
|
|
||||||
workspaceId: 'workspace-1',
|
|
||||||
content: 'hello',
|
|
||||||
signal: controller.signal,
|
|
||||||
}) as Promise<string>
|
|
||||||
).resolves.toBe('');
|
|
||||||
|
|
||||||
expect(client.gql).toHaveBeenCalled();
|
|
||||||
expect(client.chatTextStream).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not create image local BYOK lease after cancellation', async () => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const client = createClient({
|
|
||||||
createMessage: vi.fn().mockImplementation(async () => {
|
|
||||||
controller.abort();
|
|
||||||
return 'message-1';
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
await drain(
|
|
||||||
toImage({
|
|
||||||
client,
|
|
||||||
sessionId: 'session-1',
|
|
||||||
workspaceId: 'workspace-1',
|
|
||||||
content: 'image',
|
|
||||||
endpoint: Endpoint.Images,
|
|
||||||
signal: controller.signal,
|
|
||||||
}) as AsyncIterable<string>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(client.gql).not.toHaveBeenCalled();
|
|
||||||
expect(client.imagesStream).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not create image stream when cancelled while creating local BYOK lease', async () => {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const client = createClient({
|
|
||||||
gql: vi.fn().mockImplementation(async () => {
|
|
||||||
controller.abort();
|
|
||||||
return { createWorkspaceByokLocalLease: { leaseId: 'lease-1' } };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
await drain(
|
|
||||||
toImage({
|
|
||||||
client,
|
|
||||||
sessionId: 'session-1',
|
|
||||||
workspaceId: 'workspace-1',
|
|
||||||
content: 'image',
|
|
||||||
endpoint: Endpoint.Images,
|
|
||||||
signal: controller.signal,
|
|
||||||
}) as AsyncIterable<string>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(client.gql).toHaveBeenCalled();
|
|
||||||
expect(client.imagesStream).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
/**
|
|
||||||
* @vitest-environment happy-dom
|
|
||||||
*/
|
|
||||||
import { BehaviorSubject } from 'rxjs';
|
|
||||||
import { describe, expect, test, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { AIProvider } from './ai-provider';
|
|
||||||
import { CopilotClient, Endpoint } from './copilot-client';
|
|
||||||
import { setupAIProvider } from './setup-provider';
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'EventSource', {
|
|
||||||
configurable: true,
|
|
||||||
value: {
|
|
||||||
CLOSED: 2,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
type SetupAIProviderArgs = Parameters<typeof setupAIProvider>;
|
|
||||||
type ActionInput<T extends keyof BlockSuitePresets.AIActions> = Parameters<
|
|
||||||
NonNullable<BlockSuitePresets.AIActions[T]>
|
|
||||||
>[0];
|
|
||||||
|
|
||||||
async function drain(stream: AsyncIterable<unknown>) {
|
|
||||||
for await (const chunk of stream) {
|
|
||||||
void chunk;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function drainActionResult(
|
|
||||||
stream: string | AsyncIterable<unknown> | undefined
|
|
||||||
) {
|
|
||||||
expect(stream).toBeDefined();
|
|
||||||
expect(typeof stream).not.toBe('string');
|
|
||||||
await drain(stream as AsyncIterable<unknown>);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createClosedEventSource(): EventSource {
|
|
||||||
return {
|
|
||||||
readyState: EventSource.CLOSED,
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
close: vi.fn(),
|
|
||||||
} as unknown as EventSource;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('setupAIProvider action migrations', () => {
|
|
||||||
test('routes mindmap, slides and image filter through action API', async () => {
|
|
||||||
const createdSessions: unknown[] = [];
|
|
||||||
const textStreams: unknown[] = [];
|
|
||||||
const client = new CopilotClient(
|
|
||||||
vi.fn(),
|
|
||||||
vi.fn(() => createClosedEventSource())
|
|
||||||
);
|
|
||||||
vi.spyOn(client, 'createSession').mockImplementation(async options => {
|
|
||||||
createdSessions.push(options);
|
|
||||||
return `session:${options.promptName}`;
|
|
||||||
});
|
|
||||||
vi.spyOn(client, 'createMessage').mockResolvedValue('message-1');
|
|
||||||
vi.spyOn(client, 'chatTextStream').mockImplementation(
|
|
||||||
(options, endpoint) => {
|
|
||||||
textStreams.push({ options, endpoint });
|
|
||||||
return createClosedEventSource();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
vi.spyOn(client, 'imagesStream').mockReturnValue(createClosedEventSource());
|
|
||||||
|
|
||||||
setupAIProvider(
|
|
||||||
client,
|
|
||||||
{ open: vi.fn() } as unknown as SetupAIProviderArgs[1],
|
|
||||||
{
|
|
||||||
session: {
|
|
||||||
account$: new BehaviorSubject(null),
|
|
||||||
},
|
|
||||||
} as unknown as SetupAIProviderArgs[2]
|
|
||||||
);
|
|
||||||
|
|
||||||
await drainActionResult(
|
|
||||||
await AIProvider.actions.brainstormMindmap?.({
|
|
||||||
workspaceId: 'workspace-1',
|
|
||||||
input: 'make a map',
|
|
||||||
stream: true,
|
|
||||||
} satisfies ActionInput<'brainstormMindmap'>)
|
|
||||||
);
|
|
||||||
await drainActionResult(
|
|
||||||
await AIProvider.actions.createSlides?.({
|
|
||||||
workspaceId: 'workspace-1',
|
|
||||||
input: 'make slides',
|
|
||||||
stream: true,
|
|
||||||
} satisfies ActionInput<'createSlides'>)
|
|
||||||
);
|
|
||||||
await drainActionResult(
|
|
||||||
await AIProvider.actions.filterImage?.({
|
|
||||||
workspaceId: 'workspace-1',
|
|
||||||
input: 'convert',
|
|
||||||
attachments: ['blob-1'],
|
|
||||||
style: 'Sketch style',
|
|
||||||
} satisfies ActionInput<'filterImage'>)
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(createdSessions).toEqual([
|
|
||||||
expect.objectContaining({ promptName: 'mindmap.generate' }),
|
|
||||||
expect.objectContaining({ promptName: 'slides.outline' }),
|
|
||||||
expect.objectContaining({ promptName: 'image.filter.sketch' }),
|
|
||||||
]);
|
|
||||||
expect(textStreams).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
endpoint: Endpoint.Action,
|
|
||||||
options: expect.objectContaining({ actionId: 'mindmap.generate' }),
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
endpoint: Endpoint.Action,
|
|
||||||
options: expect.objectContaining({ actionId: 'slides.outline' }),
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
endpoint: Endpoint.Action,
|
|
||||||
options: expect.objectContaining({ actionId: 'image.filter.sketch' }),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(client.imagesStream).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,20 +1,11 @@
|
|||||||
import { toggleGeneralAIOnboarding } from '@affine/core/components/affine/ai-onboarding/apis';
|
import { toggleGeneralAIOnboarding } from '@affine/core/components/affine/ai-onboarding/apis';
|
||||||
import type { AuthAccountInfo, AuthService } from '@affine/core/modules/cloud';
|
import type { AuthAccountInfo, AuthService } from '@affine/core/modules/cloud';
|
||||||
import type { GlobalDialogService } from '@affine/core/modules/dialogs';
|
import type { GlobalDialogService } from '@affine/core/modules/dialogs';
|
||||||
import {
|
|
||||||
type AddContextFileInput,
|
|
||||||
ContextCategories,
|
|
||||||
type ContextWorkspaceEmbeddingStatus,
|
|
||||||
type getCopilotHistoriesQuery,
|
|
||||||
type QueryChatSessionsInput,
|
|
||||||
type RequestOptions,
|
|
||||||
type UpdateChatSessionInput,
|
|
||||||
} from '@affine/graphql';
|
|
||||||
|
|
||||||
|
import type { AIRequestService } from '../runtime/request';
|
||||||
|
import { setAIRequestService } from '../runtime/request';
|
||||||
|
import { AIAppEvents } from './ai-app-events';
|
||||||
import { AIProvider } from './ai-provider';
|
import { AIProvider } from './ai-provider';
|
||||||
import { type CopilotClient, Endpoint } from './copilot-client';
|
|
||||||
import type { PromptKey } from './prompt';
|
|
||||||
import { textToText, toImage } from './request';
|
|
||||||
import { setupTracker } from './tracker';
|
import { setupTracker } from './tracker';
|
||||||
|
|
||||||
function toAIUserInfo(account: AuthAccountInfo | null) {
|
function toAIUserInfo(account: AuthAccountInfo | null) {
|
||||||
@@ -27,48 +18,12 @@ function toAIUserInfo(account: AuthAccountInfo | null) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterStyleToPromptName = new Map<string, PromptKey>(
|
|
||||||
Object.entries({
|
|
||||||
'Clay style': 'image.filter.clay',
|
|
||||||
'Pixel style': 'image.filter.pixel',
|
|
||||||
'Sketch style': 'image.filter.sketch',
|
|
||||||
'Anime style': 'image.filter.anime',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const processTypeToPromptName = new Map<string, PromptKey>(
|
|
||||||
Object.entries({
|
|
||||||
Clearer: 'Upscale image',
|
|
||||||
'Remove background': 'Remove background',
|
|
||||||
'Convert to sticker': 'Convert to sticker',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export function setupAIProvider(
|
export function setupAIProvider(
|
||||||
client: CopilotClient,
|
requestService: AIRequestService,
|
||||||
globalDialogService: GlobalDialogService,
|
globalDialogService: GlobalDialogService,
|
||||||
authService: AuthService
|
authService: AuthService
|
||||||
) {
|
) {
|
||||||
async function createSession({
|
setAIRequestService(requestService);
|
||||||
promptName,
|
|
||||||
workspaceId,
|
|
||||||
docId,
|
|
||||||
sessionId,
|
|
||||||
retry,
|
|
||||||
pinned,
|
|
||||||
reuseLatestChat,
|
|
||||||
}: BlockSuitePresets.AICreateSessionOptions) {
|
|
||||||
if (sessionId) return sessionId;
|
|
||||||
if (retry) return AIProvider.LAST_ACTION_SESSIONID;
|
|
||||||
|
|
||||||
return client.createSession({
|
|
||||||
workspaceId,
|
|
||||||
docId,
|
|
||||||
promptName,
|
|
||||||
pinned,
|
|
||||||
reuseLatestChat,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
AIProvider.provide('userInfo', () => {
|
AIProvider.provide('userInfo', () => {
|
||||||
return toAIUserInfo(authService.session.account$.value);
|
return toAIUserInfo(authService.session.account$.value);
|
||||||
@@ -76,715 +31,10 @@ export function setupAIProvider(
|
|||||||
|
|
||||||
const accountSubscription = authService.session.account$.subscribe(
|
const accountSubscription = authService.session.account$.subscribe(
|
||||||
account => {
|
account => {
|
||||||
AIProvider.slots.userInfo.next(toAIUserInfo(account));
|
AIAppEvents.userInfo.next(toAIUserInfo(account));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
//#region actions
|
|
||||||
AIProvider.provide('chat', async options => {
|
|
||||||
const { input, contexts } = options;
|
|
||||||
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Chat With AFFiNE AI',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
modelId: options.modelId,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: input,
|
|
||||||
timeout: 5 * 60 * 1000, // 5 minutes
|
|
||||||
params: {
|
|
||||||
docs: contexts?.docs,
|
|
||||||
files: contexts?.files,
|
|
||||||
selectedSnapshot: contexts?.selectedSnapshot,
|
|
||||||
selectedMarkdown: contexts?.selectedMarkdown,
|
|
||||||
html: contexts?.html,
|
|
||||||
...(options.docId ? { currentDocId: options.docId } : {}),
|
|
||||||
},
|
|
||||||
endpoint: Endpoint.StreamObject,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('summary', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Summary',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('translate', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Translate to',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
params: {
|
|
||||||
language: options.lang,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('changeTone', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Change tone to',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
params: {
|
|
||||||
tone: options.tone.toLowerCase(),
|
|
||||||
},
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('improveWriting', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Improve writing for it',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('improveGrammar', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Improve grammar for it',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('fixSpelling', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Fix spelling for it',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('createHeadings', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Create headings',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('makeLonger', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Make it longer',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('makeShorter', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Make it shorter',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('checkCodeErrors', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Check code error',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('explainCode', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Explain this code',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('writeArticle', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Write an article about this',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('writeTwitterPost', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Write a twitter about this',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('writePoem', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Write a poem about this',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('writeOutline', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Write outline',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('writeBlogPost', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Write a blog post about this',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('brainstorm', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Brainstorm ideas about this',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('findActions', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Find action items from it',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('brainstormMindmap', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'mindmap.generate',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
// 3 minutes
|
|
||||||
timeout: 180000,
|
|
||||||
endpoint: Endpoint.Action,
|
|
||||||
actionId: 'mindmap.generate',
|
|
||||||
actionVersion: 'v1',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('expandMindmap', async options => {
|
|
||||||
if (!options.input) {
|
|
||||||
throw new Error('expandMindmap action requires input');
|
|
||||||
}
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Expand mind map',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
params: {
|
|
||||||
mindmap: options.mindmap,
|
|
||||||
node: options.input,
|
|
||||||
},
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('explain', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Explain this',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('explainImage', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Explain this image',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('makeItReal', async options => {
|
|
||||||
let promptName: PromptKey = 'Make it real';
|
|
||||||
let content = options.input || '';
|
|
||||||
|
|
||||||
// wireframes
|
|
||||||
if (options.attachments?.length) {
|
|
||||||
content = `Here are the latest wireframes. Could you make a new website based on these wireframes and notes and send back just the html file?
|
|
||||||
Here are our design notes:\n ${content}.`;
|
|
||||||
} else {
|
|
||||||
// notes
|
|
||||||
promptName = 'Make it real with text';
|
|
||||||
content = `Here are the latest notes: \n ${content}.
|
|
||||||
Could you make a new website based on these notes and send back just the html file?`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName,
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('createSlides', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'slides.outline',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
// 3 minutes
|
|
||||||
timeout: 180000,
|
|
||||||
endpoint: Endpoint.Action,
|
|
||||||
actionId: 'slides.outline',
|
|
||||||
actionVersion: 'v1',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('createImage', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Generate image',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return toImage({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content:
|
|
||||||
!options.input && options.attachments
|
|
||||||
? 'Make the image more detailed.'
|
|
||||||
: options.input,
|
|
||||||
// 5 minutes
|
|
||||||
timeout: 300000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('filterImage', async options => {
|
|
||||||
// test to image
|
|
||||||
const promptName: PromptKey | undefined = filterStyleToPromptName.get(
|
|
||||||
options.style
|
|
||||||
);
|
|
||||||
if (!promptName) {
|
|
||||||
throw new Error('filterImage requires a promptName');
|
|
||||||
}
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName,
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return toImage({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
timeout: 180000,
|
|
||||||
endpoint: Endpoint.Action,
|
|
||||||
actionId: promptName,
|
|
||||||
actionVersion: 'v1',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('processImage', async options => {
|
|
||||||
// test to image
|
|
||||||
const promptName: PromptKey | undefined = processTypeToPromptName.get(
|
|
||||||
options.type
|
|
||||||
);
|
|
||||||
if (!promptName) {
|
|
||||||
throw new Error('processImage requires a promptName');
|
|
||||||
}
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName,
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return toImage({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
timeout: 180000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('generateCaption', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Generate a caption',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('continueWriting', async options => {
|
|
||||||
const sessionId = await createSession({
|
|
||||||
promptName: 'Continue writing',
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
return textToText({
|
|
||||||
...options,
|
|
||||||
client,
|
|
||||||
sessionId,
|
|
||||||
content: options.input,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
//#endregion
|
|
||||||
|
|
||||||
AIProvider.provide('session', {
|
|
||||||
createSession,
|
|
||||||
createSessionWithHistory: async options => {
|
|
||||||
if (!options.sessionId && !options.retry) {
|
|
||||||
return client.createSessionWithHistory({
|
|
||||||
workspaceId: options.workspaceId,
|
|
||||||
docId: options.docId,
|
|
||||||
promptName: options.promptName,
|
|
||||||
pinned: options.pinned,
|
|
||||||
reuseLatestChat: options.reuseLatestChat,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = await createSession(options);
|
|
||||||
if (!sessionId) return undefined;
|
|
||||||
return client.getSession(options.workspaceId, sessionId);
|
|
||||||
},
|
|
||||||
getSession: async (workspaceId: string, sessionId: string) => {
|
|
||||||
return client.getSession(workspaceId, sessionId);
|
|
||||||
},
|
|
||||||
getSessions: async (
|
|
||||||
workspaceId: string,
|
|
||||||
docId?: string,
|
|
||||||
options?: QueryChatSessionsInput
|
|
||||||
) => {
|
|
||||||
return client.getSessions(workspaceId, {}, docId, options);
|
|
||||||
},
|
|
||||||
getRecentSessions: async (
|
|
||||||
workspaceId: string,
|
|
||||||
limit?: number,
|
|
||||||
offset?: number
|
|
||||||
) => {
|
|
||||||
return client.getRecentSessions(workspaceId, limit, offset);
|
|
||||||
},
|
|
||||||
updateSession: async (options: UpdateChatSessionInput) => {
|
|
||||||
return client.updateSession(options);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('context', {
|
|
||||||
createContext: async (workspaceId: string, sessionId: string) => {
|
|
||||||
return client.createContext(workspaceId, sessionId);
|
|
||||||
},
|
|
||||||
getContextId: async (workspaceId: string, sessionId: string) => {
|
|
||||||
return client.getContextId(workspaceId, sessionId);
|
|
||||||
},
|
|
||||||
addContextDoc: async (options: { contextId: string; docId: string }) => {
|
|
||||||
return client.addContextDoc(options);
|
|
||||||
},
|
|
||||||
removeContextDoc: async (options: { contextId: string; docId: string }) => {
|
|
||||||
return client.removeContextDoc(options);
|
|
||||||
},
|
|
||||||
addContextFile: async (file: File, options: AddContextFileInput) => {
|
|
||||||
return client.addContextFile(file, options);
|
|
||||||
},
|
|
||||||
removeContextFile: async (options: {
|
|
||||||
contextId: string;
|
|
||||||
fileId: string;
|
|
||||||
}) => {
|
|
||||||
return client.removeContextFile(options);
|
|
||||||
},
|
|
||||||
addContextTag: async (options: {
|
|
||||||
contextId: string;
|
|
||||||
tagId: string;
|
|
||||||
docIds: string[];
|
|
||||||
}) => {
|
|
||||||
return client.addContextCategory({
|
|
||||||
contextId: options.contextId,
|
|
||||||
type: ContextCategories.Tag,
|
|
||||||
categoryId: options.tagId,
|
|
||||||
docs: options.docIds,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
removeContextTag: async (options: { contextId: string; tagId: string }) => {
|
|
||||||
return client.removeContextCategory({
|
|
||||||
contextId: options.contextId,
|
|
||||||
type: ContextCategories.Tag,
|
|
||||||
categoryId: options.tagId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
addContextCollection: async (options: {
|
|
||||||
contextId: string;
|
|
||||||
collectionId: string;
|
|
||||||
docIds: string[];
|
|
||||||
}) => {
|
|
||||||
return client.addContextCategory({
|
|
||||||
contextId: options.contextId,
|
|
||||||
type: ContextCategories.Collection,
|
|
||||||
categoryId: options.collectionId,
|
|
||||||
docs: options.docIds,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
removeContextCollection: async (options: {
|
|
||||||
contextId: string;
|
|
||||||
collectionId: string;
|
|
||||||
}) => {
|
|
||||||
return client.removeContextCategory({
|
|
||||||
contextId: options.contextId,
|
|
||||||
type: ContextCategories.Collection,
|
|
||||||
categoryId: options.collectionId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
getContextDocsAndFiles: async (
|
|
||||||
workspaceId: string,
|
|
||||||
sessionId: string,
|
|
||||||
contextId: string
|
|
||||||
) => {
|
|
||||||
return client.getContextDocsAndFiles(workspaceId, sessionId, contextId);
|
|
||||||
},
|
|
||||||
pollContextDocsAndFiles: async (
|
|
||||||
workspaceId: string,
|
|
||||||
sessionId: string,
|
|
||||||
contextId: string,
|
|
||||||
onPoll: (
|
|
||||||
result: BlockSuitePresets.AIDocsAndFilesContext | undefined
|
|
||||||
) => void,
|
|
||||||
abortSignal: AbortSignal
|
|
||||||
) => {
|
|
||||||
const poll = async () => {
|
|
||||||
const result = await client.getContextDocsAndFiles(
|
|
||||||
workspaceId,
|
|
||||||
sessionId,
|
|
||||||
contextId
|
|
||||||
);
|
|
||||||
onPoll(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
let attempts = 0;
|
|
||||||
const MIN_INTERVAL = 1000;
|
|
||||||
const MAX_INTERVAL = 30 * 1000;
|
|
||||||
|
|
||||||
while (!abortSignal.aborted) {
|
|
||||||
await poll();
|
|
||||||
const interval = Math.min(
|
|
||||||
MIN_INTERVAL * Math.pow(1.5, attempts),
|
|
||||||
MAX_INTERVAL
|
|
||||||
);
|
|
||||||
attempts++;
|
|
||||||
await new Promise(resolve => setTimeout(resolve, interval));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
pollEmbeddingStatus: async (
|
|
||||||
workspaceId: string,
|
|
||||||
onPoll: (result: ContextWorkspaceEmbeddingStatus) => void,
|
|
||||||
abortSignal: AbortSignal
|
|
||||||
) => {
|
|
||||||
const poll = async () => {
|
|
||||||
const result = await client.getEmbeddingStatus(workspaceId);
|
|
||||||
onPoll(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
const INTERVAL = 10 * 1000;
|
|
||||||
|
|
||||||
while (!abortSignal.aborted) {
|
|
||||||
await poll();
|
|
||||||
await new Promise(resolve => setTimeout(resolve, INTERVAL));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
matchContext: async (
|
|
||||||
content: string,
|
|
||||||
contextId?: string,
|
|
||||||
workspaceId?: string,
|
|
||||||
limit?: number,
|
|
||||||
scopedThreshold?: number,
|
|
||||||
threshold?: number
|
|
||||||
) => {
|
|
||||||
return client.matchContext(
|
|
||||||
content,
|
|
||||||
contextId,
|
|
||||||
workspaceId,
|
|
||||||
limit,
|
|
||||||
scopedThreshold,
|
|
||||||
threshold
|
|
||||||
);
|
|
||||||
},
|
|
||||||
addContextBlob: async (options: { blobId: string; contextId: string }) => {
|
|
||||||
return client.addContextBlob({
|
|
||||||
contextId: options.contextId,
|
|
||||||
blobId: options.blobId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
removeContextBlob: async (options: {
|
|
||||||
blobId: string;
|
|
||||||
contextId: string;
|
|
||||||
}) => {
|
|
||||||
return client.removeContextBlob({
|
|
||||||
contextId: options.contextId,
|
|
||||||
blobId: options.blobId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('histories', {
|
|
||||||
actions: async (
|
|
||||||
workspaceId: string,
|
|
||||||
docId: string
|
|
||||||
): Promise<BlockSuitePresets.AIHistory[]> => {
|
|
||||||
// @ts-expect-error - 'action' is missing in server impl
|
|
||||||
return (
|
|
||||||
(await client.getHistories(workspaceId, {}, docId, {
|
|
||||||
action: true,
|
|
||||||
withPrompt: true,
|
|
||||||
withMessages: true,
|
|
||||||
})) ?? []
|
|
||||||
);
|
|
||||||
},
|
|
||||||
chats: async (
|
|
||||||
workspaceId: string,
|
|
||||||
sessionId: string,
|
|
||||||
docId?: string
|
|
||||||
): Promise<BlockSuitePresets.AIHistory[]> => {
|
|
||||||
// @ts-expect-error - 'action' is missing in server impl
|
|
||||||
return (
|
|
||||||
(await client.getHistories(workspaceId, {}, docId, {
|
|
||||||
sessionId,
|
|
||||||
withMessages: true,
|
|
||||||
})) ?? []
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cleanup: async (
|
|
||||||
workspaceId: string,
|
|
||||||
docId: string | undefined,
|
|
||||||
sessionIds: string[]
|
|
||||||
) => {
|
|
||||||
await client.cleanupSessions({ workspaceId, docId, sessionIds });
|
|
||||||
},
|
|
||||||
ids: async (
|
|
||||||
workspaceId: string,
|
|
||||||
docId?: string,
|
|
||||||
options?: RequestOptions<
|
|
||||||
typeof getCopilotHistoriesQuery
|
|
||||||
>['variables']['options']
|
|
||||||
): Promise<BlockSuitePresets.AIHistoryIds[]> => {
|
|
||||||
// @ts-expect-error - 'action' is missing in server impl
|
|
||||||
return await client.getHistoryIds(workspaceId, {}, docId, options);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
AIProvider.provide('photoEngine', {
|
AIProvider.provide('photoEngine', {
|
||||||
async searchImages(options): Promise<string[]> {
|
async searchImages(options): Promise<string[]> {
|
||||||
let url = '/api/copilot/unsplash/photos';
|
let url = '/api/copilot/unsplash/photos';
|
||||||
@@ -813,19 +63,15 @@ Could you make a new website based on these notes and send back just the html fi
|
|||||||
|
|
||||||
AIProvider.provide('onboarding', toggleGeneralAIOnboarding);
|
AIProvider.provide('onboarding', toggleGeneralAIOnboarding);
|
||||||
|
|
||||||
AIProvider.provide('forkChat', options => {
|
const disposeRequestLoginHandler = AIAppEvents.requestLogin.subscribe(() => {
|
||||||
return client.forkSession(options);
|
globalDialogService.open('sign-in', {});
|
||||||
});
|
});
|
||||||
|
|
||||||
const disposeRequestLoginHandler = AIProvider.slots.requestLogin.subscribe(
|
const trackerDisposer = setupTracker(requestService);
|
||||||
() => {
|
|
||||||
globalDialogService.open('sign-in', {});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
setupTracker();
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
setAIRequestService(null);
|
||||||
|
trackerDisposer();
|
||||||
disposeRequestLoginHandler.unsubscribe();
|
disposeRequestLoginHandler.unsubscribe();
|
||||||
accountSubscription.unsubscribe();
|
accountSubscription.unsubscribe();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import type { EditorHost } from '@blocksuite/affine/std';
|
|||||||
import type { GfxPrimitiveElementModel } from '@blocksuite/affine/std/gfx';
|
import type { GfxPrimitiveElementModel } from '@blocksuite/affine/std/gfx';
|
||||||
import type { BlockModel } from '@blocksuite/affine/store';
|
import type { BlockModel } from '@blocksuite/affine/store';
|
||||||
import { lowerCase, omit } from 'lodash-es';
|
import { lowerCase, omit } from 'lodash-es';
|
||||||
import type { Subject } from 'rxjs';
|
|
||||||
|
|
||||||
import { AIProvider } from './ai-provider';
|
import type {
|
||||||
|
AIRequestActionEvent,
|
||||||
|
AIRequestService,
|
||||||
|
} from '../runtime/request';
|
||||||
|
import { AIAppEvents } from './ai-app-events';
|
||||||
|
|
||||||
type ElementModel = GfxPrimitiveElementModel;
|
type ElementModel = GfxPrimitiveElementModel;
|
||||||
|
|
||||||
@@ -62,10 +65,6 @@ type AIActionEventProperties = {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SubjectValue<T> = T extends Subject<infer U> ? U : never;
|
|
||||||
|
|
||||||
type BlocksuiteActionEvent = SubjectValue<typeof AIProvider.slots.actions>;
|
|
||||||
|
|
||||||
const trackAction = ({
|
const trackAction = ({
|
||||||
eventName,
|
eventName,
|
||||||
properties,
|
properties,
|
||||||
@@ -106,7 +105,7 @@ function isBlockModel(model: BlockModel | ElementModel): model is BlockModel {
|
|||||||
return 'flavour' in model;
|
return 'flavour' in model;
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferObjectType(event: BlocksuiteActionEvent) {
|
function inferObjectType(event: AIRequestActionEvent) {
|
||||||
const models: (BlockModel | ElementModel)[] | undefined =
|
const models: (BlockModel | ElementModel)[] | undefined =
|
||||||
event.options.models;
|
event.options.models;
|
||||||
if (!models) {
|
if (!models) {
|
||||||
@@ -135,7 +134,7 @@ function inferObjectType(event: BlocksuiteActionEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function inferSegment(
|
function inferSegment(
|
||||||
event: BlocksuiteActionEvent
|
event: AIRequestActionEvent
|
||||||
): AIActionEventProperties['segment'] {
|
): AIActionEventProperties['segment'] {
|
||||||
if (event.options.where === 'inline-chat-panel') {
|
if (event.options.where === 'inline-chat-panel') {
|
||||||
return 'inline chat panel';
|
return 'inline chat panel';
|
||||||
@@ -151,7 +150,7 @@ function inferSegment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function inferModule(
|
function inferModule(
|
||||||
event: BlocksuiteActionEvent
|
event: AIRequestActionEvent
|
||||||
): AIActionEventProperties['module'] {
|
): AIActionEventProperties['module'] {
|
||||||
if (event.options.where === 'chat-panel') {
|
if (event.options.where === 'chat-panel') {
|
||||||
return 'AI chat panel';
|
return 'AI chat panel';
|
||||||
@@ -168,9 +167,7 @@ function inferModule(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferEventName(
|
function inferEventName(event: AIRequestActionEvent): AIActionEventName | null {
|
||||||
event: BlocksuiteActionEvent
|
|
||||||
): AIActionEventName | null {
|
|
||||||
if (['result:discard', 'result:retry'].includes(event.event)) {
|
if (['result:discard', 'result:retry'].includes(event.event)) {
|
||||||
return 'AI result discarded';
|
return 'AI result discarded';
|
||||||
} else if (event.event.startsWith('result:')) {
|
} else if (event.event.startsWith('result:')) {
|
||||||
@@ -184,7 +181,7 @@ function inferEventName(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function inferControl(
|
function inferControl(
|
||||||
event: BlocksuiteActionEvent
|
event: AIRequestActionEvent
|
||||||
): AIActionEventProperties['control'] {
|
): AIActionEventProperties['control'] {
|
||||||
if (event.event === 'aborted:stop') {
|
if (event.event === 'aborted:stop') {
|
||||||
return 'stop button';
|
return 'stop button';
|
||||||
@@ -222,7 +219,7 @@ function inferControl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toTrackedOptions = (
|
const toTrackedOptions = (
|
||||||
event: BlocksuiteActionEvent
|
event: AIRequestActionEvent
|
||||||
): {
|
): {
|
||||||
eventName: AIActionEventName;
|
eventName: AIActionEventName;
|
||||||
properties: AIActionEventProperties;
|
properties: AIActionEventProperties;
|
||||||
@@ -257,19 +254,25 @@ const toTrackedOptions = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function setupTracker() {
|
export function setupTracker(requestService: AIRequestService) {
|
||||||
AIProvider.slots.requestUpgradePlan.subscribe(() => {
|
const upgradeSubscription = AIAppEvents.requestUpgradePlan.subscribe(() => {
|
||||||
track.$.paywall.aiAction.viewPlans();
|
track.$.paywall.aiAction.viewPlans();
|
||||||
});
|
});
|
||||||
|
|
||||||
AIProvider.slots.requestLogin.subscribe(() => {
|
const loginSubscription = AIAppEvents.requestLogin.subscribe(() => {
|
||||||
track.doc.editor.aiActions.requestSignIn();
|
track.doc.editor.aiActions.requestSignIn();
|
||||||
});
|
});
|
||||||
|
|
||||||
AIProvider.slots.actions.subscribe(event => {
|
const actionSubscription = requestService.actionEvents$.subscribe(event => {
|
||||||
const properties = toTrackedOptions(event);
|
const properties = toTrackedOptions(event);
|
||||||
if (properties) {
|
if (properties) {
|
||||||
trackAction(properties);
|
trackAction(properties);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
upgradeSubscription.unsubscribe();
|
||||||
|
loginSubscription.unsubscribe();
|
||||||
|
actionSubscription.unsubscribe();
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { CopilotChatHistoryFragment } from '@affine/graphql';
|
||||||
|
|
||||||
|
import type { AIChatContextItem, AIChatScope } from './state';
|
||||||
|
|
||||||
|
export type AIChatSendOptions = {
|
||||||
|
input?: string;
|
||||||
|
contexts?: {
|
||||||
|
docs?: unknown;
|
||||||
|
files?: unknown;
|
||||||
|
selectedSnapshot?: unknown;
|
||||||
|
selectedMarkdown?: unknown;
|
||||||
|
html?: unknown;
|
||||||
|
};
|
||||||
|
attachments?: (string | Blob | File)[];
|
||||||
|
attachmentPreviews?: string[];
|
||||||
|
isRootSession?: boolean;
|
||||||
|
where?: BlockSuitePresets.TrackerWhere;
|
||||||
|
control?: BlockSuitePresets.TrackerControl;
|
||||||
|
reasoning?: boolean;
|
||||||
|
toolsConfig?: unknown;
|
||||||
|
modelId?: string;
|
||||||
|
userInfo?: {
|
||||||
|
userId?: string;
|
||||||
|
userName?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AIChatAction =
|
||||||
|
| { type: 'initialize'; scope?: AIChatScope }
|
||||||
|
| { type: 'setScope'; scope: AIChatScope }
|
||||||
|
| { type: 'refreshHistory' }
|
||||||
|
| {
|
||||||
|
type: 'openSession';
|
||||||
|
sessionId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'openSessionObject';
|
||||||
|
session: CopilotChatHistoryFragment;
|
||||||
|
}
|
||||||
|
| { type: 'closeTab'; tabId: string }
|
||||||
|
| { type: 'createNewSession'; pinned?: boolean }
|
||||||
|
| { type: 'togglePinActiveSession' }
|
||||||
|
| { type: 'deleteSession'; sessionId: string }
|
||||||
|
| { type: 'clearError' }
|
||||||
|
| { type: 'setComposerText'; text: string }
|
||||||
|
| { type: 'setReasoning'; reasoning: boolean }
|
||||||
|
| { type: 'setModel'; modelId?: string }
|
||||||
|
| { type: 'addAttachment'; attachment: string | Blob | File }
|
||||||
|
| { type: 'removeAttachment'; index: number }
|
||||||
|
| { type: 'addContextItem'; item: AIChatContextItem }
|
||||||
|
| { type: 'removeContextItem'; item: AIChatContextItem }
|
||||||
|
| { type: 'loadContext' }
|
||||||
|
| { type: 'pollContext' }
|
||||||
|
| { type: 'startContextPolling' }
|
||||||
|
| { type: 'stopContextPolling' }
|
||||||
|
| { type: 'pollEmbeddingStatus' }
|
||||||
|
| ({ type: 'send' } & AIChatSendOptions)
|
||||||
|
| { type: 'retry'; messageId: string }
|
||||||
|
| { type: 'stop' };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export * from './actions';
|
||||||
|
export * from './runtime';
|
||||||
|
export * from './session-strategy';
|
||||||
|
export * from './state';
|
||||||
|
export * from './use-element';
|
||||||
|
export * from './use-runtime';
|
||||||
File diff suppressed because it is too large
Load Diff
1311
packages/frontend/core/src/blocksuite/ai/runtime/chat/runtime.ts
Normal file
1311
packages/frontend/core/src/blocksuite/ai/runtime/chat/runtime.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,225 @@
|
|||||||
|
import type { CopilotChatHistoryFragment } from '@affine/graphql';
|
||||||
|
|
||||||
|
import type { AIRequestService } from '../request';
|
||||||
|
import { type AIChatScope, type AIChatTab, createDraftTab } from './state';
|
||||||
|
|
||||||
|
export type OpenSessionResult =
|
||||||
|
| {
|
||||||
|
type: 'opened';
|
||||||
|
session: CopilotChatHistoryFragment;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'navigate';
|
||||||
|
target: {
|
||||||
|
workspaceId: string;
|
||||||
|
docId: string;
|
||||||
|
sessionId: string;
|
||||||
|
};
|
||||||
|
resetTabs: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AIChatSessionStrategy {
|
||||||
|
loadInitialSession(
|
||||||
|
scope: AIChatScope,
|
||||||
|
request: AIRequestService
|
||||||
|
): Promise<CopilotChatHistoryFragment | null>;
|
||||||
|
createDraftSession(scope: AIChatScope): AIChatTab;
|
||||||
|
createSession(
|
||||||
|
scope: AIChatScope,
|
||||||
|
request: AIRequestService,
|
||||||
|
options?: { pinned?: boolean }
|
||||||
|
): Promise<CopilotChatHistoryFragment | null | undefined>;
|
||||||
|
canOpenAsTab(
|
||||||
|
session: CopilotChatHistoryFragment,
|
||||||
|
scope: AIChatScope
|
||||||
|
): boolean;
|
||||||
|
openSession(
|
||||||
|
session: CopilotChatHistoryFragment,
|
||||||
|
scope: AIChatScope
|
||||||
|
): OpenSessionResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DocAIChatSessionStrategy implements AIChatSessionStrategy {
|
||||||
|
async loadInitialSession(scope: AIChatScope, request: AIRequestService) {
|
||||||
|
if (scope.kind !== 'doc') return null;
|
||||||
|
const pinned = await request.getSessions(scope.workspaceId, undefined, {
|
||||||
|
pinned: true,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
if (Array.isArray(pinned) && pinned[0]) {
|
||||||
|
return (
|
||||||
|
(await request.getSession(scope.workspaceId, pinned[0].sessionId)) ??
|
||||||
|
pinned[0]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (scope.pendingSessionId) {
|
||||||
|
return (
|
||||||
|
(await request.getSession(scope.workspaceId, scope.pendingSessionId)) ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const docSessions = await request.getSessions(
|
||||||
|
scope.workspaceId,
|
||||||
|
scope.docId,
|
||||||
|
{
|
||||||
|
action: false,
|
||||||
|
fork: false,
|
||||||
|
limit: 1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const session = docSessions?.[0];
|
||||||
|
if (!session) return null;
|
||||||
|
return (
|
||||||
|
(await request.getSession(scope.workspaceId, session.sessionId)) ??
|
||||||
|
session
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createDraftSession(scope: AIChatScope) {
|
||||||
|
return createDraftTab(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
createSession(
|
||||||
|
scope: AIChatScope,
|
||||||
|
request: AIRequestService,
|
||||||
|
options: { pinned?: boolean } = {}
|
||||||
|
) {
|
||||||
|
if (scope.kind !== 'doc') return Promise.resolve(null);
|
||||||
|
return request.createSessionWithHistory({
|
||||||
|
workspaceId: scope.workspaceId,
|
||||||
|
docId: scope.docId,
|
||||||
|
promptName: 'Chat With AFFiNE AI',
|
||||||
|
reuseLatestChat: false,
|
||||||
|
pinned: options.pinned,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
canOpenAsTab(session: CopilotChatHistoryFragment, scope: AIChatScope) {
|
||||||
|
return (
|
||||||
|
scope.kind === 'doc' && (!session.docId || session.docId === scope.docId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
openSession(session: CopilotChatHistoryFragment, scope: AIChatScope) {
|
||||||
|
if (this.canOpenAsTab(session, scope)) {
|
||||||
|
return { type: 'opened' as const, session };
|
||||||
|
}
|
||||||
|
if (scope.kind === 'doc' && session.docId) {
|
||||||
|
return {
|
||||||
|
type: 'navigate' as const,
|
||||||
|
target: {
|
||||||
|
workspaceId: session.workspaceId,
|
||||||
|
docId: session.docId,
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
},
|
||||||
|
resetTabs: true as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { type: 'opened' as const, session };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkspaceAIChatSessionStrategy implements AIChatSessionStrategy {
|
||||||
|
async loadInitialSession(scope: AIChatScope, request: AIRequestService) {
|
||||||
|
const sessions = await request.getSessions(scope.workspaceId, undefined, {
|
||||||
|
pinned: true,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
const session = sessions?.[0];
|
||||||
|
if (!session) return null;
|
||||||
|
return (
|
||||||
|
(await request.getSession(scope.workspaceId, session.sessionId)) ??
|
||||||
|
session
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createDraftSession(scope: AIChatScope) {
|
||||||
|
return createDraftTab(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
createSession(
|
||||||
|
scope: AIChatScope,
|
||||||
|
request: AIRequestService,
|
||||||
|
options: { pinned?: boolean } = {}
|
||||||
|
) {
|
||||||
|
return request.createSessionWithHistory({
|
||||||
|
workspaceId: scope.workspaceId,
|
||||||
|
promptName: 'Chat With AFFiNE AI',
|
||||||
|
reuseLatestChat: false,
|
||||||
|
pinned: options.pinned,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
canOpenAsTab(session: CopilotChatHistoryFragment, scope: AIChatScope) {
|
||||||
|
return session.workspaceId === scope.workspaceId && !session.docId;
|
||||||
|
}
|
||||||
|
|
||||||
|
openSession(session: CopilotChatHistoryFragment) {
|
||||||
|
return { type: 'opened' as const, session };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ForkAIChatSessionStrategy implements AIChatSessionStrategy {
|
||||||
|
async loadInitialSession(scope: AIChatScope, request: AIRequestService) {
|
||||||
|
if (
|
||||||
|
scope.kind !== 'fork' &&
|
||||||
|
scope.kind !== 'chat-block' &&
|
||||||
|
scope.kind !== 'playground'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parentSessionId =
|
||||||
|
'parentSessionId' in scope ? scope.parentSessionId : undefined;
|
||||||
|
if (!parentSessionId) return null;
|
||||||
|
return (
|
||||||
|
(await request.getSession(scope.workspaceId, parentSessionId)) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createDraftSession(scope: AIChatScope) {
|
||||||
|
return createDraftTab(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(
|
||||||
|
scope: AIChatScope,
|
||||||
|
request: AIRequestService,
|
||||||
|
options: { pinned?: boolean } = {}
|
||||||
|
) {
|
||||||
|
const docId = 'docId' in scope ? scope.docId : undefined;
|
||||||
|
const parentSessionId =
|
||||||
|
'parentSessionId' in scope ? scope.parentSessionId : undefined;
|
||||||
|
if (!parentSessionId) {
|
||||||
|
return request.createSessionWithHistory({
|
||||||
|
workspaceId: scope.workspaceId,
|
||||||
|
docId,
|
||||||
|
promptName: 'Chat With AFFiNE AI',
|
||||||
|
reuseLatestChat: false,
|
||||||
|
pinned: options.pinned,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestMessageId =
|
||||||
|
'latestMessageId' in scope ? scope.latestMessageId : undefined;
|
||||||
|
const forkSessionId = await request.forkChat({
|
||||||
|
workspaceId: scope.workspaceId,
|
||||||
|
docId: docId ?? '',
|
||||||
|
sessionId: parentSessionId,
|
||||||
|
...(latestMessageId ? { latestMessageId } : {}),
|
||||||
|
});
|
||||||
|
if (!forkSessionId) return null;
|
||||||
|
return request.getSession(scope.workspaceId, forkSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
canOpenAsTab(session: CopilotChatHistoryFragment, scope: AIChatScope) {
|
||||||
|
return session.workspaceId === scope.workspaceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
openSession(session: CopilotChatHistoryFragment) {
|
||||||
|
return { type: 'opened' as const, session };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChatBlockAIChatSessionStrategy extends ForkAIChatSessionStrategy {}
|
||||||
|
|
||||||
|
export class PlaygroundAIChatSessionStrategy extends ForkAIChatSessionStrategy {}
|
||||||
212
packages/frontend/core/src/blocksuite/ai/runtime/chat/state.ts
Normal file
212
packages/frontend/core/src/blocksuite/ai/runtime/chat/state.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import type { AIToolsConfig } from '@affine/core/modules/ai-button';
|
||||||
|
import type { CopilotChatHistoryFragment } from '@affine/graphql';
|
||||||
|
|
||||||
|
export type AIChatScope =
|
||||||
|
| {
|
||||||
|
kind: 'doc';
|
||||||
|
workspaceId: string;
|
||||||
|
docId: string;
|
||||||
|
pendingSessionId?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'workspace';
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'fork';
|
||||||
|
workspaceId: string;
|
||||||
|
parentSessionId: string;
|
||||||
|
latestMessageId?: string;
|
||||||
|
docId?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'chat-block';
|
||||||
|
workspaceId: string;
|
||||||
|
docId: string;
|
||||||
|
blockId: string;
|
||||||
|
parentSessionId?: string;
|
||||||
|
latestMessageId?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'playground';
|
||||||
|
workspaceId: string;
|
||||||
|
docId?: string;
|
||||||
|
parentSessionId?: string;
|
||||||
|
latestMessageId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AIChatMessage = {
|
||||||
|
id: string;
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
streamObjects?: unknown[];
|
||||||
|
attachments?: string[];
|
||||||
|
userId?: string;
|
||||||
|
userName?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AIChatTab =
|
||||||
|
| {
|
||||||
|
kind: 'draft';
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
scope: AIChatScope;
|
||||||
|
hasMessages: false;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'session';
|
||||||
|
id: string;
|
||||||
|
sessionId: string;
|
||||||
|
title: string;
|
||||||
|
docId: string | null;
|
||||||
|
pinned: boolean;
|
||||||
|
hasMessages: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AIChatStatus =
|
||||||
|
| 'idle'
|
||||||
|
| 'loading'
|
||||||
|
| 'transmitting'
|
||||||
|
| 'success'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
|
export type AIChatHistoryGroups = {
|
||||||
|
currentDoc: CopilotChatHistoryFragment[];
|
||||||
|
recent: CopilotChatHistoryFragment[];
|
||||||
|
loading: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AIChatContextItem =
|
||||||
|
| {
|
||||||
|
kind: 'doc';
|
||||||
|
docId: string;
|
||||||
|
state?: string;
|
||||||
|
createdAt?: number;
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'file';
|
||||||
|
file: File;
|
||||||
|
blobId?: string;
|
||||||
|
fileId?: string;
|
||||||
|
state?: string;
|
||||||
|
createdAt?: number;
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'tag';
|
||||||
|
tagId: string;
|
||||||
|
docIds: string[];
|
||||||
|
state?: string;
|
||||||
|
createdAt?: number;
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'collection';
|
||||||
|
collectionId: string;
|
||||||
|
docIds: string[];
|
||||||
|
state?: string;
|
||||||
|
createdAt?: number;
|
||||||
|
tooltip?: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'blob';
|
||||||
|
blobId: string;
|
||||||
|
state?: string;
|
||||||
|
createdAt?: number;
|
||||||
|
tooltip?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AIChatContextState = {
|
||||||
|
contextId: string | null;
|
||||||
|
items: AIChatContextItem[];
|
||||||
|
loading: boolean;
|
||||||
|
polling: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
embeddingCompleted: boolean;
|
||||||
|
embeddingCount: Record<'finished' | 'processing' | 'failed', number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AIChatComposerState = {
|
||||||
|
text: string;
|
||||||
|
attachments: (string | Blob | File)[];
|
||||||
|
context: AIChatContextState;
|
||||||
|
reasoning: boolean;
|
||||||
|
toolsConfig?: AIToolsConfig;
|
||||||
|
modelId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AIChatNavigationRequest = {
|
||||||
|
workspaceId: string;
|
||||||
|
docId: string;
|
||||||
|
sessionId: string;
|
||||||
|
resetTabs: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AIChatSnapshot = {
|
||||||
|
scope: AIChatScope;
|
||||||
|
readiness: 'initializing' | 'ready' | 'unavailable';
|
||||||
|
activeSessionId: string | null;
|
||||||
|
activeTabId: string | null;
|
||||||
|
tabs: AIChatTab[];
|
||||||
|
sessions: CopilotChatHistoryFragment[];
|
||||||
|
history: AIChatHistoryGroups;
|
||||||
|
messages: AIChatMessage[];
|
||||||
|
status: AIChatStatus;
|
||||||
|
error: Error | null;
|
||||||
|
composer: AIChatComposerState;
|
||||||
|
navigationRequest: AIChatNavigationRequest | null;
|
||||||
|
uiPolicy: {
|
||||||
|
showDraftTab: boolean;
|
||||||
|
canCreateNewSession: boolean;
|
||||||
|
canCloseActiveTab: boolean;
|
||||||
|
canPinActiveSession: boolean;
|
||||||
|
canSend: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createInitialComposerState(): AIChatComposerState {
|
||||||
|
return {
|
||||||
|
text: '',
|
||||||
|
attachments: [],
|
||||||
|
context: {
|
||||||
|
contextId: null,
|
||||||
|
items: [],
|
||||||
|
loading: false,
|
||||||
|
polling: false,
|
||||||
|
error: null,
|
||||||
|
embeddingCompleted: false,
|
||||||
|
embeddingCount: {
|
||||||
|
finished: 0,
|
||||||
|
processing: 0,
|
||||||
|
failed: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
reasoning: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionToTab(session: CopilotChatHistoryFragment): AIChatTab {
|
||||||
|
return {
|
||||||
|
kind: 'session',
|
||||||
|
id: session.sessionId,
|
||||||
|
sessionId: session.sessionId,
|
||||||
|
title: session.title || 'New chat',
|
||||||
|
docId: session.docId ?? null,
|
||||||
|
pinned: !!session.pinned,
|
||||||
|
hasMessages: !!session.messages?.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDraftTab(scope: AIChatScope): AIChatTab {
|
||||||
|
return {
|
||||||
|
kind: 'draft',
|
||||||
|
id: `draft:${scope.kind}:${'docId' in scope ? (scope.docId ?? '') : ''}`,
|
||||||
|
title: 'New chat',
|
||||||
|
scope,
|
||||||
|
hasMessages: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { useAIChatElement } from './use-element';
|
||||||
|
|
||||||
|
class TestAIChatElement extends HTMLElement {
|
||||||
|
accessor value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customElements.get('test-ai-chat-element')) {
|
||||||
|
customElements.define('test-ai-chat-element', TestAIChatElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createContainerRef() {
|
||||||
|
const container = document.createElement('div');
|
||||||
|
document.body.append(container);
|
||||||
|
return { current: container };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createElement() {
|
||||||
|
return document.createElement('test-ai-chat-element') as TestAIChatElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useAIChatElement', () => {
|
||||||
|
test('creates one element and keeps its properties in sync', async () => {
|
||||||
|
const containerRef = createContainerRef();
|
||||||
|
|
||||||
|
const { rerender, result } = renderHook(
|
||||||
|
({ value }) =>
|
||||||
|
useAIChatElement({
|
||||||
|
containerRef,
|
||||||
|
selector: 'test-ai-chat-element',
|
||||||
|
enabled: true,
|
||||||
|
createElement,
|
||||||
|
configureElement: element => {
|
||||||
|
element.value = value;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ initialProps: { value: 'first' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
containerRef.current.querySelectorAll('test-ai-chat-element')
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(result.current?.value).toBe('first');
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ value: 'next' });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
containerRef.current.querySelectorAll('test-ai-chat-element')
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(result.current?.value).toBe('next');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reuses an existing element and removes duplicates', async () => {
|
||||||
|
const containerRef = createContainerRef();
|
||||||
|
const first = createElement();
|
||||||
|
const duplicate = createElement();
|
||||||
|
containerRef.current.append(first, duplicate);
|
||||||
|
const createElementSpy = vi.fn(createElement);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAIChatElement({
|
||||||
|
containerRef,
|
||||||
|
selector: 'test-ai-chat-element',
|
||||||
|
enabled: true,
|
||||||
|
createElement: createElementSpy,
|
||||||
|
configureElement: element => {
|
||||||
|
element.value = 'reused';
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
containerRef.current.querySelectorAll('test-ai-chat-element')
|
||||||
|
).toHaveLength(1);
|
||||||
|
expect(result.current).toBe(first);
|
||||||
|
expect(first.value).toBe('reused');
|
||||||
|
});
|
||||||
|
expect(createElementSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { RefObject } from 'react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export type UseAIChatElementOptions<T extends HTMLElement> = {
|
||||||
|
containerRef: RefObject<HTMLElement | null>;
|
||||||
|
selector: string;
|
||||||
|
enabled: boolean;
|
||||||
|
createElement: () => T;
|
||||||
|
configureElement: (element: T) => void;
|
||||||
|
onElementReady?: (element: T) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAIChatElement<T extends HTMLElement>({
|
||||||
|
containerRef,
|
||||||
|
selector,
|
||||||
|
enabled,
|
||||||
|
createElement,
|
||||||
|
configureElement,
|
||||||
|
onElementReady,
|
||||||
|
}: UseAIChatElementOptions<T>) {
|
||||||
|
const [element, setElement] = useState<T | null>(null);
|
||||||
|
const readyElementsRef = useRef(new WeakSet<T>());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!enabled || !container) return;
|
||||||
|
|
||||||
|
const existingElements = Array.from(
|
||||||
|
container.querySelectorAll(selector)
|
||||||
|
) as T[];
|
||||||
|
const nextElement = element ?? existingElements[0] ?? createElement();
|
||||||
|
|
||||||
|
existingElements
|
||||||
|
.filter(existingElement => existingElement !== nextElement)
|
||||||
|
.forEach(existingElement => existingElement.remove());
|
||||||
|
|
||||||
|
configureElement(nextElement);
|
||||||
|
|
||||||
|
if (nextElement.parentElement !== container) {
|
||||||
|
container.append(nextElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!readyElementsRef.current.has(nextElement)) {
|
||||||
|
readyElementsRef.current.add(nextElement);
|
||||||
|
onElementReady?.(nextElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element !== nextElement) {
|
||||||
|
setElement(nextElement);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
configureElement,
|
||||||
|
containerRef,
|
||||||
|
createElement,
|
||||||
|
element,
|
||||||
|
enabled,
|
||||||
|
onElementReady,
|
||||||
|
selector,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect, useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
import type { AIChatRuntime } from './runtime';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes and owns the passed runtime for the current React mount.
|
||||||
|
*/
|
||||||
|
export function useAIChatRuntime(runtime: AIChatRuntime | null) {
|
||||||
|
const snapshot = useSyncExternalStore(
|
||||||
|
runtime?.subscribe ?? (() => () => {}),
|
||||||
|
runtime?.getSnapshot ?? (() => null),
|
||||||
|
runtime?.getSnapshot ?? (() => null)
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!runtime) return;
|
||||||
|
runtime.dispatch({ type: 'initialize' }).catch(console.error);
|
||||||
|
return () => runtime.dispose();
|
||||||
|
}, [runtime]);
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import type { PromptKey } from '../../provider/prompt';
|
||||||
|
import { Endpoint } from './copilot-client';
|
||||||
|
import type { TextToTextOptions } from './message-transport';
|
||||||
|
|
||||||
|
export type AIActionId = keyof BlockSuitePresets.AIActions;
|
||||||
|
export type AIActionOptions = BlockSuitePresets.AITextActionOptions &
|
||||||
|
Record<string, unknown>;
|
||||||
|
|
||||||
|
export type AIActionDefinition = {
|
||||||
|
id: AIActionId;
|
||||||
|
promptName: PromptKey | ((options: AIActionOptions) => PromptKey);
|
||||||
|
responseType: 'text' | 'image';
|
||||||
|
endpoint?: Endpoint;
|
||||||
|
actionId?: string | ((options: AIActionOptions) => string | undefined);
|
||||||
|
actionVersion?: string | ((options: AIActionOptions) => string | undefined);
|
||||||
|
timeout?: number;
|
||||||
|
buildContent?: (options: AIActionOptions) => string | undefined;
|
||||||
|
buildParams?: (options: AIActionOptions) => TextToTextOptions['params'];
|
||||||
|
validate?: (options: AIActionOptions) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const filterStyleToPromptName = new Map<string, PromptKey>(
|
||||||
|
Object.entries({
|
||||||
|
'Clay style': 'image.filter.clay',
|
||||||
|
'Pixel style': 'image.filter.pixel',
|
||||||
|
'Sketch style': 'image.filter.sketch',
|
||||||
|
'Anime style': 'image.filter.anime',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const processTypeToPromptName = new Map<string, PromptKey>(
|
||||||
|
Object.entries({
|
||||||
|
Clearer: 'Upscale image',
|
||||||
|
'Remove background': 'Remove background',
|
||||||
|
'Convert to sticker': 'Convert to sticker',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const textAction = (
|
||||||
|
id: AIActionId,
|
||||||
|
promptName: PromptKey
|
||||||
|
): AIActionDefinition => ({
|
||||||
|
id,
|
||||||
|
promptName,
|
||||||
|
responseType: 'text',
|
||||||
|
buildContent: options => options.input,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const actionDefinitions = {
|
||||||
|
chat: {
|
||||||
|
id: 'chat',
|
||||||
|
promptName: 'Chat With AFFiNE AI',
|
||||||
|
responseType: 'text',
|
||||||
|
timeout: 5 * 60 * 1000,
|
||||||
|
endpoint: Endpoint.StreamObject,
|
||||||
|
buildContent: options => options.input,
|
||||||
|
buildParams: options => {
|
||||||
|
const contexts = options.contexts as
|
||||||
|
| {
|
||||||
|
docs?: unknown;
|
||||||
|
files?: unknown;
|
||||||
|
selectedSnapshot?: unknown;
|
||||||
|
selectedMarkdown?: unknown;
|
||||||
|
html?: unknown;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
|
return {
|
||||||
|
docs: contexts?.docs,
|
||||||
|
files: contexts?.files,
|
||||||
|
selectedSnapshot: contexts?.selectedSnapshot,
|
||||||
|
selectedMarkdown: contexts?.selectedMarkdown,
|
||||||
|
html: contexts?.html,
|
||||||
|
...(options.docId ? { currentDocId: options.docId } : {}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
summary: textAction('summary', 'Summary'),
|
||||||
|
translate: {
|
||||||
|
...textAction('translate', 'Translate to'),
|
||||||
|
buildParams: options => ({ language: options.lang }),
|
||||||
|
},
|
||||||
|
changeTone: {
|
||||||
|
...textAction('changeTone', 'Change tone to'),
|
||||||
|
buildParams: options => ({
|
||||||
|
tone: typeof options.tone === 'string' ? options.tone.toLowerCase() : '',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
improveWriting: textAction('improveWriting', 'Improve writing for it'),
|
||||||
|
improveGrammar: textAction('improveGrammar', 'Improve grammar for it'),
|
||||||
|
fixSpelling: textAction('fixSpelling', 'Fix spelling for it'),
|
||||||
|
createHeadings: textAction('createHeadings', 'Create headings'),
|
||||||
|
makeLonger: textAction('makeLonger', 'Make it longer'),
|
||||||
|
makeShorter: textAction('makeShorter', 'Make it shorter'),
|
||||||
|
checkCodeErrors: textAction('checkCodeErrors', 'Check code error'),
|
||||||
|
explainCode: textAction('explainCode', 'Explain this code'),
|
||||||
|
writeArticle: textAction('writeArticle', 'Write an article about this'),
|
||||||
|
writeTwitterPost: textAction(
|
||||||
|
'writeTwitterPost',
|
||||||
|
'Write a twitter about this'
|
||||||
|
),
|
||||||
|
writePoem: textAction('writePoem', 'Write a poem about this'),
|
||||||
|
writeOutline: textAction('writeOutline', 'Write outline'),
|
||||||
|
writeBlogPost: textAction('writeBlogPost', 'Write a blog post about this'),
|
||||||
|
brainstorm: textAction('brainstorm', 'Brainstorm ideas about this'),
|
||||||
|
findActions: textAction('findActions', 'Find action items from it'),
|
||||||
|
brainstormMindmap: {
|
||||||
|
id: 'brainstormMindmap',
|
||||||
|
promptName: 'mindmap.generate',
|
||||||
|
responseType: 'text',
|
||||||
|
timeout: 180000,
|
||||||
|
endpoint: Endpoint.Action,
|
||||||
|
actionId: 'mindmap.generate',
|
||||||
|
actionVersion: 'v1',
|
||||||
|
buildContent: options => options.input,
|
||||||
|
},
|
||||||
|
expandMindmap: {
|
||||||
|
...textAction('expandMindmap', 'Expand mind map'),
|
||||||
|
validate: options => {
|
||||||
|
if (!options.input) {
|
||||||
|
throw new Error('expandMindmap action requires input');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buildParams: options => ({
|
||||||
|
mindmap: options.mindmap,
|
||||||
|
node: options.input,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
explain: textAction('explain', 'Explain this'),
|
||||||
|
explainImage: textAction('explainImage', 'Explain this image'),
|
||||||
|
makeItReal: {
|
||||||
|
id: 'makeItReal',
|
||||||
|
promptName: options =>
|
||||||
|
options.attachments && Array.isArray(options.attachments)
|
||||||
|
? 'Make it real'
|
||||||
|
: 'Make it real with text',
|
||||||
|
responseType: 'text',
|
||||||
|
buildContent: options => {
|
||||||
|
const input = options.input ?? '';
|
||||||
|
if (options.attachments && Array.isArray(options.attachments)) {
|
||||||
|
return `Here are the latest wireframes. Could you make a new website based on these wireframes and notes and send back just the html file?
|
||||||
|
Here are our design notes:\n ${input}.`;
|
||||||
|
}
|
||||||
|
return `Here are the latest notes: \n ${input}.
|
||||||
|
Could you make a new website based on these notes and send back just the html file?`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createSlides: {
|
||||||
|
id: 'createSlides',
|
||||||
|
promptName: 'slides.outline',
|
||||||
|
responseType: 'text',
|
||||||
|
timeout: 180000,
|
||||||
|
endpoint: Endpoint.Action,
|
||||||
|
actionId: 'slides.outline',
|
||||||
|
actionVersion: 'v1',
|
||||||
|
buildContent: options => options.input,
|
||||||
|
},
|
||||||
|
createImage: {
|
||||||
|
id: 'createImage',
|
||||||
|
promptName: 'Generate image',
|
||||||
|
responseType: 'image',
|
||||||
|
timeout: 300000,
|
||||||
|
buildContent: options =>
|
||||||
|
!options.input && options.attachments
|
||||||
|
? 'Make the image more detailed.'
|
||||||
|
: options.input,
|
||||||
|
},
|
||||||
|
filterImage: {
|
||||||
|
id: 'filterImage',
|
||||||
|
promptName: options => {
|
||||||
|
const promptName =
|
||||||
|
typeof options.style === 'string'
|
||||||
|
? filterStyleToPromptName.get(options.style)
|
||||||
|
: undefined;
|
||||||
|
if (!promptName) {
|
||||||
|
throw new Error('filterImage requires a promptName');
|
||||||
|
}
|
||||||
|
return promptName;
|
||||||
|
},
|
||||||
|
responseType: 'image',
|
||||||
|
timeout: 180000,
|
||||||
|
endpoint: Endpoint.Action,
|
||||||
|
actionId: options =>
|
||||||
|
typeof options.style === 'string'
|
||||||
|
? filterStyleToPromptName.get(options.style)
|
||||||
|
: undefined,
|
||||||
|
actionVersion: 'v1',
|
||||||
|
buildContent: options => options.input,
|
||||||
|
},
|
||||||
|
processImage: {
|
||||||
|
id: 'processImage',
|
||||||
|
promptName: options => {
|
||||||
|
const promptName =
|
||||||
|
typeof options.type === 'string'
|
||||||
|
? processTypeToPromptName.get(options.type)
|
||||||
|
: undefined;
|
||||||
|
if (!promptName) {
|
||||||
|
throw new Error('processImage requires a promptName');
|
||||||
|
}
|
||||||
|
return promptName;
|
||||||
|
},
|
||||||
|
responseType: 'image',
|
||||||
|
timeout: 180000,
|
||||||
|
buildContent: options => options.input,
|
||||||
|
},
|
||||||
|
generateCaption: textAction('generateCaption', 'Generate a caption'),
|
||||||
|
continueWriting: textAction('continueWriting', 'Continue writing'),
|
||||||
|
} satisfies Partial<Record<AIActionId, AIActionDefinition>>;
|
||||||
|
|
||||||
|
export function getActionDefinition(id: AIActionId): AIActionDefinition {
|
||||||
|
const definition = actionDefinitions[id];
|
||||||
|
if (!definition) {
|
||||||
|
throw new Error(`AI action ${String(id)} is not defined`);
|
||||||
|
}
|
||||||
|
return definition;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveDefinitionValue(
|
||||||
|
value:
|
||||||
|
| string
|
||||||
|
| ((options: AIActionOptions) => string | undefined)
|
||||||
|
| undefined,
|
||||||
|
options: AIActionOptions
|
||||||
|
) {
|
||||||
|
return typeof value === 'function' ? value(options) : value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { apis, type ClientHandler } from '@affine/electron-api';
|
||||||
|
import { UserFriendlyError } from '@affine/error';
|
||||||
|
import {
|
||||||
|
ByokProvider,
|
||||||
|
createWorkspaceByokLocalLeaseMutation,
|
||||||
|
} from '@affine/graphql';
|
||||||
|
|
||||||
|
import type { CopilotClient } from './copilot-client';
|
||||||
|
|
||||||
|
function isElectronBuild() {
|
||||||
|
return typeof BUILD_CONFIG !== 'undefined' && BUILD_CONFIG.isElectron;
|
||||||
|
}
|
||||||
|
|
||||||
|
function byokStorageApi(): ClientHandler['byokStorage'] | undefined {
|
||||||
|
return isElectronBuild() ? apis?.byokStorage : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toGraphqlByokProvider(provider: string): ByokProvider | null {
|
||||||
|
switch (provider) {
|
||||||
|
case ByokProvider.openai:
|
||||||
|
return ByokProvider.openai;
|
||||||
|
case ByokProvider.anthropic:
|
||||||
|
return ByokProvider.anthropic;
|
||||||
|
case ByokProvider.gemini:
|
||||||
|
return ByokProvider.gemini;
|
||||||
|
case ByokProvider.fal:
|
||||||
|
return ByokProvider.fal;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMetadata(error: unknown) {
|
||||||
|
if (!error || typeof error !== 'object') {
|
||||||
|
return { kind: typeof error };
|
||||||
|
}
|
||||||
|
const record = error as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
name: typeof record.name === 'string' ? record.name : undefined,
|
||||||
|
code: typeof record.code === 'string' ? record.code : undefined,
|
||||||
|
status:
|
||||||
|
typeof record.status === 'number' || typeof record.status === 'string'
|
||||||
|
? record.status
|
||||||
|
: undefined,
|
||||||
|
type: typeof record.type === 'string' ? record.type : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWorkspaceByokLocalLease(
|
||||||
|
client: CopilotClient,
|
||||||
|
workspaceId?: string
|
||||||
|
) {
|
||||||
|
const storage = byokStorageApi();
|
||||||
|
if (!workspaceId || !storage) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(await storage.isSupported())) return undefined;
|
||||||
|
const providers = await storage.getWorkspaceLeaseProviders(workspaceId);
|
||||||
|
if (!providers.length) return undefined;
|
||||||
|
const leaseProviders = providers.flatMap(provider => {
|
||||||
|
const gqlProvider = toGraphqlByokProvider(provider.provider);
|
||||||
|
return gqlProvider
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
provider: gqlProvider,
|
||||||
|
name: provider.name,
|
||||||
|
description: provider.description ?? null,
|
||||||
|
apiKey: provider.apiKey,
|
||||||
|
endpoint: provider.endpoint ?? null,
|
||||||
|
sortOrder: provider.sortOrder ?? 0,
|
||||||
|
enabled: provider.enabled ?? true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
});
|
||||||
|
if (!leaseProviders.length) return undefined;
|
||||||
|
|
||||||
|
const result = await client.gql({
|
||||||
|
query: createWorkspaceByokLocalLeaseMutation,
|
||||||
|
variables: {
|
||||||
|
input: {
|
||||||
|
workspaceId,
|
||||||
|
providers: leaseProviders,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return result.createWorkspaceByokLocalLease.leaseId;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
'Failed to create workspace BYOK local lease',
|
||||||
|
errorMetadata(error)
|
||||||
|
);
|
||||||
|
throw UserFriendlyError.fromAny(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ import {
|
|||||||
GeneralNetworkError,
|
GeneralNetworkError,
|
||||||
PaymentRequiredError,
|
PaymentRequiredError,
|
||||||
UnauthorizedError,
|
UnauthorizedError,
|
||||||
} from './error';
|
} from '../../provider/error';
|
||||||
|
|
||||||
export enum Endpoint {
|
export enum Endpoint {
|
||||||
Action = 'action',
|
Action = 'action',
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './action-definitions';
|
||||||
|
export * from './message-transport';
|
||||||
|
export * from './provider';
|
||||||
|
export * from './service';
|
||||||
@@ -1,114 +1,19 @@
|
|||||||
import type { AIToolsConfig } from '@affine/core/modules/ai-button';
|
import type { AIToolsConfig } from '@affine/core/modules/ai-button';
|
||||||
import { apis, type ClientHandler } from '@affine/electron-api';
|
|
||||||
import { UserFriendlyError } from '@affine/error';
|
|
||||||
import {
|
|
||||||
ByokProvider,
|
|
||||||
createWorkspaceByokLocalLeaseMutation,
|
|
||||||
} from '@affine/graphql';
|
|
||||||
import { partition } from 'lodash-es';
|
import { partition } from 'lodash-es';
|
||||||
|
|
||||||
import { AIProvider } from './ai-provider';
|
import { toTextStream } from '../../provider/event-source';
|
||||||
|
import { createWorkspaceByokLocalLease } from './byok-local-lease';
|
||||||
import { type CopilotClient, Endpoint } from './copilot-client';
|
import { type CopilotClient, Endpoint } from './copilot-client';
|
||||||
import { toTextStream } from './event-source';
|
|
||||||
|
|
||||||
const TIMEOUT = 50000;
|
const TIMEOUT = 50000;
|
||||||
|
|
||||||
function isElectronBuild() {
|
|
||||||
return typeof BUILD_CONFIG !== 'undefined' && BUILD_CONFIG.isElectron;
|
|
||||||
}
|
|
||||||
|
|
||||||
function byokStorageApi(): ClientHandler['byokStorage'] | undefined {
|
|
||||||
return isElectronBuild() ? apis?.byokStorage : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toGraphqlByokProvider(provider: string): ByokProvider | null {
|
|
||||||
switch (provider) {
|
|
||||||
case ByokProvider.openai:
|
|
||||||
return ByokProvider.openai;
|
|
||||||
case ByokProvider.anthropic:
|
|
||||||
return ByokProvider.anthropic;
|
|
||||||
case ByokProvider.gemini:
|
|
||||||
return ByokProvider.gemini;
|
|
||||||
case ByokProvider.fal:
|
|
||||||
return ByokProvider.fal;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMetadata(error: unknown) {
|
|
||||||
if (!error || typeof error !== 'object') {
|
|
||||||
return { kind: typeof error };
|
|
||||||
}
|
|
||||||
const record = error as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
name: typeof record.name === 'string' ? record.name : undefined,
|
|
||||||
code: typeof record.code === 'string' ? record.code : undefined,
|
|
||||||
status:
|
|
||||||
typeof record.status === 'number' || typeof record.status === 'string'
|
|
||||||
? record.status
|
|
||||||
: undefined,
|
|
||||||
type: typeof record.type === 'string' ? record.type : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createWorkspaceByokLocalLease(
|
|
||||||
client: CopilotClient,
|
|
||||||
workspaceId?: string
|
|
||||||
) {
|
|
||||||
const storage = byokStorageApi();
|
|
||||||
if (!workspaceId || !storage) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!(await storage.isSupported())) return undefined;
|
|
||||||
const providers = await storage.getWorkspaceLeaseProviders(workspaceId);
|
|
||||||
if (!providers.length) return undefined;
|
|
||||||
const leaseProviders = providers.flatMap(provider => {
|
|
||||||
const gqlProvider = toGraphqlByokProvider(provider.provider);
|
|
||||||
return gqlProvider
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
provider: gqlProvider,
|
|
||||||
name: provider.name,
|
|
||||||
description: provider.description ?? null,
|
|
||||||
apiKey: provider.apiKey,
|
|
||||||
endpoint: provider.endpoint ?? null,
|
|
||||||
sortOrder: provider.sortOrder ?? 0,
|
|
||||||
enabled: provider.enabled ?? true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [];
|
|
||||||
});
|
|
||||||
if (!leaseProviders.length) return undefined;
|
|
||||||
|
|
||||||
const result = await client.gql({
|
|
||||||
query: createWorkspaceByokLocalLeaseMutation,
|
|
||||||
variables: {
|
|
||||||
input: {
|
|
||||||
workspaceId,
|
|
||||||
providers: leaseProviders,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return result.createWorkspaceByokLocalLease.leaseId;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn(
|
|
||||||
'Failed to create workspace BYOK local lease',
|
|
||||||
errorMetadata(error)
|
|
||||||
);
|
|
||||||
throw UserFriendlyError.fromAny(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TextToTextOptions = {
|
export type TextToTextOptions = {
|
||||||
client: CopilotClient;
|
client: CopilotClient;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
attachments?: (string | Blob | File)[];
|
attachments?: (string | Blob | File)[];
|
||||||
params?: Record<string, any>;
|
params?: Record<string, unknown>;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
stream?: boolean;
|
stream?: boolean;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
@@ -138,7 +43,6 @@ async function resizeImage(blob: Blob | File): Promise<Blob | null> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
// keep aspect ratio
|
|
||||||
const scale = Math.min(1024 / img.width, 1024 / img.height);
|
const scale = Math.min(1024 / img.width, 1024 / img.height);
|
||||||
canvas.width = Math.floor(img.width * scale);
|
canvas.width = Math.floor(img.width * scale);
|
||||||
canvas.height = Math.floor(img.height * scale);
|
canvas.height = Math.floor(img.height * scale);
|
||||||
@@ -164,7 +68,7 @@ interface CreateMessageOptions {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
attachments?: (string | Blob | File)[];
|
attachments?: (string | Blob | File)[];
|
||||||
params?: Record<string, any>;
|
params?: Record<string, unknown>;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
@@ -182,7 +86,7 @@ async function createMessage({
|
|||||||
const options: Parameters<CopilotClient['createMessage']>[0] = {
|
const options: Parameters<CopilotClient['createMessage']>[0] = {
|
||||||
sessionId,
|
sessionId,
|
||||||
content,
|
content,
|
||||||
params,
|
params: params as Parameters<CopilotClient['createMessage']>[0]['params'],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hasAttachments) {
|
if (hasAttachments) {
|
||||||
@@ -267,7 +171,6 @@ export function textToText({
|
|||||||
},
|
},
|
||||||
endpoint
|
endpoint
|
||||||
);
|
);
|
||||||
AIProvider.LAST_ACTION_SESSIONID = sessionId;
|
|
||||||
|
|
||||||
let onAbort: (() => void) | undefined;
|
let onAbort: (() => void) | undefined;
|
||||||
try {
|
try {
|
||||||
@@ -336,7 +239,6 @@ export function textToText({
|
|||||||
},
|
},
|
||||||
endpoint
|
endpoint
|
||||||
);
|
);
|
||||||
AIProvider.LAST_ACTION_SESSIONID = sessionId;
|
|
||||||
|
|
||||||
let onAbort: (() => void) | undefined;
|
let onAbort: (() => void) | undefined;
|
||||||
try {
|
try {
|
||||||
@@ -361,8 +263,7 @@ export function textToText({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = messages.join('');
|
return messages.join('');
|
||||||
return result;
|
|
||||||
} finally {
|
} finally {
|
||||||
eventSource.close();
|
eventSource.close();
|
||||||
if (signal && onAbort) {
|
if (signal && onAbort) {
|
||||||
@@ -373,7 +274,6 @@ export function textToText({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only one image is currently being processed
|
|
||||||
export function toImage({
|
export function toImage({
|
||||||
content,
|
content,
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -435,7 +335,6 @@ export function toImage({
|
|||||||
endpoint,
|
endpoint,
|
||||||
byokLeaseId
|
byokLeaseId
|
||||||
);
|
);
|
||||||
AIProvider.LAST_ACTION_SESSIONID = sessionId;
|
|
||||||
|
|
||||||
for await (const event of toTextStream(eventSource, {
|
for await (const event of toTextStream(eventSource, {
|
||||||
timeout,
|
timeout,
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { AIRequestService } from './service';
|
||||||
|
|
||||||
|
let currentRequestService: AIRequestService | null = null;
|
||||||
|
|
||||||
|
export function setAIRequestService(service: AIRequestService | null) {
|
||||||
|
currentRequestService = service;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAIRequestService() {
|
||||||
|
if (!currentRequestService) {
|
||||||
|
throw new Error('AIRequestService is not initialized');
|
||||||
|
}
|
||||||
|
return currentRequestService;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAIRequestService() {
|
||||||
|
return !!currentRequestService;
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
import { UserFriendlyError } from '@affine/error';
|
||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { type CopilotClient, Endpoint } from './copilot-client';
|
||||||
|
import { textToText, toImage } from './message-transport';
|
||||||
|
import { AIRequestService } from './service';
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'EventSource', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
CLOSED: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const electronApis = vi.hoisted(() => ({
|
||||||
|
byokStorage: undefined as
|
||||||
|
| {
|
||||||
|
isSupported: () => Promise<boolean>;
|
||||||
|
getWorkspaceLeaseProviders: (workspaceId: string) => Promise<
|
||||||
|
Array<{
|
||||||
|
provider: string;
|
||||||
|
name: string;
|
||||||
|
apiKey: string;
|
||||||
|
description?: string | null;
|
||||||
|
endpoint?: string | null;
|
||||||
|
sortOrder?: number | null;
|
||||||
|
enabled?: boolean | null;
|
||||||
|
}>
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
| undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const createWorkspaceByokLocalLeaseMutation = vi.hoisted(() =>
|
||||||
|
Symbol('createWorkspaceByokLocalLeaseMutation')
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock('@affine/electron-api', () => ({
|
||||||
|
apis: electronApis,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@affine/graphql', () => ({
|
||||||
|
ByokProvider: {
|
||||||
|
openai: 'openai',
|
||||||
|
anthropic: 'anthropic',
|
||||||
|
gemini: 'gemini',
|
||||||
|
fal: 'fal',
|
||||||
|
},
|
||||||
|
ContextCategories: {
|
||||||
|
Tag: 'tag',
|
||||||
|
Collection: 'collection',
|
||||||
|
},
|
||||||
|
createWorkspaceByokLocalLeaseMutation,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createClosedEventSource(): EventSource {
|
||||||
|
return {
|
||||||
|
readyState: EventSource.CLOSED,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
close: vi.fn(),
|
||||||
|
} as unknown as EventSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClient(
|
||||||
|
overrides: Partial<
|
||||||
|
Pick<
|
||||||
|
CopilotClient,
|
||||||
|
| 'gql'
|
||||||
|
| 'createSession'
|
||||||
|
| 'createMessage'
|
||||||
|
| 'getSessions'
|
||||||
|
| 'getHistories'
|
||||||
|
| 'chatTextStream'
|
||||||
|
| 'imagesStream'
|
||||||
|
>
|
||||||
|
> = {}
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
gql: vi.fn().mockResolvedValue({
|
||||||
|
createWorkspaceByokLocalLease: { leaseId: 'lease-1' },
|
||||||
|
}),
|
||||||
|
createSession: vi.fn().mockImplementation(async options => {
|
||||||
|
return `session:${options.promptName}`;
|
||||||
|
}),
|
||||||
|
createMessage: vi.fn().mockResolvedValue('message-1'),
|
||||||
|
getSessions: vi.fn().mockResolvedValue([]),
|
||||||
|
getHistories: vi.fn().mockResolvedValue([]),
|
||||||
|
chatTextStream: vi.fn(() => createClosedEventSource()),
|
||||||
|
imagesStream: vi.fn(() => createClosedEventSource()),
|
||||||
|
...overrides,
|
||||||
|
} as unknown as CopilotClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drain(stream: AsyncIterable<unknown>) {
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
void chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drainActionResult(
|
||||||
|
stream: string | AsyncIterable<unknown> | undefined
|
||||||
|
) {
|
||||||
|
expect(stream).toBeDefined();
|
||||||
|
expect(typeof stream).not.toBe('string');
|
||||||
|
await drain(stream as AsyncIterable<unknown>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runtime request transport BYOK local lease handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('BUILD_CONFIG', { isElectron: true });
|
||||||
|
electronApis.byokStorage = {
|
||||||
|
isSupported: vi.fn().mockResolvedValue(true),
|
||||||
|
getWorkspaceLeaseProviders: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
provider: 'openai',
|
||||||
|
name: 'OpenAI',
|
||||||
|
apiKey: 'sk-local',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fails closed when local BYOK providers exist but lease creation fails', async () => {
|
||||||
|
const client = createClient({
|
||||||
|
gql: vi.fn().mockRejectedValue(new Error('mutation failed')),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = textToText({
|
||||||
|
client,
|
||||||
|
sessionId: 'session-1',
|
||||||
|
workspaceId: 'workspace-1',
|
||||||
|
content: 'hello',
|
||||||
|
}) as Promise<string>;
|
||||||
|
|
||||||
|
await expect(result).rejects.toThrow('mutation failed');
|
||||||
|
await expect(result).rejects.toBeInstanceOf(UserFriendlyError);
|
||||||
|
expect(client.chatTextStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not create stream local BYOK lease after cancellation', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const client = createClient({
|
||||||
|
createMessage: vi.fn().mockImplementation(async () => {
|
||||||
|
controller.abort();
|
||||||
|
return 'message-1';
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await drain(
|
||||||
|
textToText({
|
||||||
|
client,
|
||||||
|
sessionId: 'session-1',
|
||||||
|
workspaceId: 'workspace-1',
|
||||||
|
content: 'hello',
|
||||||
|
stream: true,
|
||||||
|
signal: controller.signal,
|
||||||
|
}) as AsyncIterable<string>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(client.gql).not.toHaveBeenCalled();
|
||||||
|
expect(client.chatTextStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not create image stream when cancelled while creating local BYOK lease', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const client = createClient({
|
||||||
|
gql: vi.fn().mockImplementation(async () => {
|
||||||
|
controller.abort();
|
||||||
|
return { createWorkspaceByokLocalLease: { leaseId: 'lease-1' } };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await drain(
|
||||||
|
toImage({
|
||||||
|
client,
|
||||||
|
sessionId: 'session-1',
|
||||||
|
workspaceId: 'workspace-1',
|
||||||
|
content: 'image',
|
||||||
|
endpoint: Endpoint.Images,
|
||||||
|
signal: controller.signal,
|
||||||
|
}) as AsyncIterable<string>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(client.gql).toHaveBeenCalled();
|
||||||
|
expect(client.imagesStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AIRequestService action definitions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('BUILD_CONFIG', { isElectron: false });
|
||||||
|
electronApis.byokStorage = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('routes action-stream requests through action endpoint', async () => {
|
||||||
|
const client = createClient();
|
||||||
|
const service = new AIRequestService(client);
|
||||||
|
|
||||||
|
await drainActionResult(
|
||||||
|
(await service.executeAction('brainstormMindmap', {
|
||||||
|
workspaceId: 'workspace-1',
|
||||||
|
input: 'make a map',
|
||||||
|
stream: true,
|
||||||
|
})) as AsyncIterable<unknown>
|
||||||
|
);
|
||||||
|
await drainActionResult(
|
||||||
|
(await service.executeAction('createSlides', {
|
||||||
|
workspaceId: 'workspace-1',
|
||||||
|
input: 'make slides',
|
||||||
|
stream: true,
|
||||||
|
})) as AsyncIterable<unknown>
|
||||||
|
);
|
||||||
|
await drainActionResult(
|
||||||
|
(await service.executeAction('filterImage', {
|
||||||
|
workspaceId: 'workspace-1',
|
||||||
|
input: 'convert',
|
||||||
|
attachments: ['blob-1'],
|
||||||
|
style: 'Sketch style',
|
||||||
|
})) as AsyncIterable<unknown>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(client.createSession).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ promptName: 'mindmap.generate' })
|
||||||
|
);
|
||||||
|
expect(client.createSession).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ promptName: 'slides.outline' })
|
||||||
|
);
|
||||||
|
expect(client.createSession).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ promptName: 'image.filter.sketch' })
|
||||||
|
);
|
||||||
|
expect(client.chatTextStream).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ actionId: 'mindmap.generate' }),
|
||||||
|
Endpoint.Action
|
||||||
|
);
|
||||||
|
expect(client.chatTextStream).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ actionId: 'slides.outline' }),
|
||||||
|
Endpoint.Action
|
||||||
|
);
|
||||||
|
expect(client.chatTextStream).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ actionId: 'image.filter.sketch' }),
|
||||||
|
Endpoint.Action
|
||||||
|
);
|
||||||
|
expect(client.imagesStream).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reuses the last action session for retry', async () => {
|
||||||
|
const client = createClient();
|
||||||
|
const service = new AIRequestService(client);
|
||||||
|
|
||||||
|
await drainActionResult(
|
||||||
|
(await service.executeAction('summary', {
|
||||||
|
workspaceId: 'workspace-1',
|
||||||
|
input: 'summarize',
|
||||||
|
stream: true,
|
||||||
|
})) as AsyncIterable<unknown>
|
||||||
|
);
|
||||||
|
await drainActionResult(
|
||||||
|
(await service.executeAction('summary', {
|
||||||
|
workspaceId: 'workspace-1',
|
||||||
|
input: 'summarize again',
|
||||||
|
retry: true,
|
||||||
|
stream: true,
|
||||||
|
})) as AsyncIterable<unknown>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(client.createSession).toHaveBeenCalledTimes(1);
|
||||||
|
expect(client.createMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(client.chatTextStream).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
sessionId: 'session:Summary',
|
||||||
|
retry: true,
|
||||||
|
}),
|
||||||
|
Endpoint.StreamObject
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports action result against the matching host action', async () => {
|
||||||
|
const client = createClient();
|
||||||
|
const service = new AIRequestService(client);
|
||||||
|
const events: string[] = [];
|
||||||
|
const hostOne = {} as NonNullable<
|
||||||
|
BlockSuitePresets.AITextActionOptions['host']
|
||||||
|
>;
|
||||||
|
const hostTwo = {} as NonNullable<
|
||||||
|
BlockSuitePresets.AITextActionOptions['host']
|
||||||
|
>;
|
||||||
|
const subscription = service.actionEvents$.subscribe(event => {
|
||||||
|
events.push(
|
||||||
|
`${event.options.host === hostOne ? 'one' : 'two'}:${event.event}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await drainActionResult(
|
||||||
|
(await service.executeAction('summary', {
|
||||||
|
workspaceId: 'workspace-1',
|
||||||
|
input: 'first',
|
||||||
|
host: hostOne,
|
||||||
|
stream: true,
|
||||||
|
})) as AsyncIterable<unknown>
|
||||||
|
);
|
||||||
|
await drainActionResult(
|
||||||
|
(await service.executeAction('translate', {
|
||||||
|
workspaceId: 'workspace-1',
|
||||||
|
input: 'second',
|
||||||
|
lang: 'French',
|
||||||
|
host: hostTwo,
|
||||||
|
stream: true,
|
||||||
|
})) as AsyncIterable<unknown>
|
||||||
|
);
|
||||||
|
|
||||||
|
service.reportLastAction('result:insert', hostOne);
|
||||||
|
subscription.unsubscribe();
|
||||||
|
|
||||||
|
expect(events).toContain('one:result:insert');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads sessions through history query with messages', async () => {
|
||||||
|
const history = {
|
||||||
|
sessionId: 'session-1',
|
||||||
|
workspaceId: 'workspace-1',
|
||||||
|
docId: 'doc-1',
|
||||||
|
messages: [{ id: 'message-1', role: 'user', content: 'hello' }],
|
||||||
|
};
|
||||||
|
const client = createClient({
|
||||||
|
getHistories: vi.fn().mockResolvedValue([history]),
|
||||||
|
});
|
||||||
|
const service = new AIRequestService(client);
|
||||||
|
|
||||||
|
const session = await service.getSession('workspace-1', 'session-1');
|
||||||
|
|
||||||
|
expect(client.getHistories).toHaveBeenCalledWith(
|
||||||
|
'workspace-1',
|
||||||
|
{},
|
||||||
|
undefined,
|
||||||
|
expect.objectContaining({
|
||||||
|
sessionId: 'session-1',
|
||||||
|
withMessages: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(session?.messages).toEqual(history.messages);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads chat history lists with messages for title derivation', async () => {
|
||||||
|
const client = createClient();
|
||||||
|
const service = new AIRequestService(client);
|
||||||
|
|
||||||
|
await service.getSessions('workspace-1', 'doc-1', {
|
||||||
|
action: false,
|
||||||
|
fork: false,
|
||||||
|
});
|
||||||
|
await service.getRecentSessions('workspace-1', 10, 20);
|
||||||
|
|
||||||
|
expect(client.getSessions).toHaveBeenCalledWith(
|
||||||
|
'workspace-1',
|
||||||
|
{},
|
||||||
|
'doc-1',
|
||||||
|
expect.objectContaining({
|
||||||
|
action: false,
|
||||||
|
fork: false,
|
||||||
|
withMessages: true,
|
||||||
|
}),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
expect(client.getHistories).toHaveBeenCalledWith(
|
||||||
|
'workspace-1',
|
||||||
|
{ first: 10, offset: 20 },
|
||||||
|
undefined,
|
||||||
|
expect.objectContaining({
|
||||||
|
action: false,
|
||||||
|
fork: false,
|
||||||
|
sessionOrder: 'desc',
|
||||||
|
withMessages: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import {
|
||||||
|
ContextCategories,
|
||||||
|
type CopilotChatHistoryFragment,
|
||||||
|
type getCopilotHistoriesQuery,
|
||||||
|
type GraphQLQuery,
|
||||||
|
type QueryChatSessionsInput,
|
||||||
|
type QueryOptions,
|
||||||
|
type QueryResponse,
|
||||||
|
type RequestOptions,
|
||||||
|
type UpdateChatSessionInput,
|
||||||
|
} from '@affine/graphql';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
|
import type { ActionEventType } from '../../provider';
|
||||||
|
import {
|
||||||
|
type AIActionId,
|
||||||
|
type AIActionOptions,
|
||||||
|
getActionDefinition,
|
||||||
|
resolveDefinitionValue,
|
||||||
|
} from './action-definitions';
|
||||||
|
import {
|
||||||
|
CopilotClient,
|
||||||
|
type CopilotClient as CopilotClientType,
|
||||||
|
} from './copilot-client';
|
||||||
|
import { textToText, toImage } from './message-transport';
|
||||||
|
|
||||||
|
type CreateSessionOptions = BlockSuitePresets.AICreateSessionOptions;
|
||||||
|
|
||||||
|
export type AIRequestActionEvent = {
|
||||||
|
action: AIActionId;
|
||||||
|
options: AIActionOptions;
|
||||||
|
event: ActionEventType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AIRequestService {
|
||||||
|
private lastActionSessionId = '';
|
||||||
|
private readonly actionHistory: {
|
||||||
|
action: AIActionId;
|
||||||
|
options: AIActionOptions;
|
||||||
|
}[] = [];
|
||||||
|
readonly actionEvents$ = new Subject<AIRequestActionEvent>();
|
||||||
|
|
||||||
|
constructor(readonly client: CopilotClientType) {}
|
||||||
|
|
||||||
|
isReady() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession(options: CreateSessionOptions) {
|
||||||
|
if (options.sessionId) return options.sessionId;
|
||||||
|
if (options.retry) return this.lastActionSessionId;
|
||||||
|
return this.client.createSession({
|
||||||
|
workspaceId: options.workspaceId,
|
||||||
|
docId: options.docId,
|
||||||
|
promptName: options.promptName,
|
||||||
|
pinned: options.pinned,
|
||||||
|
reuseLatestChat: options.reuseLatestChat,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSessionWithHistory(options: CreateSessionOptions) {
|
||||||
|
if (!options.sessionId && !options.retry) {
|
||||||
|
return this.client.createSessionWithHistory({
|
||||||
|
workspaceId: options.workspaceId,
|
||||||
|
docId: options.docId,
|
||||||
|
promptName: options.promptName,
|
||||||
|
pinned: options.pinned,
|
||||||
|
reuseLatestChat: options.reuseLatestChat,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = await this.createSession(options);
|
||||||
|
if (!sessionId) return undefined;
|
||||||
|
return this.getSession(options.workspaceId, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSession(workspaceId: string, sessionId: string) {
|
||||||
|
return this.client
|
||||||
|
.getHistories(workspaceId, {}, undefined, {
|
||||||
|
sessionId,
|
||||||
|
withMessages: true,
|
||||||
|
} as RequestOptions<
|
||||||
|
typeof getCopilotHistoriesQuery
|
||||||
|
>['variables']['options'])
|
||||||
|
.then(
|
||||||
|
histories =>
|
||||||
|
(histories?.[0] ?? null) as CopilotChatHistoryFragment | null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getSessions(
|
||||||
|
workspaceId: string,
|
||||||
|
docId?: string,
|
||||||
|
options?: QueryChatSessionsInput,
|
||||||
|
signal?: AbortSignal
|
||||||
|
) {
|
||||||
|
return this.client.getSessions(
|
||||||
|
workspaceId,
|
||||||
|
{},
|
||||||
|
docId,
|
||||||
|
{ ...options, withMessages: true },
|
||||||
|
signal
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRecentSessions(workspaceId: string, limit?: number, offset?: number) {
|
||||||
|
return this.client.getHistories(
|
||||||
|
workspaceId,
|
||||||
|
{ first: limit, offset },
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
action: false,
|
||||||
|
fork: false,
|
||||||
|
sessionOrder: 'desc',
|
||||||
|
withMessages: true,
|
||||||
|
} as RequestOptions<
|
||||||
|
typeof getCopilotHistoriesQuery
|
||||||
|
>['variables']['options']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSession(options: UpdateChatSessionInput) {
|
||||||
|
return this.client.updateSession(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupSessions(input: {
|
||||||
|
workspaceId: string;
|
||||||
|
docId: string | undefined;
|
||||||
|
sessionIds: string[];
|
||||||
|
}) {
|
||||||
|
return this.client.cleanupSessions(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
histories = {
|
||||||
|
actions: async (
|
||||||
|
workspaceId: string,
|
||||||
|
docId: string
|
||||||
|
): Promise<BlockSuitePresets.AIHistory[]> => {
|
||||||
|
return ((await this.client.getHistories(workspaceId, {}, docId, {
|
||||||
|
action: true,
|
||||||
|
withPrompt: true,
|
||||||
|
withMessages: true,
|
||||||
|
} as RequestOptions<
|
||||||
|
typeof getCopilotHistoriesQuery
|
||||||
|
>['variables']['options'])) ?? []) as BlockSuitePresets.AIHistory[];
|
||||||
|
},
|
||||||
|
chats: async (
|
||||||
|
workspaceId: string,
|
||||||
|
sessionId: string,
|
||||||
|
docId?: string
|
||||||
|
): Promise<BlockSuitePresets.AIHistory[]> => {
|
||||||
|
return ((await this.client.getHistories(workspaceId, {}, docId, {
|
||||||
|
sessionId,
|
||||||
|
withMessages: true,
|
||||||
|
} as RequestOptions<
|
||||||
|
typeof getCopilotHistoriesQuery
|
||||||
|
>['variables']['options'])) ?? []) as BlockSuitePresets.AIHistory[];
|
||||||
|
},
|
||||||
|
cleanup: async (
|
||||||
|
workspaceId: string,
|
||||||
|
docId: string | undefined,
|
||||||
|
sessionIds: string[]
|
||||||
|
) => {
|
||||||
|
await this.cleanupSessions({ workspaceId, docId, sessionIds });
|
||||||
|
},
|
||||||
|
ids: async (
|
||||||
|
workspaceId: string,
|
||||||
|
docId?: string,
|
||||||
|
options?: RequestOptions<
|
||||||
|
typeof getCopilotHistoriesQuery
|
||||||
|
>['variables']['options']
|
||||||
|
): Promise<BlockSuitePresets.AIHistoryIds[]> => {
|
||||||
|
return (await this.client.getHistoryIds(
|
||||||
|
workspaceId,
|
||||||
|
{},
|
||||||
|
docId,
|
||||||
|
options
|
||||||
|
)) as unknown as BlockSuitePresets.AIHistoryIds[];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
context = {
|
||||||
|
createContext: (workspaceId: string, sessionId: string) =>
|
||||||
|
this.client.createContext(workspaceId, sessionId),
|
||||||
|
getContextId: (workspaceId: string, sessionId: string) =>
|
||||||
|
this.client.getContextId(workspaceId, sessionId),
|
||||||
|
addContextDoc: (options: { contextId: string; docId: string }) =>
|
||||||
|
this.client.addContextDoc(options),
|
||||||
|
removeContextDoc: (options: { contextId: string; docId: string }) =>
|
||||||
|
this.client.removeContextDoc(options),
|
||||||
|
addContextFile: (
|
||||||
|
file: File,
|
||||||
|
options: Parameters<CopilotClient['addContextFile']>[1]
|
||||||
|
) => this.client.addContextFile(file, options),
|
||||||
|
removeContextFile: (options: { contextId: string; fileId: string }) =>
|
||||||
|
this.client.removeContextFile(options),
|
||||||
|
addContextTag: (options: {
|
||||||
|
contextId: string;
|
||||||
|
tagId: string;
|
||||||
|
docIds: string[];
|
||||||
|
}) =>
|
||||||
|
this.client.addContextCategory({
|
||||||
|
contextId: options.contextId,
|
||||||
|
type: ContextCategories.Tag,
|
||||||
|
categoryId: options.tagId,
|
||||||
|
docs: options.docIds,
|
||||||
|
}),
|
||||||
|
removeContextTag: (options: { contextId: string; tagId: string }) =>
|
||||||
|
this.client.removeContextCategory({
|
||||||
|
contextId: options.contextId,
|
||||||
|
type: ContextCategories.Tag,
|
||||||
|
categoryId: options.tagId,
|
||||||
|
}),
|
||||||
|
addContextCollection: (options: {
|
||||||
|
contextId: string;
|
||||||
|
collectionId: string;
|
||||||
|
docIds: string[];
|
||||||
|
}) =>
|
||||||
|
this.client.addContextCategory({
|
||||||
|
contextId: options.contextId,
|
||||||
|
type: ContextCategories.Collection,
|
||||||
|
categoryId: options.collectionId,
|
||||||
|
docs: options.docIds,
|
||||||
|
}),
|
||||||
|
removeContextCollection: (options: {
|
||||||
|
contextId: string;
|
||||||
|
collectionId: string;
|
||||||
|
}) =>
|
||||||
|
this.client.removeContextCategory({
|
||||||
|
contextId: options.contextId,
|
||||||
|
type: ContextCategories.Collection,
|
||||||
|
categoryId: options.collectionId,
|
||||||
|
}),
|
||||||
|
getContextDocsAndFiles: (
|
||||||
|
workspaceId: string,
|
||||||
|
sessionId: string,
|
||||||
|
contextId: string
|
||||||
|
) => this.client.getContextDocsAndFiles(workspaceId, sessionId, contextId),
|
||||||
|
matchContext: (
|
||||||
|
content: string,
|
||||||
|
contextId?: string,
|
||||||
|
workspaceId?: string,
|
||||||
|
limit?: number,
|
||||||
|
scopedThreshold?: number,
|
||||||
|
threshold?: number
|
||||||
|
) =>
|
||||||
|
this.client.matchContext(
|
||||||
|
content,
|
||||||
|
contextId,
|
||||||
|
workspaceId,
|
||||||
|
limit,
|
||||||
|
scopedThreshold,
|
||||||
|
threshold
|
||||||
|
),
|
||||||
|
addContextBlob: (options: { blobId: string; contextId: string }) =>
|
||||||
|
this.client.addContextBlob({
|
||||||
|
contextId: options.contextId,
|
||||||
|
blobId: options.blobId,
|
||||||
|
}),
|
||||||
|
removeContextBlob: (options: { blobId: string; contextId: string }) =>
|
||||||
|
this.client.removeContextBlob({
|
||||||
|
contextId: options.contextId,
|
||||||
|
blobId: options.blobId,
|
||||||
|
}),
|
||||||
|
pollContextDocsAndFiles: async (
|
||||||
|
workspaceId: string,
|
||||||
|
sessionId: string,
|
||||||
|
contextId: string,
|
||||||
|
onPoll: (
|
||||||
|
result: BlockSuitePresets.AIDocsAndFilesContext | undefined
|
||||||
|
) => void,
|
||||||
|
abortSignal: AbortSignal
|
||||||
|
) => {
|
||||||
|
let attempts = 0;
|
||||||
|
const minInterval = 1000;
|
||||||
|
const maxInterval = 30 * 1000;
|
||||||
|
|
||||||
|
while (!abortSignal.aborted) {
|
||||||
|
const result = await this.client.getContextDocsAndFiles(
|
||||||
|
workspaceId,
|
||||||
|
sessionId,
|
||||||
|
contextId
|
||||||
|
);
|
||||||
|
onPoll(result);
|
||||||
|
const interval = Math.min(
|
||||||
|
minInterval * Math.pow(1.5, attempts),
|
||||||
|
maxInterval
|
||||||
|
);
|
||||||
|
attempts++;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, interval));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pollEmbeddingStatus: async (
|
||||||
|
workspaceId: string,
|
||||||
|
onPoll: (
|
||||||
|
result: Awaited<ReturnType<CopilotClientType['getEmbeddingStatus']>>
|
||||||
|
) => void,
|
||||||
|
abortSignal: AbortSignal
|
||||||
|
) => {
|
||||||
|
const interval = 10 * 1000;
|
||||||
|
while (!abortSignal.aborted) {
|
||||||
|
onPoll(await this.client.getEmbeddingStatus(workspaceId));
|
||||||
|
await new Promise(resolve => setTimeout(resolve, interval));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
forkChat(options: BlockSuitePresets.AIForkChatSessionOptions) {
|
||||||
|
return this.client.forkSession(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
reportLastAction(event: ActionEventType, host?: unknown) {
|
||||||
|
const lastAction = host
|
||||||
|
? this.actionHistory.findLast(item => item.options.host === host)
|
||||||
|
: this.actionHistory.at(-1);
|
||||||
|
if (!lastAction) return;
|
||||||
|
this.actionEvents$.next({
|
||||||
|
action: lastAction.action,
|
||||||
|
options: lastAction.options,
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private wrapTextStream(
|
||||||
|
stream: AsyncIterable<string>,
|
||||||
|
id: AIActionId,
|
||||||
|
options: AIActionOptions
|
||||||
|
): AsyncIterable<string> {
|
||||||
|
const actionEvents$ = this.actionEvents$;
|
||||||
|
return {
|
||||||
|
async *[Symbol.asyncIterator]() {
|
||||||
|
try {
|
||||||
|
yield* stream;
|
||||||
|
actionEvents$.next({ action: id, options, event: 'finished' });
|
||||||
|
} catch (error) {
|
||||||
|
actionEvents$.next({ action: id, options, event: 'error' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeAction(id: AIActionId, options: AIActionOptions) {
|
||||||
|
this.actionHistory.push({ action: id, options });
|
||||||
|
if (this.actionHistory.length > 10) {
|
||||||
|
this.actionHistory.shift();
|
||||||
|
}
|
||||||
|
this.actionEvents$.next({ action: id, options, event: 'started' });
|
||||||
|
const definition = getActionDefinition(id);
|
||||||
|
definition.validate?.(options);
|
||||||
|
const promptName = resolveDefinitionValue(
|
||||||
|
definition.promptName,
|
||||||
|
options
|
||||||
|
) as CreateSessionOptions['promptName'];
|
||||||
|
const sessionId = await this.createSession({
|
||||||
|
promptName,
|
||||||
|
...options,
|
||||||
|
} as CreateSessionOptions);
|
||||||
|
this.lastActionSessionId = sessionId;
|
||||||
|
|
||||||
|
const actionId = resolveDefinitionValue(definition.actionId, options);
|
||||||
|
const actionVersion = resolveDefinitionValue(
|
||||||
|
definition.actionVersion,
|
||||||
|
options
|
||||||
|
);
|
||||||
|
const transportOptions = {
|
||||||
|
...options,
|
||||||
|
client: this.client,
|
||||||
|
sessionId,
|
||||||
|
content: definition.buildContent?.(options) ?? options.input,
|
||||||
|
params: definition.buildParams?.(options),
|
||||||
|
timeout: definition.timeout,
|
||||||
|
endpoint: definition.endpoint,
|
||||||
|
actionId,
|
||||||
|
actionVersion,
|
||||||
|
};
|
||||||
|
|
||||||
|
const stream =
|
||||||
|
definition.responseType === 'image'
|
||||||
|
? toImage(transportOptions)
|
||||||
|
: textToText(transportOptions);
|
||||||
|
return this.wrapTextStream(stream as AsyncIterable<string>, id, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAIRequestService(
|
||||||
|
gql: <Query extends GraphQLQuery>(
|
||||||
|
options: QueryOptions<Query>
|
||||||
|
) => Promise<QueryResponse<Query>>,
|
||||||
|
eventSource: (
|
||||||
|
url: string,
|
||||||
|
eventSourceInitDict?: EventSourceInit
|
||||||
|
) => EventSource
|
||||||
|
) {
|
||||||
|
return new AIRequestService(new CopilotClient(gql, eventSource));
|
||||||
|
}
|
||||||
@@ -1,12 +1,6 @@
|
|||||||
import { type ActionEventType, AIProvider } from '../provider';
|
import type { ActionEventType } from '../provider';
|
||||||
|
import { getAIRequestService } from '../runtime/request';
|
||||||
|
|
||||||
export function reportResponse(event: ActionEventType) {
|
export function reportResponse(event: ActionEventType, host?: unknown) {
|
||||||
const lastAction = AIProvider.actionHistory.at(-1);
|
getAIRequestService().reportLastAction(event, host);
|
||||||
if (!lastAction) return;
|
|
||||||
|
|
||||||
AIProvider.slots.actions.next({
|
|
||||||
action: lastAction.action,
|
|
||||||
options: lastAction.options,
|
|
||||||
event,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { styleMap } from 'lit/directives/style-map.js';
|
|||||||
import { literal, unsafeStatic } from 'lit/static-html.js';
|
import { literal, unsafeStatic } from 'lit/static-html.js';
|
||||||
|
|
||||||
import type { AIItemGroupConfig } from '../../components/ai-item/types.js';
|
import type { AIItemGroupConfig } from '../../components/ai-item/types.js';
|
||||||
import { AIProvider } from '../../provider/index.js';
|
import { AIAppEvents } from '../../provider/index.js';
|
||||||
import { extractSelectedContent } from '../../utils/extract.js';
|
import { extractSelectedContent } from '../../utils/extract.js';
|
||||||
import {
|
import {
|
||||||
AFFINE_AI_PANEL_WIDGET,
|
AFFINE_AI_PANEL_WIDGET,
|
||||||
@@ -106,7 +106,7 @@ export class EdgelessCopilotWidget extends WidgetComponent<RootBlockModel> {
|
|||||||
aiPanel.hide();
|
aiPanel.hide();
|
||||||
extractSelectedContent(this.host)
|
extractSelectedContent(this.host)
|
||||||
.then(context => {
|
.then(context => {
|
||||||
AIProvider.slots.requestSendWithChat.next({
|
AIAppEvents.requestSendWithChat.next({
|
||||||
input,
|
input,
|
||||||
context,
|
context,
|
||||||
host: this.host,
|
host: this.host,
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
resolveGlobalLoadingEventAtom,
|
resolveGlobalLoadingEventAtom,
|
||||||
} from '@affine/component/global-loading';
|
} from '@affine/component/global-loading';
|
||||||
import {
|
import {
|
||||||
AIProvider,
|
AIAppEvents,
|
||||||
CopilotClient,
|
createAIRequestService,
|
||||||
setupAIProvider,
|
setupAIProvider,
|
||||||
} from '@affine/core/blocksuite/ai';
|
} from '@affine/core/blocksuite/ai';
|
||||||
import { useRegisterFindInPageCommands } from '@affine/core/components/hooks/affine/use-register-find-in-page-commands';
|
import { useRegisterFindInPageCommands } from '@affine/core/components/hooks/affine/use-register-find-in-page-commands';
|
||||||
@@ -103,7 +103,7 @@ export const WorkspaceSideEffects = () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const disposable = AIProvider.slots.requestInsertTemplate.subscribe(
|
const disposable = AIAppEvents.requestInsertTemplate.subscribe(
|
||||||
({ template, mode }) => {
|
({ template, mode }) => {
|
||||||
insertTemplate({ template, mode });
|
insertTemplate({ template, mode });
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@ export const WorkspaceSideEffects = () => {
|
|||||||
const globalDialogService = useService(GlobalDialogService);
|
const globalDialogService = useService(GlobalDialogService);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const disposable = AIProvider.slots.requestUpgradePlan.subscribe(() => {
|
const disposable = AIAppEvents.requestUpgradePlan.subscribe(() => {
|
||||||
workspaceDialogService.open('setting', {
|
workspaceDialogService.open('setting', {
|
||||||
activeTab: 'billing',
|
activeTab: 'billing',
|
||||||
});
|
});
|
||||||
@@ -143,7 +143,10 @@ export const WorkspaceSideEffects = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const dispose = setupAIProvider(
|
const dispose = setupAIProvider(
|
||||||
new CopilotClient(graphqlService.gql, eventSourceService.eventSource),
|
createAIRequestService(
|
||||||
|
graphqlService.gql,
|
||||||
|
eventSourceService.eventSource
|
||||||
|
),
|
||||||
globalDialogService,
|
globalDialogService,
|
||||||
authService
|
authService
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
import { WorkspaceLocalState } from '@affine/core/modules/workspace';
|
|
||||||
import type { I18nInstance } from '@affine/i18n';
|
|
||||||
import type { NotificationService } from '@blocksuite/affine/shared/services';
|
|
||||||
import { useService } from '@toeverything/infra';
|
|
||||||
import {
|
|
||||||
type Dispatch,
|
|
||||||
type SetStateAction,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
const AI_CHAT_OPEN_TABS_KEY = 'aiChatOpenTabs';
|
|
||||||
|
|
||||||
// Pass `null` for `loadSession` to defer hydration until a real loader is ready.
|
|
||||||
export function useAIChatOpenTabs<T extends { sessionId: string }>(
|
|
||||||
loadSession: ((sessionId: string) => Promise<T | null | undefined>) | null
|
|
||||||
): {
|
|
||||||
openTabs: T[];
|
|
||||||
setOpenTabs: Dispatch<SetStateAction<T[]>>;
|
|
||||||
} {
|
|
||||||
const workspaceLocalState = useService(WorkspaceLocalState);
|
|
||||||
const [openTabs, setOpenTabsState] = useState<T[]>([]);
|
|
||||||
// Ref so persist gate isn't subject to React state-batch ordering.
|
|
||||||
const hydratedRef = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loadSession) return;
|
|
||||||
hydratedRef.current = false;
|
|
||||||
setOpenTabsState([]);
|
|
||||||
|
|
||||||
const ids = workspaceLocalState.get<string[]>(AI_CHAT_OPEN_TABS_KEY) ?? [];
|
|
||||||
if (!ids.length) {
|
|
||||||
hydratedRef.current = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
Promise.all(ids.map(id => loadSession(id).catch(() => null)))
|
|
||||||
.then(results => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const valid = (results as (T | null | undefined)[]).filter(
|
|
||||||
(entry): entry is T => !!entry && !!entry.sessionId
|
|
||||||
);
|
|
||||||
if (valid.length) setOpenTabsState(valid);
|
|
||||||
hydratedRef.current = true;
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error(error);
|
|
||||||
if (!cancelled) hydratedRef.current = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [loadSession, workspaceLocalState]);
|
|
||||||
|
|
||||||
const setOpenTabs = useCallback<Dispatch<SetStateAction<T[]>>>(
|
|
||||||
updater => {
|
|
||||||
setOpenTabsState(prev => {
|
|
||||||
const next =
|
|
||||||
typeof updater === 'function'
|
|
||||||
? (updater as (p: T[]) => T[])(prev)
|
|
||||||
: updater;
|
|
||||||
if (hydratedRef.current) {
|
|
||||||
if (next.length) {
|
|
||||||
workspaceLocalState.set(
|
|
||||||
AI_CHAT_OPEN_TABS_KEY,
|
|
||||||
next.map(tab => tab.sessionId)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
workspaceLocalState.del(AI_CHAT_OPEN_TABS_KEY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[workspaceLocalState]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { openTabs, setOpenTabs };
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SessionDeleteCleanupFn = (
|
|
||||||
session: BlockSuitePresets.AIRecentSession
|
|
||||||
) => Promise<void>;
|
|
||||||
|
|
||||||
export type CreateSessionDeleteHandlerOptions = {
|
|
||||||
t: I18nInstance;
|
|
||||||
notificationService: NotificationService;
|
|
||||||
cleanupSession: SessionDeleteCleanupFn;
|
|
||||||
canDeleteSession?: (session: BlockSuitePresets.AIRecentSession) => boolean;
|
|
||||||
isActiveSession?: (session: BlockSuitePresets.AIRecentSession) => boolean;
|
|
||||||
onActiveSessionDeleted?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createSessionDeleteHandler({
|
|
||||||
t,
|
|
||||||
notificationService,
|
|
||||||
cleanupSession,
|
|
||||||
canDeleteSession,
|
|
||||||
isActiveSession,
|
|
||||||
onActiveSessionDeleted,
|
|
||||||
}: CreateSessionDeleteHandlerOptions) {
|
|
||||||
return async (sessionToDelete: BlockSuitePresets.AIRecentSession) => {
|
|
||||||
if (canDeleteSession && !canDeleteSession(sessionToDelete)) {
|
|
||||||
notificationService.toast(
|
|
||||||
t['com.affine.ai.chat-panel.session.delete.toast.failed']()
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirm = await notificationService.confirm({
|
|
||||||
title: t['com.affine.ai.chat-panel.session.delete.confirm.title'](),
|
|
||||||
message: t['com.affine.ai.chat-panel.session.delete.confirm.message'](),
|
|
||||||
confirmText: t['Delete'](),
|
|
||||||
cancelText: t['Cancel'](),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!confirm) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await cleanupSession(sessionToDelete);
|
|
||||||
notificationService.toast(
|
|
||||||
t['com.affine.ai.chat-panel.session.delete.toast.success']()
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
notificationService.toast(
|
|
||||||
t['com.affine.ai.chat-panel.session.delete.toast.failed']()
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isActiveSession?.(sessionToDelete)) {
|
|
||||||
onActiveSessionDeleted?.();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
import { observeResize, useConfirmModal } from '@affine/component';
|
import { observeResize, useConfirmModal } from '@affine/component';
|
||||||
import { CopilotClient } from '@affine/core/blocksuite/ai';
|
|
||||||
import {
|
import {
|
||||||
AIChatContent,
|
AIChatRuntime,
|
||||||
type ChatContextValue,
|
createAIRequestService,
|
||||||
} from '@affine/core/blocksuite/ai/components/ai-chat-content';
|
useAIChatElement,
|
||||||
import type { ChatStatus } from '@affine/core/blocksuite/ai/components/ai-chat-messages';
|
useAIChatRuntime,
|
||||||
import type { AIChatToolbar } from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
|
WorkspaceAIChatSessionStrategy,
|
||||||
|
} from '@affine/core/blocksuite/ai';
|
||||||
|
import { AIChatContent } from '@affine/core/blocksuite/ai/components/ai-chat-content';
|
||||||
import {
|
import {
|
||||||
AIChatTabs,
|
AIChatTabs,
|
||||||
|
AIChatToolbar,
|
||||||
configureAIChatToolbar,
|
configureAIChatToolbar,
|
||||||
getOrCreateAIChatToolbar,
|
|
||||||
} from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
|
} from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
|
||||||
import type { PromptKey } from '@affine/core/blocksuite/ai/provider/prompt';
|
|
||||||
import { getViewManager } from '@affine/core/blocksuite/manager/view';
|
import { getViewManager } from '@affine/core/blocksuite/manager/view';
|
||||||
import { NotificationServiceImpl } from '@affine/core/blocksuite/view-extensions/editor-view/notification-service';
|
import { NotificationServiceImpl } from '@affine/core/blocksuite/view-extensions/editor-view/notification-service';
|
||||||
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
|
import { useAIChatConfig } from '@affine/core/components/hooks/affine/use-ai-chat-config';
|
||||||
@@ -50,20 +50,18 @@ import { useFramework, useService } from '@toeverything/infra';
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
|
||||||
createSessionDeleteHandler,
|
|
||||||
useAIChatOpenTabs,
|
|
||||||
} from '../chat-panel-utils';
|
|
||||||
import * as styles from './index.css';
|
import * as styles from './index.css';
|
||||||
|
|
||||||
type CopilotSession = Awaited<ReturnType<CopilotClient['getSession']>>;
|
function useAIRequestService() {
|
||||||
|
|
||||||
function useCopilotClient() {
|
|
||||||
const graphqlService = useService(GraphQLService);
|
const graphqlService = useService(GraphQLService);
|
||||||
const eventSourceService = useService(EventSourceService);
|
const eventSourceService = useService(EventSourceService);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => new CopilotClient(graphqlService.gql, eventSourceService.eventSource),
|
() =>
|
||||||
|
createAIRequestService(
|
||||||
|
graphqlService.gql,
|
||||||
|
eventSourceService.eventSource
|
||||||
|
),
|
||||||
[graphqlService, eventSourceService]
|
[graphqlService, eventSourceService]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -95,164 +93,32 @@ export const Component = () => {
|
|||||||
const framework = useFramework();
|
const framework = useFramework();
|
||||||
const [isBodyProvided, setIsBodyProvided] = useState(false);
|
const [isBodyProvided, setIsBodyProvided] = useState(false);
|
||||||
const [isHeaderProvided, setIsHeaderProvided] = useState(false);
|
const [isHeaderProvided, setIsHeaderProvided] = useState(false);
|
||||||
const [chatContent, setChatContent] = useState<AIChatContent | null>(null);
|
|
||||||
const [chatTool, setChatTool] = useState<AIChatToolbar | null>(null);
|
|
||||||
const [chatTabs, setChatTabs] = useState<AIChatTabs | null>(null);
|
|
||||||
const [currentSession, setCurrentSession] = useState<CopilotSession | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
const [status, setStatus] = useState<ChatStatus>('idle');
|
|
||||||
const [isTogglingPin, setIsTogglingPin] = useState(false);
|
|
||||||
const [isOpeningSession, setIsOpeningSession] = useState(false);
|
|
||||||
const hasRestoredPinnedSessionRef = useRef(false);
|
|
||||||
const chatContainerRef = useRef<HTMLDivElement>(null);
|
const chatContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const chatToolContainerRef = useRef<HTMLDivElement>(null);
|
const chatToolContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const chatTabsContainerRef = useRef<HTMLDivElement | null>(null);
|
const chatTabsContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const widthSignalRef = useRef<Signal<number>>(signal(0));
|
const widthSignalRef = useRef<Signal<number>>(signal(0));
|
||||||
const client = useCopilotClient();
|
const requestService = useAIRequestService();
|
||||||
const workbench = useService(WorkbenchService).workbench;
|
const workbench = useService(WorkbenchService).workbench;
|
||||||
|
|
||||||
const workspaceId = useService(WorkspaceService).workspace.id;
|
const workspaceId = useService(WorkspaceService).workspace.id;
|
||||||
|
|
||||||
const loadSession = useCallback(
|
const runtime = useMemo(
|
||||||
(sessionId: string) => client.getSession(workspaceId, sessionId),
|
() =>
|
||||||
[client, workspaceId]
|
new AIChatRuntime({
|
||||||
|
request: requestService,
|
||||||
|
scope: { kind: 'workspace', workspaceId },
|
||||||
|
strategy: new WorkspaceAIChatSessionStrategy(),
|
||||||
|
}),
|
||||||
|
[requestService, workspaceId]
|
||||||
);
|
);
|
||||||
const { openTabs, setOpenTabs } = useAIChatOpenTabs(loadSession);
|
const snapshot = useAIChatRuntime(runtime);
|
||||||
|
const session =
|
||||||
useEffect(() => {
|
snapshot?.sessions.find(
|
||||||
hasRestoredPinnedSessionRef.current = false;
|
session => session.sessionId === snapshot.activeSessionId
|
||||||
}, [workspaceId]);
|
) ?? null;
|
||||||
|
|
||||||
const { docDisplayConfig, searchMenuConfig, reasoningConfig } =
|
const { docDisplayConfig, searchMenuConfig, reasoningConfig } =
|
||||||
useAIChatConfig();
|
useAIChatConfig();
|
||||||
|
|
||||||
const createSession = useCallback(
|
|
||||||
async (options: Partial<BlockSuitePresets.AICreateSessionOptions> = {}) => {
|
|
||||||
if (currentSession) {
|
|
||||||
return currentSession;
|
|
||||||
}
|
|
||||||
const session = await client.createSessionWithHistory({
|
|
||||||
workspaceId,
|
|
||||||
promptName: 'Chat With AFFiNE AI' satisfies PromptKey,
|
|
||||||
reuseLatestChat: false,
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
setCurrentSession(session);
|
|
||||||
return session;
|
|
||||||
},
|
|
||||||
[client, currentSession, workspaceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const togglePin = useCallback(async () => {
|
|
||||||
if (isTogglingPin) return;
|
|
||||||
setIsTogglingPin(true);
|
|
||||||
try {
|
|
||||||
const pinned = !currentSession?.pinned;
|
|
||||||
if (!currentSession) {
|
|
||||||
await createSession({ pinned });
|
|
||||||
} else {
|
|
||||||
await client.updateSession({
|
|
||||||
sessionId: currentSession.sessionId,
|
|
||||||
pinned,
|
|
||||||
});
|
|
||||||
// retrieve the latest session and update the state
|
|
||||||
const session = await client.getSession(
|
|
||||||
workspaceId,
|
|
||||||
currentSession.sessionId
|
|
||||||
);
|
|
||||||
setCurrentSession(session);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsTogglingPin(false);
|
|
||||||
}
|
|
||||||
}, [client, createSession, currentSession, isTogglingPin, workspaceId]);
|
|
||||||
|
|
||||||
// remove the old content to trigger re-mount
|
|
||||||
// to avoid infinitely load and mount, should not make `chatContent` as dependency
|
|
||||||
const reMountChatContent = useCallback(() => {
|
|
||||||
setChatContent(prev => {
|
|
||||||
prev?.remove();
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const createFreshSession = useCallback(async () => {
|
|
||||||
if (isOpeningSession) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsOpeningSession(true);
|
|
||||||
try {
|
|
||||||
setCurrentSession(null);
|
|
||||||
reMountChatContent();
|
|
||||||
const session = await client.createSessionWithHistory({
|
|
||||||
workspaceId,
|
|
||||||
promptName: 'Chat With AFFiNE AI' satisfies PromptKey,
|
|
||||||
reuseLatestChat: false,
|
|
||||||
});
|
|
||||||
setCurrentSession(session);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setIsOpeningSession(false);
|
|
||||||
}
|
|
||||||
}, [client, isOpeningSession, reMountChatContent, workspaceId]);
|
|
||||||
|
|
||||||
const onOpenSession = useCallback(
|
|
||||||
async (sessionId: string) => {
|
|
||||||
if (isOpeningSession || currentSession?.sessionId === sessionId) return;
|
|
||||||
setIsOpeningSession(true);
|
|
||||||
try {
|
|
||||||
const session = await client.getSession(workspaceId, sessionId);
|
|
||||||
if (!session) {
|
|
||||||
// Drop stale tab if session no longer exists.
|
|
||||||
setOpenTabs(prev => prev.filter(tab => tab.sessionId !== sessionId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCurrentSession(session);
|
|
||||||
reMountChatContent();
|
|
||||||
chatTool?.closeHistoryMenu();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setIsOpeningSession(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
chatTool,
|
|
||||||
client,
|
|
||||||
currentSession?.sessionId,
|
|
||||||
isOpeningSession,
|
|
||||||
reMountChatContent,
|
|
||||||
setOpenTabs,
|
|
||||||
workspaceId,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const closeTab = useCallback(
|
|
||||||
(sessionId: string) => {
|
|
||||||
let fallback: NonNullable<CopilotSession> | undefined;
|
|
||||||
setOpenTabs(prev => {
|
|
||||||
const idx = prev.findIndex(tab => tab.sessionId === sessionId);
|
|
||||||
if (idx === -1) return prev;
|
|
||||||
const next = prev.filter(tab => tab.sessionId !== sessionId);
|
|
||||||
fallback = next[idx] ?? next[idx - 1] ?? next[0];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
if (currentSession?.sessionId !== sessionId) return;
|
|
||||||
if (fallback) {
|
|
||||||
onOpenSession(fallback.sessionId).catch(console.error);
|
|
||||||
} else {
|
|
||||||
createFreshSession().catch(console.error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[createFreshSession, currentSession?.sessionId, onOpenSession, setOpenTabs]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onContextChange = useCallback((context: Partial<ChatContextValue>) => {
|
|
||||||
setStatus(context.status ?? 'idle');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onOpenDoc = useCallback(
|
const onOpenDoc = useCallback(
|
||||||
(docId: string) => {
|
(docId: string) => {
|
||||||
workbench.openDoc(docId, { at: 'active' });
|
workbench.openDoc(docId, { at: 'active' });
|
||||||
@@ -283,54 +149,42 @@ export const Component = () => {
|
|||||||
const mockStd = useMockStd();
|
const mockStd = useMockStd();
|
||||||
const handleAISubscribe = useAISubscribe();
|
const handleAISubscribe = useAISubscribe();
|
||||||
|
|
||||||
const deleteSession = useMemo(
|
const deleteSession = useCallback(
|
||||||
() =>
|
async (sessionToDelete: BlockSuitePresets.AIRecentSession) => {
|
||||||
createSessionDeleteHandler({
|
const confirm = await notificationService.confirm({
|
||||||
t,
|
title: t['com.affine.ai.chat-panel.session.delete.confirm.title'](),
|
||||||
notificationService,
|
message: t['com.affine.ai.chat-panel.session.delete.confirm.message'](),
|
||||||
cleanupSession: async sessionToDelete => {
|
confirmText: t['Delete'](),
|
||||||
await client.cleanupSessions({
|
cancelText: t['Cancel'](),
|
||||||
workspaceId: sessionToDelete.workspaceId,
|
|
||||||
docId: sessionToDelete.docId || undefined,
|
|
||||||
sessionIds: [sessionToDelete.sessionId],
|
|
||||||
});
|
});
|
||||||
|
if (!confirm) return;
|
||||||
|
await runtime.dispatch({
|
||||||
|
type: 'deleteSession',
|
||||||
|
sessionId: sessionToDelete.sessionId,
|
||||||
|
});
|
||||||
|
notificationService.toast(
|
||||||
|
t['com.affine.ai.chat-panel.session.delete.toast.success'](),
|
||||||
|
{}
|
||||||
|
);
|
||||||
},
|
},
|
||||||
isActiveSession: sessionToDelete =>
|
[notificationService, runtime, t]
|
||||||
sessionToDelete.sessionId === currentSession?.sessionId,
|
|
||||||
onActiveSessionDeleted: () => {
|
|
||||||
setCurrentSession(null);
|
|
||||||
reMountChatContent();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
client,
|
|
||||||
currentSession?.sessionId,
|
|
||||||
notificationService,
|
|
||||||
reMountChatContent,
|
|
||||||
t,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// init or update ai-chat-content
|
useAIChatElement({
|
||||||
useEffect(() => {
|
containerRef: chatContainerRef,
|
||||||
if (!isBodyProvided) {
|
selector: 'ai-chat-content',
|
||||||
return;
|
enabled: isBodyProvided,
|
||||||
}
|
createElement: () => new AIChatContent(),
|
||||||
|
configureElement: content => {
|
||||||
let content = chatContent;
|
content.session = session;
|
||||||
|
content.runtime = runtime;
|
||||||
if (!content) {
|
content.runtimeSnapshot = snapshot;
|
||||||
content = new AIChatContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
content.session = currentSession;
|
|
||||||
content.workspaceId = workspaceId;
|
content.workspaceId = workspaceId;
|
||||||
content.extensions = specs;
|
content.extensions = specs;
|
||||||
content.host = mockStd?.host;
|
content.host = mockStd?.host;
|
||||||
content.docDisplayConfig = docDisplayConfig;
|
content.docDisplayConfig = docDisplayConfig;
|
||||||
content.searchMenuConfig = searchMenuConfig;
|
content.searchMenuConfig = searchMenuConfig;
|
||||||
content.reasoningConfig = reasoningConfig;
|
content.reasoningConfig = reasoningConfig;
|
||||||
content.onContextChange = onContextChange;
|
|
||||||
content.affineFeatureFlagService = framework.get(FeatureFlagService);
|
content.affineFeatureFlagService = framework.get(FeatureFlagService);
|
||||||
content.affineWorkspaceDialogService = framework.get(
|
content.affineWorkspaceDialogService = framework.get(
|
||||||
WorkspaceDialogService
|
WorkspaceDialogService
|
||||||
@@ -344,82 +198,37 @@ export const Component = () => {
|
|||||||
content.subscriptionService = framework.get(SubscriptionService);
|
content.subscriptionService = framework.get(SubscriptionService);
|
||||||
content.aiModelService = framework.get(AIModelService);
|
content.aiModelService = framework.get(AIModelService);
|
||||||
content.onAISubscribe = handleAISubscribe;
|
content.onAISubscribe = handleAISubscribe;
|
||||||
|
|
||||||
content.createSession = createSession;
|
|
||||||
content.onOpenDoc = onOpenDoc;
|
content.onOpenDoc = onOpenDoc;
|
||||||
|
},
|
||||||
if (!chatContent) {
|
onElementReady: content => {
|
||||||
// initial values that won't change
|
|
||||||
content.independentMode = true;
|
content.independentMode = true;
|
||||||
content.onboardingOffsetY = -100;
|
content.onboardingOffsetY = -100;
|
||||||
chatContainerRef.current?.append(content);
|
|
||||||
setChatContent(content);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
chatContent,
|
|
||||||
createSession,
|
|
||||||
currentSession,
|
|
||||||
docDisplayConfig,
|
|
||||||
framework,
|
|
||||||
isBodyProvided,
|
|
||||||
mockStd,
|
|
||||||
reasoningConfig,
|
|
||||||
searchMenuConfig,
|
|
||||||
workspaceId,
|
|
||||||
onContextChange,
|
|
||||||
notificationService,
|
|
||||||
specs,
|
|
||||||
onOpenDoc,
|
|
||||||
handleAISubscribe,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// init or update header ai-chat-toolbar
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isHeaderProvided || !chatToolContainerRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const tool = getOrCreateAIChatToolbar(chatTool);
|
|
||||||
configureAIChatToolbar(tool, {
|
|
||||||
session: currentSession,
|
|
||||||
workspaceId,
|
|
||||||
status,
|
|
||||||
docDisplayConfig,
|
|
||||||
notificationService,
|
|
||||||
onOpenSession: sessionId => {
|
|
||||||
onOpenSession(sessionId).catch(console.error);
|
|
||||||
},
|
|
||||||
onNewSession: () => {
|
|
||||||
createFreshSession().catch(console.error);
|
|
||||||
},
|
|
||||||
onTogglePin: togglePin,
|
|
||||||
onOpenDoc: (docId: string, sessionId: string) => {
|
|
||||||
onOpenSessionDoc(docId, sessionId);
|
|
||||||
},
|
|
||||||
onSessionDelete: (sessionToDelete: BlockSuitePresets.AIRecentSession) => {
|
|
||||||
deleteSession(sessionToDelete).catch(console.error);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// initial props
|
useAIChatElement({
|
||||||
if (!chatTool) {
|
containerRef: chatToolContainerRef,
|
||||||
// mount
|
selector: 'ai-chat-toolbar',
|
||||||
chatToolContainerRef.current.append(tool);
|
enabled: isHeaderProvided,
|
||||||
setChatTool(tool);
|
createElement: () => new AIChatToolbar(),
|
||||||
}
|
configureElement: tool => {
|
||||||
}, [
|
configureAIChatToolbar(tool, {
|
||||||
chatTool,
|
session,
|
||||||
currentSession,
|
runtime,
|
||||||
|
runtimeSnapshot: snapshot ?? runtime.getSnapshot(),
|
||||||
docDisplayConfig,
|
docDisplayConfig,
|
||||||
isHeaderProvided,
|
|
||||||
onOpenSession,
|
|
||||||
togglePin,
|
|
||||||
workspaceId,
|
|
||||||
onOpenSessionDoc,
|
|
||||||
deleteSession,
|
|
||||||
status,
|
|
||||||
notificationService,
|
notificationService,
|
||||||
createFreshSession,
|
onOpenDoc: (docId: string, sessionId: string) => {
|
||||||
]);
|
onOpenSessionDoc(docId, sessionId);
|
||||||
|
},
|
||||||
|
onSessionDelete: (
|
||||||
|
sessionToDelete: BlockSuitePresets.AIRecentSession
|
||||||
|
) => {
|
||||||
|
deleteSession(sessionToDelete).catch(console.error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const refNodeSlots = mockStd?.getOptional(RefNodeSlotsProvider);
|
const refNodeSlots = mockStd?.getOptional(RefNodeSlotsProvider);
|
||||||
@@ -437,87 +246,16 @@ export const Component = () => {
|
|||||||
return () => sub.unsubscribe();
|
return () => sub.unsubscribe();
|
||||||
}, [framework, mockStd]);
|
}, [framework, mockStd]);
|
||||||
|
|
||||||
useEffect(() => {
|
useAIChatElement({
|
||||||
if (!currentSession?.sessionId) return;
|
containerRef: chatTabsContainerRef,
|
||||||
setOpenTabs(prev => {
|
selector: 'ai-chat-tabs',
|
||||||
const existing = prev.findIndex(
|
enabled: true,
|
||||||
tab => tab.sessionId === currentSession.sessionId
|
createElement: () => new AIChatTabs(),
|
||||||
);
|
configureElement: tabs => {
|
||||||
if (existing !== -1) {
|
tabs.runtime = runtime;
|
||||||
if (prev[existing] === currentSession) return prev;
|
tabs.runtimeSnapshot = snapshot;
|
||||||
const next = prev.slice();
|
},
|
||||||
next[existing] = currentSession;
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
return [...prev, currentSession];
|
|
||||||
});
|
});
|
||||||
}, [currentSession, setOpenTabs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!chatTabsContainerRef.current) return;
|
|
||||||
let tabs = chatTabs;
|
|
||||||
if (!tabs) {
|
|
||||||
tabs = new AIChatTabs();
|
|
||||||
chatTabsContainerRef.current.append(tabs);
|
|
||||||
setChatTabs(tabs);
|
|
||||||
}
|
|
||||||
tabs.sessions = openTabs;
|
|
||||||
tabs.activeSessionId = currentSession?.sessionId;
|
|
||||||
tabs.onSelectTab = (sessionId: string) => {
|
|
||||||
onOpenSession(sessionId).catch(console.error);
|
|
||||||
};
|
|
||||||
tabs.onCloseTab = (sessionId: string) => {
|
|
||||||
closeTab(sessionId);
|
|
||||||
};
|
|
||||||
}, [chatTabs, closeTab, currentSession?.sessionId, onOpenSession, openTabs]);
|
|
||||||
|
|
||||||
// restore pinned session
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasRestoredPinnedSessionRef.current || currentSession) return;
|
|
||||||
hasRestoredPinnedSessionRef.current = true;
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
const loadPinnedSession = async () => {
|
|
||||||
try {
|
|
||||||
const sessions = await client.getSessions(
|
|
||||||
workspaceId,
|
|
||||||
{},
|
|
||||||
undefined,
|
|
||||||
{ pinned: true, limit: 1 },
|
|
||||||
controller.signal
|
|
||||||
);
|
|
||||||
if (controller.signal.aborted || !Array.isArray(sessions)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const pinnedSession = sessions[0];
|
|
||||||
if (!pinnedSession) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let shouldRemount = false;
|
|
||||||
setCurrentSession(prev => {
|
|
||||||
if (prev) return prev;
|
|
||||||
shouldRemount = true;
|
|
||||||
return pinnedSession;
|
|
||||||
});
|
|
||||||
if (shouldRemount) reMountChatContent();
|
|
||||||
} catch (error) {
|
|
||||||
if (controller.signal.aborted) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadPinnedSession().catch(error => {
|
|
||||||
if (controller.signal.aborted) return;
|
|
||||||
console.error(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
// abort the request
|
|
||||||
return () => {
|
|
||||||
controller.abort();
|
|
||||||
};
|
|
||||||
}, [client, currentSession, reMountChatContent, workspaceId]);
|
|
||||||
|
|
||||||
const onChatContainerRef = useCallback((node: HTMLDivElement) => {
|
const onChatContainerRef = useCallback((node: HTMLDivElement) => {
|
||||||
if (node) {
|
if (node) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Scrollable } from '@affine/component';
|
import { Scrollable } from '@affine/component';
|
||||||
import { PageDetailLoading } from '@affine/component/page-detail-skeleton';
|
import { PageDetailLoading } from '@affine/component/page-detail-skeleton';
|
||||||
import type { AIChatParams } from '@affine/core/blocksuite/ai';
|
import { AIAppEvents, type AIChatParams } from '@affine/core/blocksuite/ai';
|
||||||
import { AIProvider } from '@affine/core/blocksuite/ai';
|
|
||||||
import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor';
|
import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor';
|
||||||
import { EditorOutlineViewer } from '@affine/core/blocksuite/outline-viewer';
|
import { EditorOutlineViewer } from '@affine/core/blocksuite/outline-viewer';
|
||||||
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
|
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
|
||||||
@@ -145,12 +144,8 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
|||||||
workbench.openSidebar();
|
workbench.openSidebar();
|
||||||
view.activeSidebarTab('chat');
|
view.activeSidebarTab('chat');
|
||||||
};
|
};
|
||||||
disposables.push(
|
disposables.push(AIAppEvents.requestOpenWithChat.subscribe(openHandler));
|
||||||
AIProvider.slots.requestOpenWithChat.subscribe(openHandler)
|
disposables.push(AIAppEvents.requestSendWithChat.subscribe(openHandler));
|
||||||
);
|
|
||||||
disposables.push(
|
|
||||||
AIProvider.slots.requestSendWithChat.subscribe(openHandler)
|
|
||||||
);
|
|
||||||
return () => disposables.forEach(d => d.unsubscribe());
|
return () => disposables.forEach(d => d.unsubscribe());
|
||||||
}, [activeSidebarTab, view, workbench]);
|
}, [activeSidebarTab, view, workbench]);
|
||||||
|
|
||||||
@@ -378,7 +373,7 @@ const DetailPageImpl = memo(function DetailPageImpl() {
|
|||||||
icon={<AiIcon />}
|
icon={<AiIcon />}
|
||||||
unmountOnInactive={false}
|
unmountOnInactive={false}
|
||||||
>
|
>
|
||||||
<EditorChatPanel editor={editorContainer} />
|
<EditorChatPanel editor={editorContainer} doc={doc.blockSuiteDoc} />
|
||||||
</ViewSidebarTab>
|
</ViewSidebarTab>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,341 +0,0 @@
|
|||||||
/* eslint-disable rxjs/finnish */
|
|
||||||
import { type CopilotChatHistoryFragment } from '@affine/graphql';
|
|
||||||
import { describe, expect, test, vi } from 'vitest';
|
|
||||||
|
|
||||||
import {
|
|
||||||
canCreateNewDocPanelSession,
|
|
||||||
filterDocPanelTabs,
|
|
||||||
getChatContentKey,
|
|
||||||
hasSessionMessages,
|
|
||||||
isSessionAvailableInDocPanel,
|
|
||||||
resolveInitialSession,
|
|
||||||
type SessionService,
|
|
||||||
shouldResetChatPanelOnUserInfoChange,
|
|
||||||
type WorkbenchLike,
|
|
||||||
} from './chat-panel-session';
|
|
||||||
|
|
||||||
const createWorkbench = (search: string) => {
|
|
||||||
const updateQueryString = vi.fn();
|
|
||||||
const workbench = {
|
|
||||||
location$: { value: { search } },
|
|
||||||
activeView$: { value: { updateQueryString } },
|
|
||||||
} satisfies WorkbenchLike;
|
|
||||||
|
|
||||||
return { workbench, updateQueryString };
|
|
||||||
};
|
|
||||||
|
|
||||||
const doc = { id: 'doc-1', workspace: { id: 'ws-1' } };
|
|
||||||
|
|
||||||
describe('getChatContentKey', () => {
|
|
||||||
const cases = [
|
|
||||||
{
|
|
||||||
name: 'uses doc id before a session is created',
|
|
||||||
input: {
|
|
||||||
docId: 'doc-1',
|
|
||||||
hasPinned: false,
|
|
||||||
session: null,
|
|
||||||
},
|
|
||||||
expected: 'doc-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'keeps a new empty doc session on the doc key',
|
|
||||||
input: {
|
|
||||||
docId: 'doc-2',
|
|
||||||
hasPinned: false,
|
|
||||||
previousSessionDocId: 'doc-1',
|
|
||||||
previousSessionId: 'session-1',
|
|
||||||
session: {
|
|
||||||
sessionId: 'session-2',
|
|
||||||
docId: 'doc-2',
|
|
||||||
messages: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: 'doc-2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'uses session id for a session with history',
|
|
||||||
input: {
|
|
||||||
docId: 'doc-1',
|
|
||||||
hasPinned: false,
|
|
||||||
session: {
|
|
||||||
sessionId: 'session-1',
|
|
||||||
docId: 'doc-1',
|
|
||||||
messages: [{ id: 'message-1' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: 'session-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'uses session id for a pinned session',
|
|
||||||
input: {
|
|
||||||
docId: 'doc-1',
|
|
||||||
hasPinned: true,
|
|
||||||
session: {
|
|
||||||
sessionId: 'session-1',
|
|
||||||
docId: 'doc-1',
|
|
||||||
messages: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: 'session-1',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'uses session id for same-doc session switch',
|
|
||||||
input: {
|
|
||||||
docId: 'doc-1',
|
|
||||||
hasPinned: false,
|
|
||||||
previousSessionDocId: 'doc-1',
|
|
||||||
previousSessionId: 'session-1',
|
|
||||||
session: {
|
|
||||||
sessionId: 'session-2',
|
|
||||||
docId: 'doc-1',
|
|
||||||
messages: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: 'session-2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'keeps generating draft session on the doc key',
|
|
||||||
input: {
|
|
||||||
docId: 'doc-1',
|
|
||||||
hasPinned: false,
|
|
||||||
isGenerating: true,
|
|
||||||
previousSessionDocId: 'doc-1',
|
|
||||||
previousSessionId: 'session-1',
|
|
||||||
session: {
|
|
||||||
sessionId: 'session-2',
|
|
||||||
docId: 'doc-1',
|
|
||||||
messages: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expected: 'doc-1',
|
|
||||||
},
|
|
||||||
] satisfies {
|
|
||||||
name: string;
|
|
||||||
input: Parameters<typeof getChatContentKey>[0];
|
|
||||||
expected: string;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
test.each(cases)('$name', ({ input, expected }) => {
|
|
||||||
expect(getChatContentKey(input)).toBe(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('shouldResetChatPanelOnUserInfoChange', () => {
|
|
||||||
const cases = [
|
|
||||||
{
|
|
||||||
name: 'ignores the initial user info emission',
|
|
||||||
input: {
|
|
||||||
previousUserId: undefined,
|
|
||||||
nextUserId: 'user-1',
|
|
||||||
},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'ignores same-user refreshes',
|
|
||||||
input: {
|
|
||||||
previousUserId: 'user-1',
|
|
||||||
nextUserId: 'user-1',
|
|
||||||
},
|
|
||||||
expected: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'resets when the effective user changes',
|
|
||||||
input: {
|
|
||||||
previousUserId: 'user-1',
|
|
||||||
nextUserId: 'user-2',
|
|
||||||
},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'resets when the effective user signs out',
|
|
||||||
input: {
|
|
||||||
previousUserId: 'user-1',
|
|
||||||
nextUserId: null,
|
|
||||||
},
|
|
||||||
expected: true,
|
|
||||||
},
|
|
||||||
] satisfies {
|
|
||||||
name: string;
|
|
||||||
input: Parameters<typeof shouldResetChatPanelOnUserInfoChange>[0];
|
|
||||||
expected: boolean;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
test.each(cases)('$name', ({ input, expected }) => {
|
|
||||||
expect(shouldResetChatPanelOnUserInfoChange(input)).toBe(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('doc panel tabs', () => {
|
|
||||||
const sessions = [
|
|
||||||
{ sessionId: 'current-doc-session', docId: 'doc-1' },
|
|
||||||
{ sessionId: 'workspace-session', docId: null },
|
|
||||||
{ sessionId: 'other-doc-session', docId: 'doc-2' },
|
|
||||||
];
|
|
||||||
|
|
||||||
test('allows only current doc or workspace sessions', () => {
|
|
||||||
expect(filterDocPanelTabs(sessions, 'doc-1')).toEqual([
|
|
||||||
sessions[0],
|
|
||||||
sessions[1],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('rejects other document sessions', () => {
|
|
||||||
expect(isSessionAvailableInDocPanel(sessions[2], 'doc-1')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('new session guard', () => {
|
|
||||||
test('allows a new session only after the current chat has messages', () => {
|
|
||||||
expect(
|
|
||||||
canCreateNewDocPanelSession({
|
|
||||||
hasContextMessages: false,
|
|
||||||
session: { messages: [] },
|
|
||||||
status: 'idle',
|
|
||||||
})
|
|
||||||
).toBe(false);
|
|
||||||
expect(
|
|
||||||
canCreateNewDocPanelSession({
|
|
||||||
hasContextMessages: true,
|
|
||||||
session: { messages: [] },
|
|
||||||
status: 'idle',
|
|
||||||
})
|
|
||||||
).toBe(true);
|
|
||||||
expect(hasSessionMessages({ messages: [{ id: 'message-1' }] })).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not allow a new session while generating', () => {
|
|
||||||
expect(
|
|
||||||
canCreateNewDocPanelSession({
|
|
||||||
hasContextMessages: true,
|
|
||||||
session: null,
|
|
||||||
status: 'loading',
|
|
||||||
})
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns undefined without session service or doc', async () => {
|
|
||||||
await expect(
|
|
||||||
resolveInitialSession({ sessionService: null, doc, workbench: null })
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
await expect(
|
|
||||||
resolveInitialSession({
|
|
||||||
sessionService: {
|
|
||||||
getSessions: vi.fn(),
|
|
||||||
getSession: vi.fn(),
|
|
||||||
},
|
|
||||||
doc: null,
|
|
||||||
workbench: null,
|
|
||||||
})
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolveInitialSession', () => {
|
|
||||||
test('prefers pinned session and clears sessionId from url', async () => {
|
|
||||||
const pinnedSession = {
|
|
||||||
sessionId: 'pinned-session',
|
|
||||||
pinned: true,
|
|
||||||
} as CopilotChatHistoryFragment;
|
|
||||||
|
|
||||||
const sessionService: SessionService = {
|
|
||||||
getSessions: vi.fn().mockResolvedValueOnce([pinnedSession]),
|
|
||||||
getSession: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const { workbench, updateQueryString } = createWorkbench(
|
|
||||||
'?sessionId=from-url'
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await resolveInitialSession({
|
|
||||||
sessionService,
|
|
||||||
doc,
|
|
||||||
workbench,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBe(pinnedSession);
|
|
||||||
expect(updateQueryString).toHaveBeenCalledWith(
|
|
||||||
{ sessionId: undefined },
|
|
||||||
{ replace: true }
|
|
||||||
);
|
|
||||||
expect(sessionService.getSession).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('loads session from url when no pinned session', async () => {
|
|
||||||
const sessionFromUrl = {
|
|
||||||
sessionId: 'url-session',
|
|
||||||
pinned: false,
|
|
||||||
} as CopilotChatHistoryFragment;
|
|
||||||
|
|
||||||
const sessionService: SessionService = {
|
|
||||||
getSessions: vi.fn().mockResolvedValueOnce([]),
|
|
||||||
getSession: vi.fn().mockResolvedValueOnce(sessionFromUrl),
|
|
||||||
};
|
|
||||||
|
|
||||||
const { workbench, updateQueryString } = createWorkbench(
|
|
||||||
'?sessionId=url-session'
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await resolveInitialSession({
|
|
||||||
sessionService,
|
|
||||||
doc,
|
|
||||||
workbench,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBe(sessionFromUrl);
|
|
||||||
expect(sessionService.getSession).toHaveBeenCalledWith(
|
|
||||||
doc.workspace.id,
|
|
||||||
'url-session'
|
|
||||||
);
|
|
||||||
expect(updateQueryString).toHaveBeenCalledWith(
|
|
||||||
{ sessionId: undefined },
|
|
||||||
{ replace: true }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('falls back to latest doc session', async () => {
|
|
||||||
const docSession = {
|
|
||||||
sessionId: 'doc-session',
|
|
||||||
pinned: false,
|
|
||||||
} as CopilotChatHistoryFragment;
|
|
||||||
|
|
||||||
const sessionService: SessionService = {
|
|
||||||
getSessions: vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValueOnce([])
|
|
||||||
.mockResolvedValueOnce([docSession]),
|
|
||||||
getSession: vi.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const { workbench } = createWorkbench('');
|
|
||||||
|
|
||||||
const result = await resolveInitialSession({
|
|
||||||
sessionService,
|
|
||||||
doc,
|
|
||||||
workbench,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBe(docSession);
|
|
||||||
expect(sessionService.getSessions).toHaveBeenCalledWith(
|
|
||||||
doc.workspace.id,
|
|
||||||
doc.id,
|
|
||||||
{ action: false, fork: false, limit: 1 }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null when url session is missing', async () => {
|
|
||||||
const sessionService: SessionService = {
|
|
||||||
getSessions: vi.fn().mockResolvedValueOnce([]),
|
|
||||||
getSession: vi.fn().mockResolvedValueOnce(null),
|
|
||||||
};
|
|
||||||
|
|
||||||
const { workbench } = createWorkbench('?sessionId=missing');
|
|
||||||
|
|
||||||
const result = await resolveInitialSession({
|
|
||||||
sessionService,
|
|
||||||
doc,
|
|
||||||
workbench,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
/* eslint-disable rxjs/finnish */
|
|
||||||
import type { CopilotChatHistoryFragment } from '@affine/graphql';
|
|
||||||
|
|
||||||
type SessionListOptions = {
|
|
||||||
pinned?: boolean;
|
|
||||||
action?: boolean;
|
|
||||||
fork?: boolean;
|
|
||||||
limit?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface SessionService {
|
|
||||||
getSessions: (
|
|
||||||
workspaceId: string,
|
|
||||||
docId?: string,
|
|
||||||
options?: SessionListOptions
|
|
||||||
) => Promise<CopilotChatHistoryFragment[] | null | undefined>;
|
|
||||||
getSession: (
|
|
||||||
workspaceId: string,
|
|
||||||
sessionId: string
|
|
||||||
) => Promise<CopilotChatHistoryFragment | null | undefined>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkbenchLike {
|
|
||||||
location$: {
|
|
||||||
value: {
|
|
||||||
search: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
activeView$: {
|
|
||||||
value: {
|
|
||||||
updateQueryString: (
|
|
||||||
patch: Record<string, unknown>,
|
|
||||||
options?: { replace?: boolean }
|
|
||||||
) => void;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DocLike {
|
|
||||||
id: string;
|
|
||||||
workspace: {
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatContentKeySession {
|
|
||||||
sessionId?: string | null;
|
|
||||||
docId?: string | null;
|
|
||||||
messages?: readonly unknown[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TabSession = {
|
|
||||||
sessionId: string;
|
|
||||||
docId?: string | null;
|
|
||||||
messages?: readonly unknown[] | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const shouldResetChatPanelOnUserInfoChange = ({
|
|
||||||
previousUserId,
|
|
||||||
nextUserId,
|
|
||||||
}: {
|
|
||||||
previousUserId?: string | null;
|
|
||||||
nextUserId?: string | null;
|
|
||||||
}) => {
|
|
||||||
return previousUserId !== undefined && previousUserId !== nextUserId;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getChatContentKey = ({
|
|
||||||
docId,
|
|
||||||
hasPinned,
|
|
||||||
isGenerating,
|
|
||||||
previousSessionDocId,
|
|
||||||
previousSessionId,
|
|
||||||
session,
|
|
||||||
}: {
|
|
||||||
docId?: string | null;
|
|
||||||
hasPinned: boolean;
|
|
||||||
isGenerating?: boolean;
|
|
||||||
previousSessionDocId?: string | null;
|
|
||||||
previousSessionId?: string | null;
|
|
||||||
session?: ChatContentKeySession | null;
|
|
||||||
}) => {
|
|
||||||
const fallbackKey = docId ?? 'chat-panel';
|
|
||||||
const sessionId = session?.sessionId;
|
|
||||||
if (!sessionId) {
|
|
||||||
return fallbackKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionDocId = session.docId ?? docId ?? null;
|
|
||||||
const hasSessionHistory = !!session.messages?.length;
|
|
||||||
const shouldPreserveTransientMessages = isGenerating && !hasSessionHistory;
|
|
||||||
const sessionSwitchedWithinDoc = !!(
|
|
||||||
previousSessionId &&
|
|
||||||
previousSessionId !== sessionId &&
|
|
||||||
previousSessionDocId &&
|
|
||||||
sessionDocId &&
|
|
||||||
previousSessionDocId === sessionDocId &&
|
|
||||||
sessionDocId === docId
|
|
||||||
);
|
|
||||||
|
|
||||||
return hasPinned ||
|
|
||||||
hasSessionHistory ||
|
|
||||||
(sessionSwitchedWithinDoc && !shouldPreserveTransientMessages)
|
|
||||||
? sessionId
|
|
||||||
: fallbackKey;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isSessionAvailableInDocPanel = (
|
|
||||||
session: TabSession,
|
|
||||||
docId?: string | null
|
|
||||||
) => {
|
|
||||||
return !session.docId || session.docId === docId;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const filterDocPanelTabs = <T extends TabSession>(
|
|
||||||
sessions: T[],
|
|
||||||
docId?: string | null
|
|
||||||
) => {
|
|
||||||
return sessions.filter(session =>
|
|
||||||
isSessionAvailableInDocPanel(session, docId)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const hasSessionMessages = (
|
|
||||||
session?: Pick<TabSession, 'messages'> | null
|
|
||||||
) => {
|
|
||||||
return !!session?.messages?.length;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const canCreateNewDocPanelSession = ({
|
|
||||||
hasContextMessages,
|
|
||||||
session,
|
|
||||||
status,
|
|
||||||
}: {
|
|
||||||
hasContextMessages: boolean;
|
|
||||||
session?: Pick<TabSession, 'messages'> | null;
|
|
||||||
status?: string | null;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
(hasContextMessages || hasSessionMessages(session)) &&
|
|
||||||
status !== 'loading' &&
|
|
||||||
status !== 'transmitting'
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSessionIdFromUrl = (workbench?: WorkbenchLike | null) => {
|
|
||||||
if (!workbench) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const searchParams = new URLSearchParams(workbench.location$.value.search);
|
|
||||||
const sessionId = searchParams.get('sessionId');
|
|
||||||
if (sessionId) {
|
|
||||||
workbench.activeView$.value.updateQueryString(
|
|
||||||
{ sessionId: undefined },
|
|
||||||
{ replace: true }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return sessionId ?? undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const resolveInitialSession = async ({
|
|
||||||
sessionService,
|
|
||||||
doc,
|
|
||||||
workbench,
|
|
||||||
}: {
|
|
||||||
sessionService?: SessionService | null;
|
|
||||||
doc?: DocLike | null;
|
|
||||||
workbench?: WorkbenchLike | null;
|
|
||||||
}): Promise<CopilotChatHistoryFragment | null | undefined> => {
|
|
||||||
if (!sessionService || !doc) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = getSessionIdFromUrl(workbench);
|
|
||||||
|
|
||||||
const pinSessions = await sessionService.getSessions(
|
|
||||||
doc.workspace.id,
|
|
||||||
undefined,
|
|
||||||
{
|
|
||||||
pinned: true,
|
|
||||||
limit: 1,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (Array.isArray(pinSessions) && pinSessions[0]) {
|
|
||||||
return pinSessions[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionId) {
|
|
||||||
const session = await sessionService.getSession(
|
|
||||||
doc.workspace.id,
|
|
||||||
sessionId
|
|
||||||
);
|
|
||||||
return session ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const docSessions = await sessionService.getSessions(
|
|
||||||
doc.workspace.id,
|
|
||||||
doc.id,
|
|
||||||
{
|
|
||||||
action: false,
|
|
||||||
fork: false,
|
|
||||||
limit: 1,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return docSessions?.[0] ?? null;
|
|
||||||
};
|
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import { useConfirmModal } from '@affine/component';
|
import { useConfirmModal } from '@affine/component';
|
||||||
import { AIProvider } from '@affine/core/blocksuite/ai';
|
|
||||||
import type { AppSidebarConfig } from '@affine/core/blocksuite/ai/chat-panel/chat-config';
|
|
||||||
import {
|
import {
|
||||||
AIChatContent,
|
AIAppEvents,
|
||||||
type ChatContextValue,
|
AIChatRuntime,
|
||||||
} from '@affine/core/blocksuite/ai/components/ai-chat-content';
|
createAIRequestService,
|
||||||
import type { ChatStatus } from '@affine/core/blocksuite/ai/components/ai-chat-messages';
|
DocAIChatSessionStrategy,
|
||||||
import type { AIChatToolbar } from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
|
useAIChatElement,
|
||||||
|
useAIChatRuntime,
|
||||||
|
} from '@affine/core/blocksuite/ai';
|
||||||
|
import type { AppSidebarConfig } from '@affine/core/blocksuite/ai/chat-panel/chat-config';
|
||||||
|
import { AIChatContent } from '@affine/core/blocksuite/ai/components/ai-chat-content';
|
||||||
import {
|
import {
|
||||||
AIChatTabs,
|
AIChatTabs,
|
||||||
|
AIChatToolbar,
|
||||||
configureAIChatToolbar,
|
configureAIChatToolbar,
|
||||||
getOrCreateAIChatToolbar,
|
|
||||||
} from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
|
} from '@affine/core/blocksuite/ai/components/ai-chat-toolbar';
|
||||||
import { createPlaygroundModal } from '@affine/core/blocksuite/ai/components/playground/modal';
|
import { createPlaygroundModal } from '@affine/core/blocksuite/ai/components/playground/modal';
|
||||||
import { registerAIAppEffects } from '@affine/core/blocksuite/ai/effects/app';
|
import { registerAIAppEffects } from '@affine/core/blocksuite/ai/effects/app';
|
||||||
@@ -24,52 +26,55 @@ import {
|
|||||||
AIToolsConfigService,
|
AIToolsConfigService,
|
||||||
} from '@affine/core/modules/ai-button';
|
} from '@affine/core/modules/ai-button';
|
||||||
import { AIModelService } from '@affine/core/modules/ai-button/services/models';
|
import { AIModelService } from '@affine/core/modules/ai-button/services/models';
|
||||||
import { ServerService, SubscriptionService } from '@affine/core/modules/cloud';
|
import {
|
||||||
|
EventSourceService,
|
||||||
|
GraphQLService,
|
||||||
|
ServerService,
|
||||||
|
SubscriptionService,
|
||||||
|
} from '@affine/core/modules/cloud';
|
||||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||||
import { useSignalValue } from '@affine/core/modules/doc-info/utils';
|
import { useSignalValue } from '@affine/core/modules/doc-info/utils';
|
||||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||||
import { PeekViewService } from '@affine/core/modules/peek-view';
|
import { PeekViewService } from '@affine/core/modules/peek-view';
|
||||||
import { AppThemeService } from '@affine/core/modules/theme';
|
import { AppThemeService } from '@affine/core/modules/theme';
|
||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import type {
|
|
||||||
ContextEmbedStatus,
|
|
||||||
CopilotChatHistoryFragment,
|
|
||||||
UpdateChatSessionInput,
|
|
||||||
} from '@affine/graphql';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference';
|
import { RefNodeSlotsProvider } from '@blocksuite/affine/inlines/reference';
|
||||||
import { DocModeProvider } from '@blocksuite/affine/shared/services';
|
import { DocModeProvider } from '@blocksuite/affine/shared/services';
|
||||||
import { createSignalFromObservable } from '@blocksuite/affine/shared/utils';
|
import { createSignalFromObservable } from '@blocksuite/affine/shared/utils';
|
||||||
|
import type { Store } from '@blocksuite/affine/store';
|
||||||
import { CenterPeekIcon, Logo1Icon } from '@blocksuite/icons/rc';
|
import { CenterPeekIcon, Logo1Icon } from '@blocksuite/icons/rc';
|
||||||
import type { Signal } from '@preact/signals-core';
|
import type { Signal } from '@preact/signals-core';
|
||||||
import { useFramework, useService } from '@toeverything/infra';
|
import { useFramework, useService } from '@toeverything/infra';
|
||||||
import { html } from 'lit';
|
import { html } from 'lit';
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
|
||||||
createSessionDeleteHandler,
|
|
||||||
useAIChatOpenTabs,
|
|
||||||
} from '../../chat-panel-utils';
|
|
||||||
import * as styles from './chat.css';
|
import * as styles from './chat.css';
|
||||||
import {
|
|
||||||
canCreateNewDocPanelSession,
|
|
||||||
filterDocPanelTabs,
|
|
||||||
getChatContentKey,
|
|
||||||
isSessionAvailableInDocPanel,
|
|
||||||
resolveInitialSession,
|
|
||||||
shouldResetChatPanelOnUserInfoChange,
|
|
||||||
type WorkbenchLike,
|
|
||||||
} from './chat-panel-session';
|
|
||||||
|
|
||||||
registerAIAppEffects();
|
registerAIAppEffects();
|
||||||
|
|
||||||
|
const shouldResetChatPanelOnUserInfoChange = ({
|
||||||
|
previousUserId,
|
||||||
|
nextUserId,
|
||||||
|
}: {
|
||||||
|
previousUserId?: string | null;
|
||||||
|
nextUserId?: string | null;
|
||||||
|
}) => previousUserId !== undefined && previousUserId !== nextUserId;
|
||||||
|
|
||||||
export interface SidebarTabProps {
|
export interface SidebarTabProps {
|
||||||
editor: AffineEditorContainer | null;
|
editor: AffineEditorContainer | null;
|
||||||
|
doc: Store;
|
||||||
onLoad?: ((component: HTMLElement) => void) | null;
|
onLoad?: ((component: HTMLElement) => void) | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
export const EditorChatPanel = ({
|
||||||
|
editor,
|
||||||
|
doc: fallbackDoc,
|
||||||
|
onLoad,
|
||||||
|
}: SidebarTabProps) => {
|
||||||
const framework = useFramework();
|
const framework = useFramework();
|
||||||
|
const graphqlService = useService(GraphQLService);
|
||||||
|
const eventSourceService = useService(EventSourceService);
|
||||||
const workbench = useService(WorkbenchService).workbench;
|
const workbench = useService(WorkbenchService).workbench;
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
@@ -89,81 +94,57 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
} = useAIChatConfig();
|
} = useAIChatConfig();
|
||||||
const playgroundVisible = useSignalValue(playgroundConfig.visible) ?? false;
|
const playgroundVisible = useSignalValue(playgroundConfig.visible) ?? false;
|
||||||
|
|
||||||
const [session, setSession] = useState<
|
|
||||||
CopilotChatHistoryFragment | null | undefined
|
|
||||||
>(undefined);
|
|
||||||
const [embeddingProgress, setEmbeddingProgress] = useState<[number, number]>([
|
|
||||||
0, 0,
|
|
||||||
]);
|
|
||||||
const [status, setStatus] = useState<ChatStatus>('idle');
|
|
||||||
const [hasPinned, setHasPinned] = useState(false);
|
|
||||||
|
|
||||||
const [chatContent, setChatContent] = useState<AIChatContent | null>(null);
|
|
||||||
const [chatToolbar, setChatToolbar] = useState<AIChatToolbar | null>(null);
|
|
||||||
const [chatTabs, setChatTabs] = useState<AIChatTabs | null>(null);
|
|
||||||
const [isBodyProvided, setIsBodyProvided] = useState(false);
|
const [isBodyProvided, setIsBodyProvided] = useState(false);
|
||||||
const [isHeaderProvided, setIsHeaderProvided] = useState(false);
|
const [isHeaderProvided, setIsHeaderProvided] = useState(false);
|
||||||
const chatContainerRef = useRef<HTMLDivElement | null>(null);
|
const chatContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const chatToolbarContainerRef = useRef<HTMLDivElement | null>(null);
|
const chatToolbarContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const chatTabsContainerRef = useRef<HTMLDivElement | null>(null);
|
const chatTabsContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const contentKeyRef = useRef<string | null>(null);
|
|
||||||
const prevSessionIdRef = useRef<string | null>(null);
|
|
||||||
const prevSessionDocIdRef = useRef<string | null>(null);
|
|
||||||
const lastDocIdRef = useRef<string | null>(null);
|
|
||||||
const sessionLoadSeqRef = useRef(0);
|
|
||||||
const creatingSessionRef = useRef<{
|
|
||||||
docId: string;
|
|
||||||
promise: Promise<CopilotChatHistoryFragment | undefined>;
|
|
||||||
} | null>(null);
|
|
||||||
const creatingFreshSessionRef = useRef<{
|
|
||||||
docId: string;
|
|
||||||
promise: Promise<void>;
|
|
||||||
} | null>(null);
|
|
||||||
const userIdRef = useRef<string | null | undefined>(undefined);
|
const userIdRef = useRef<string | null | undefined>(undefined);
|
||||||
|
|
||||||
const doc = editor?.doc;
|
const doc = editor?.doc ?? fallbackDoc;
|
||||||
const host = editor?.host;
|
const host = editor?.host;
|
||||||
const workspaceId = doc?.workspace.id;
|
const workspaceId = doc?.workspace.id;
|
||||||
|
const requestService = useMemo(
|
||||||
const [sessionServiceReady, setSessionServiceReady] = useState(
|
() =>
|
||||||
() => !!AIProvider.session
|
createAIRequestService(
|
||||||
|
graphqlService.gql,
|
||||||
|
eventSourceService.eventSource
|
||||||
|
),
|
||||||
|
[eventSourceService.eventSource, graphqlService.gql]
|
||||||
);
|
);
|
||||||
const [hasContextMessages, setHasContextMessages] = useState(false);
|
|
||||||
|
const [pendingSessionId] = useState(() => {
|
||||||
|
const searchParams = new URLSearchParams(workbench.location$.value.search);
|
||||||
|
return searchParams.get('sessionId') ?? undefined;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionServiceReady) return;
|
if (pendingSessionId) {
|
||||||
if (AIProvider.session) {
|
workbench.activeView$.value.updateQueryString(
|
||||||
setSessionServiceReady(true);
|
{ sessionId: undefined },
|
||||||
return;
|
{ replace: true }
|
||||||
}
|
|
||||||
const sub = AIProvider.slots.sessionReady.subscribe(ready => {
|
|
||||||
if (ready) setSessionServiceReady(true);
|
|
||||||
});
|
|
||||||
return () => sub.unsubscribe();
|
|
||||||
}, [sessionServiceReady]);
|
|
||||||
|
|
||||||
const loadSession = useMemo(() => {
|
|
||||||
if (!sessionServiceReady || !workspaceId) return null;
|
|
||||||
const sessionService = AIProvider.session;
|
|
||||||
if (!sessionService) return null;
|
|
||||||
return async (
|
|
||||||
sessionId: string
|
|
||||||
): Promise<CopilotChatHistoryFragment | null | undefined> =>
|
|
||||||
sessionService.getSession(workspaceId, sessionId);
|
|
||||||
}, [sessionServiceReady, workspaceId]);
|
|
||||||
|
|
||||||
const { openTabs, setOpenTabs } =
|
|
||||||
useAIChatOpenTabs<CopilotChatHistoryFragment>(loadSession);
|
|
||||||
const visibleOpenTabs = useMemo(
|
|
||||||
() => filterDocPanelTabs(openTabs, doc?.id),
|
|
||||||
[doc?.id, openTabs]
|
|
||||||
);
|
);
|
||||||
const canCreateNewSession = canCreateNewDocPanelSession({
|
}
|
||||||
hasContextMessages,
|
}, [pendingSessionId, workbench]);
|
||||||
session,
|
|
||||||
status,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const runtime = useMemo(() => {
|
||||||
|
if (!doc || !workspaceId) return null;
|
||||||
|
return new AIChatRuntime({
|
||||||
|
request: requestService,
|
||||||
|
scope: {
|
||||||
|
kind: 'doc',
|
||||||
|
workspaceId,
|
||||||
|
docId: doc.id,
|
||||||
|
pendingSessionId,
|
||||||
|
},
|
||||||
|
strategy: new DocAIChatSessionStrategy(),
|
||||||
|
});
|
||||||
|
}, [doc, pendingSessionId, requestService, workspaceId]);
|
||||||
|
const snapshot = useAIChatRuntime(runtime);
|
||||||
|
const session =
|
||||||
|
snapshot?.sessions.find(
|
||||||
|
item => item.sessionId === snapshot.activeSessionId
|
||||||
|
) ?? null;
|
||||||
const appSidebarConfig = useMemo<AppSidebarConfig>(() => {
|
const appSidebarConfig = useMemo<AppSidebarConfig>(() => {
|
||||||
return {
|
return {
|
||||||
getWidth: () =>
|
getWidth: () =>
|
||||||
@@ -188,159 +169,14 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
return cleanup;
|
return cleanup;
|
||||||
}, [appSidebarConfig]);
|
}, [appSidebarConfig]);
|
||||||
|
|
||||||
const resetPanel = useCallback(() => {
|
|
||||||
sessionLoadSeqRef.current += 1;
|
|
||||||
setSession(undefined);
|
|
||||||
setEmbeddingProgress([0, 0]);
|
|
||||||
setHasPinned(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const initPanel = useCallback(async () => {
|
|
||||||
const requestSeq = ++sessionLoadSeqRef.current;
|
|
||||||
try {
|
|
||||||
const nextSession = await resolveInitialSession({
|
|
||||||
sessionService: AIProvider.session ?? undefined,
|
|
||||||
doc,
|
|
||||||
workbench: workbench as WorkbenchLike,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (requestSeq !== sessionLoadSeqRef.current) return;
|
|
||||||
if (nextSession === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSession(nextSession);
|
|
||||||
setHasPinned(!!nextSession?.pinned);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}, [doc, workbench]);
|
|
||||||
|
|
||||||
const createSession = useCallback(
|
|
||||||
async (options: Partial<BlockSuitePresets.AICreateSessionOptions> = {}) => {
|
|
||||||
if (session || !AIProvider.session || !doc) {
|
|
||||||
return session ?? undefined;
|
|
||||||
}
|
|
||||||
if (creatingSessionRef.current?.docId === doc.id) {
|
|
||||||
return creatingSessionRef.current.promise;
|
|
||||||
}
|
|
||||||
const requestSeq = ++sessionLoadSeqRef.current;
|
|
||||||
let promise: Promise<CopilotChatHistoryFragment | undefined>;
|
|
||||||
promise = AIProvider.session
|
|
||||||
.createSessionWithHistory({
|
|
||||||
docId: doc.id,
|
|
||||||
workspaceId: doc.workspace.id,
|
|
||||||
promptName: 'Chat With AFFiNE AI',
|
|
||||||
reuseLatestChat: false,
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
.then(nextSession => {
|
|
||||||
if (requestSeq !== sessionLoadSeqRef.current) return undefined;
|
|
||||||
setSession(nextSession ?? null);
|
|
||||||
setHasPinned(!!nextSession?.pinned);
|
|
||||||
return nextSession ?? undefined;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (creatingSessionRef.current?.promise === promise) {
|
|
||||||
creatingSessionRef.current = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
creatingSessionRef.current = { docId: doc.id, promise };
|
|
||||||
return promise;
|
|
||||||
},
|
|
||||||
[doc, session]
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateSession = useCallback(
|
|
||||||
async (options: UpdateChatSessionInput) => {
|
|
||||||
if (!AIProvider.session || !doc) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const requestSeq = ++sessionLoadSeqRef.current;
|
|
||||||
await AIProvider.session.updateSession(options);
|
|
||||||
const nextSession = await AIProvider.session.getSession(
|
|
||||||
doc.workspace.id,
|
|
||||||
options.sessionId
|
|
||||||
);
|
|
||||||
if (requestSeq !== sessionLoadSeqRef.current) return undefined;
|
|
||||||
setSession(nextSession ?? null);
|
|
||||||
setHasPinned(!!nextSession?.pinned);
|
|
||||||
return nextSession ?? undefined;
|
|
||||||
},
|
|
||||||
[doc]
|
|
||||||
);
|
|
||||||
|
|
||||||
const newSession = useCallback(async () => {
|
|
||||||
if (!canCreateNewSession) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (doc && creatingFreshSessionRef.current?.docId === doc.id) {
|
|
||||||
return creatingFreshSessionRef.current.promise;
|
|
||||||
}
|
|
||||||
resetPanel();
|
|
||||||
const requestSeq = sessionLoadSeqRef.current;
|
|
||||||
setSession(null);
|
|
||||||
setHasContextMessages(false);
|
|
||||||
|
|
||||||
if (!AIProvider.session || !doc) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let promise: Promise<void>;
|
|
||||||
promise = AIProvider.session
|
|
||||||
.createSessionWithHistory({
|
|
||||||
docId: doc.id,
|
|
||||||
workspaceId: doc.workspace.id,
|
|
||||||
promptName: 'Chat With AFFiNE AI',
|
|
||||||
reuseLatestChat: false,
|
|
||||||
})
|
|
||||||
.then(nextSession => {
|
|
||||||
if (requestSeq === sessionLoadSeqRef.current) {
|
|
||||||
setSession(nextSession ?? null);
|
|
||||||
setHasPinned(!!nextSession?.pinned);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(console.error)
|
|
||||||
.finally(() => {
|
|
||||||
if (creatingFreshSessionRef.current?.promise === promise) {
|
|
||||||
creatingFreshSessionRef.current = null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
creatingFreshSessionRef.current = { docId: doc.id, promise };
|
|
||||||
return promise;
|
|
||||||
}, [canCreateNewSession, doc, resetPanel]);
|
|
||||||
|
|
||||||
const openSession = useCallback(
|
const openSession = useCallback(
|
||||||
async (sessionId: string) => {
|
async (sessionId: string) => {
|
||||||
if (session?.sessionId === sessionId || !AIProvider.session || !doc) {
|
if (session?.sessionId === sessionId || !runtime) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const requestSeq = ++sessionLoadSeqRef.current;
|
await runtime.dispatch({ type: 'openSession', sessionId });
|
||||||
try {
|
|
||||||
const nextSession = await AIProvider.session.getSession(
|
|
||||||
doc.workspace.id,
|
|
||||||
sessionId
|
|
||||||
);
|
|
||||||
if (requestSeq !== sessionLoadSeqRef.current) return;
|
|
||||||
if (!nextSession) {
|
|
||||||
// Drop stale tab if session no longer exists.
|
|
||||||
setOpenTabs(prev => prev.filter(tab => tab.sessionId !== sessionId));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!isSessionAvailableInDocPanel(nextSession, doc.id)) {
|
|
||||||
setOpenTabs([]);
|
|
||||||
workbench.open(`/${nextSession.docId}?sessionId=${sessionId}`, {
|
|
||||||
at: 'active',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSession(nextSession);
|
|
||||||
setHasPinned(!!nextSession.pinned);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[doc, session?.sessionId, setOpenTabs, workbench]
|
[runtime, session?.sessionId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const openDoc = useCallback(
|
const openDoc = useCallback(
|
||||||
@@ -361,159 +197,47 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
workbench.open(`/${docId}`, { at: 'active' });
|
workbench.open(`/${docId}`, { at: 'active' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setOpenTabs([]);
|
|
||||||
workbench.open(`/${docId}?sessionId=${sessionId}`, { at: 'active' });
|
workbench.open(`/${docId}?sessionId=${sessionId}`, { at: 'active' });
|
||||||
},
|
},
|
||||||
[
|
[doc, openSession, session?.pinned, session?.sessionId, workbench]
|
||||||
doc,
|
|
||||||
openSession,
|
|
||||||
session?.pinned,
|
|
||||||
session?.sessionId,
|
|
||||||
setOpenTabs,
|
|
||||||
workbench,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const deleteSession = useMemo(
|
|
||||||
() =>
|
|
||||||
createSessionDeleteHandler({
|
|
||||||
t,
|
|
||||||
notificationService,
|
|
||||||
canDeleteSession: () => Boolean(AIProvider.histories),
|
|
||||||
cleanupSession: async sessionToDelete => {
|
|
||||||
await AIProvider.histories?.cleanup(
|
|
||||||
sessionToDelete.workspaceId,
|
|
||||||
sessionToDelete.docId || undefined,
|
|
||||||
[sessionToDelete.sessionId]
|
|
||||||
);
|
|
||||||
},
|
|
||||||
isActiveSession: sessionToDelete =>
|
|
||||||
sessionToDelete.sessionId === session?.sessionId,
|
|
||||||
onActiveSessionDeleted: () => {
|
|
||||||
resetPanel();
|
|
||||||
setSession(null);
|
|
||||||
setHasContextMessages(false);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[notificationService, resetPanel, session?.sessionId, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const closeTab = useCallback(
|
|
||||||
(sessionId: string) => {
|
|
||||||
let fallback: CopilotChatHistoryFragment | undefined;
|
|
||||||
setOpenTabs(prev => {
|
|
||||||
const idx = prev.findIndex(tab => tab.sessionId === sessionId);
|
|
||||||
if (idx === -1) return prev;
|
|
||||||
const next = prev.filter(tab => tab.sessionId !== sessionId);
|
|
||||||
const visibleNext = filterDocPanelTabs(next, doc?.id);
|
|
||||||
fallback = visibleNext[idx] ?? visibleNext[idx - 1] ?? visibleNext[0];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
if (session?.sessionId !== sessionId) return;
|
|
||||||
if (fallback) {
|
|
||||||
openSession(fallback.sessionId).catch(console.error);
|
|
||||||
} else {
|
|
||||||
resetPanel();
|
|
||||||
setSession(null);
|
|
||||||
setHasContextMessages(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[doc?.id, openSession, resetPanel, session?.sessionId, setOpenTabs]
|
|
||||||
);
|
|
||||||
|
|
||||||
const togglePin = useCallback(async () => {
|
|
||||||
const pinned = !session?.pinned;
|
|
||||||
setHasPinned(true);
|
|
||||||
if (!session) {
|
|
||||||
await createSession({ pinned });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSession(prev => (prev ? { ...prev, pinned } : prev));
|
|
||||||
await updateSession({
|
|
||||||
sessionId: session.sessionId,
|
|
||||||
pinned,
|
|
||||||
});
|
|
||||||
}, [createSession, session, updateSession]);
|
|
||||||
|
|
||||||
const rebindSession = useCallback(async () => {
|
|
||||||
if (!session || !doc) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (session.docId !== doc.id) {
|
|
||||||
await updateSession({
|
|
||||||
sessionId: session.sessionId,
|
|
||||||
docId: doc.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [doc, session, updateSession]);
|
|
||||||
|
|
||||||
const onEmbeddingProgressChange = useCallback(
|
|
||||||
(count: Record<ContextEmbedStatus, number>) => {
|
|
||||||
const total = count.finished + count.processing + count.failed;
|
|
||||||
setEmbeddingProgress([count.finished, total]);
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const onContextChange = useCallback(
|
|
||||||
(context: Partial<ChatContextValue>) => {
|
|
||||||
if (context.status) {
|
|
||||||
setStatus(context.status);
|
|
||||||
}
|
|
||||||
if (context.messages) {
|
|
||||||
setHasContextMessages(context.messages.length > 0);
|
|
||||||
}
|
|
||||||
if (context.status === 'success') {
|
|
||||||
rebindSession().catch(console.error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[rebindSession]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session !== undefined) {
|
const navigationRequest = snapshot?.navigationRequest;
|
||||||
|
if (!navigationRequest) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (chatContent) {
|
workbench.open(
|
||||||
chatContent.remove();
|
`/${navigationRequest.docId}?sessionId=${navigationRequest.sessionId}`,
|
||||||
setChatContent(null);
|
{ at: 'active' }
|
||||||
}
|
|
||||||
if (chatToolbar) {
|
|
||||||
chatToolbar.remove();
|
|
||||||
setChatToolbar(null);
|
|
||||||
}
|
|
||||||
if (chatTabs) {
|
|
||||||
chatTabs.remove();
|
|
||||||
setChatTabs(null);
|
|
||||||
}
|
|
||||||
}, [chatContent, chatTabs, chatToolbar, session]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!session?.sessionId) return;
|
|
||||||
setOpenTabs(prev => {
|
|
||||||
const existing = prev.findIndex(
|
|
||||||
tab => tab.sessionId === session.sessionId
|
|
||||||
);
|
);
|
||||||
if (existing !== -1) {
|
}, [snapshot?.navigationRequest, workbench]);
|
||||||
if (prev[existing] === session) return prev;
|
|
||||||
const next = prev.slice();
|
const deleteSession = useCallback(
|
||||||
next[existing] = session;
|
async (sessionToDelete: BlockSuitePresets.AIRecentSession) => {
|
||||||
return next;
|
if (!runtime) return;
|
||||||
}
|
const confirm = await notificationService.confirm({
|
||||||
return [...prev, session];
|
title: t['com.affine.ai.chat-panel.session.delete.confirm.title'](),
|
||||||
|
message: t['com.affine.ai.chat-panel.session.delete.confirm.message'](),
|
||||||
|
confirmText: t['Delete'](),
|
||||||
|
cancelText: t['Cancel'](),
|
||||||
});
|
});
|
||||||
}, [session, setOpenTabs]);
|
if (!confirm) return;
|
||||||
|
await runtime.dispatch({
|
||||||
|
type: 'deleteSession',
|
||||||
|
sessionId: sessionToDelete.sessionId,
|
||||||
|
});
|
||||||
|
notificationService.toast(
|
||||||
|
t['com.affine.ai.chat-panel.session.delete.toast.success'](),
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[notificationService, runtime, t]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
userIdRef.current ??= AIAppEvents.userInfo.value?.id ?? null;
|
||||||
Promise.resolve(AIProvider.userInfo)
|
const subscription = AIAppEvents.userInfo.subscribe(userInfo => {
|
||||||
.then(userInfo => {
|
|
||||||
if (!disposed && userIdRef.current === undefined) {
|
|
||||||
userIdRef.current = userInfo?.id ?? null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
const subscription = AIProvider.slots.userInfo.subscribe(userInfo => {
|
|
||||||
const nextUserId = userInfo?.id ?? null;
|
const nextUserId = userInfo?.id ?? null;
|
||||||
const shouldReset = shouldResetChatPanelOnUserInfoChange({
|
const shouldReset = shouldResetChatPanelOnUserInfoChange({
|
||||||
previousUserId: userIdRef.current,
|
previousUserId: userIdRef.current,
|
||||||
@@ -523,91 +247,24 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
if (!shouldReset) {
|
if (!shouldReset) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resetPanel();
|
runtime?.dispatch({ type: 'initialize' }).catch(console.error);
|
||||||
initPanel().catch(console.error);
|
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
};
|
};
|
||||||
}, [initPanel, resetPanel]);
|
}, [runtime]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const docId = doc?.id;
|
|
||||||
if (!docId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
lastDocIdRef.current &&
|
|
||||||
lastDocIdRef.current !== docId &&
|
|
||||||
!session?.pinned
|
|
||||||
) {
|
|
||||||
resetPanel();
|
|
||||||
setHasContextMessages(false);
|
|
||||||
}
|
|
||||||
lastDocIdRef.current = docId;
|
|
||||||
}, [doc?.id, resetPanel, session?.pinned]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!doc || session !== undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (AIProvider.session) {
|
|
||||||
initPanel().catch(console.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const subscription = AIProvider.slots.sessionReady.subscribe(ready => {
|
|
||||||
if (!ready || session !== undefined) return;
|
|
||||||
initPanel().catch(console.error);
|
|
||||||
});
|
|
||||||
return () => subscription.unsubscribe();
|
|
||||||
}, [doc, initPanel, session]);
|
|
||||||
|
|
||||||
const contentKey = getChatContentKey({
|
|
||||||
docId: doc?.id,
|
|
||||||
hasPinned,
|
|
||||||
isGenerating: status === 'loading' || status === 'transmitting',
|
|
||||||
previousSessionDocId: prevSessionDocIdRef.current,
|
|
||||||
previousSessionId: prevSessionIdRef.current,
|
|
||||||
session,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (session?.sessionId) {
|
|
||||||
prevSessionIdRef.current = session.sessionId;
|
|
||||||
prevSessionDocIdRef.current = session.docId ?? doc?.id ?? null;
|
|
||||||
}
|
|
||||||
}, [doc?.id, session?.docId, session?.sessionId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!chatContent) {
|
|
||||||
contentKeyRef.current = contentKey;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (contentKeyRef.current && contentKeyRef.current !== contentKey) {
|
|
||||||
chatContent.remove();
|
|
||||||
setChatContent(null);
|
|
||||||
}
|
|
||||||
contentKeyRef.current = contentKey;
|
|
||||||
}, [chatContent, contentKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isBodyProvided || !chatContainerRef.current || !doc || !host) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (session === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = chatContent;
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
content = new AIChatContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const chatContent = useAIChatElement({
|
||||||
|
containerRef: chatContainerRef,
|
||||||
|
selector: 'ai-chat-content',
|
||||||
|
enabled: isBodyProvided && !!runtime && !!snapshot,
|
||||||
|
createElement: () => new AIChatContent(),
|
||||||
|
configureElement: content => {
|
||||||
|
if (!runtime || !snapshot) return;
|
||||||
content.host = host;
|
content.host = host;
|
||||||
content.session = session;
|
content.session = session;
|
||||||
content.createSession = createSession;
|
content.runtime = runtime;
|
||||||
|
content.runtimeSnapshot = snapshot;
|
||||||
content.workspaceId = doc.workspace.id;
|
content.workspaceId = doc.workspace.id;
|
||||||
content.docId = doc.id;
|
content.docId = doc.id;
|
||||||
content.reasoningConfig = reasoningConfig;
|
content.reasoningConfig = reasoningConfig;
|
||||||
@@ -627,116 +284,53 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
content.subscriptionService = framework.get(SubscriptionService);
|
content.subscriptionService = framework.get(SubscriptionService);
|
||||||
content.aiModelService = framework.get(AIModelService);
|
content.aiModelService = framework.get(AIModelService);
|
||||||
content.onAISubscribe = handleAISubscribe;
|
content.onAISubscribe = handleAISubscribe;
|
||||||
content.onEmbeddingProgressChange = onEmbeddingProgressChange;
|
|
||||||
content.onContextChange = onContextChange;
|
|
||||||
content.width = sidebarWidthSignal;
|
content.width = sidebarWidthSignal;
|
||||||
content.onOpenDoc = (docId: string, sessionId?: string) => {
|
content.onOpenDoc = (docId: string, sessionId?: string) => {
|
||||||
openDoc(docId, sessionId).catch(console.error);
|
openDoc(docId, sessionId).catch(console.error);
|
||||||
};
|
};
|
||||||
|
},
|
||||||
if (!chatContent) {
|
onElementReady: content => {
|
||||||
chatContainerRef.current.append(content);
|
|
||||||
setChatContent(content);
|
|
||||||
onLoad?.(content);
|
onLoad?.(content);
|
||||||
}
|
|
||||||
}, [
|
|
||||||
chatContent,
|
|
||||||
createSession,
|
|
||||||
doc,
|
|
||||||
docDisplayConfig,
|
|
||||||
framework,
|
|
||||||
handleAISubscribe,
|
|
||||||
host,
|
|
||||||
isBodyProvided,
|
|
||||||
notificationService,
|
|
||||||
onContextChange,
|
|
||||||
onEmbeddingProgressChange,
|
|
||||||
onLoad,
|
|
||||||
openDoc,
|
|
||||||
reasoningConfig,
|
|
||||||
searchMenuConfig,
|
|
||||||
session,
|
|
||||||
sidebarWidthSignal,
|
|
||||||
specs,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isHeaderProvided || !chatToolbarContainerRef.current || !doc) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (session === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tool = getOrCreateAIChatToolbar(chatToolbar);
|
|
||||||
configureAIChatToolbar(tool, {
|
|
||||||
session,
|
|
||||||
workspaceId: doc.workspace.id,
|
|
||||||
docId: doc.id,
|
|
||||||
status,
|
|
||||||
canCreateNewSession,
|
|
||||||
docDisplayConfig,
|
|
||||||
notificationService,
|
|
||||||
onNewSession: () => {
|
|
||||||
newSession().catch(console.error);
|
|
||||||
},
|
|
||||||
onTogglePin: togglePin,
|
|
||||||
onOpenSession: (sessionId: string) => {
|
|
||||||
openSession(sessionId).catch(console.error);
|
|
||||||
},
|
|
||||||
onOpenDoc: (docId: string, sessionId: string) => {
|
|
||||||
openDoc(docId, sessionId).catch(console.error);
|
|
||||||
},
|
|
||||||
onSessionDelete: (sessionToDelete: BlockSuitePresets.AIRecentSession) => {
|
|
||||||
deleteSession(sessionToDelete).catch(console.error);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!chatToolbar) {
|
useAIChatElement({
|
||||||
chatToolbarContainerRef.current.append(tool);
|
containerRef: chatToolbarContainerRef,
|
||||||
setChatToolbar(tool);
|
selector: 'ai-chat-toolbar',
|
||||||
}
|
enabled: isHeaderProvided && !!runtime && !!snapshot,
|
||||||
}, [
|
createElement: () => new AIChatToolbar(),
|
||||||
chatToolbar,
|
configureElement: tool => {
|
||||||
canCreateNewSession,
|
if (!runtime || !snapshot) return;
|
||||||
deleteSession,
|
configureAIChatToolbar(tool, {
|
||||||
doc,
|
|
||||||
docDisplayConfig,
|
|
||||||
isHeaderProvided,
|
|
||||||
newSession,
|
|
||||||
notificationService,
|
|
||||||
openDoc,
|
|
||||||
openSession,
|
|
||||||
session,
|
session,
|
||||||
status,
|
runtime,
|
||||||
togglePin,
|
runtimeSnapshot: snapshot,
|
||||||
]);
|
docId: doc.id,
|
||||||
|
docDisplayConfig,
|
||||||
|
notificationService,
|
||||||
|
onOpenDoc: (docId: string, sessionId: string) => {
|
||||||
|
openDoc(docId, sessionId).catch(console.error);
|
||||||
|
},
|
||||||
|
onSessionDelete: (
|
||||||
|
sessionToDelete: BlockSuitePresets.AIRecentSession
|
||||||
|
) => {
|
||||||
|
deleteSession(sessionToDelete).catch(console.error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useAIChatElement({
|
||||||
if (!chatTabsContainerRef.current || !doc) {
|
containerRef: chatTabsContainerRef,
|
||||||
return;
|
selector: 'ai-chat-tabs',
|
||||||
}
|
enabled: !!runtime && !!snapshot,
|
||||||
if (session === undefined) {
|
createElement: () => new AIChatTabs(),
|
||||||
return;
|
configureElement: tabs => {
|
||||||
}
|
if (!runtime || !snapshot) return;
|
||||||
|
tabs.runtime = runtime;
|
||||||
let tabs = chatTabs;
|
tabs.runtimeSnapshot = snapshot;
|
||||||
if (!tabs) {
|
},
|
||||||
tabs = new AIChatTabs();
|
});
|
||||||
chatTabsContainerRef.current.append(tabs);
|
|
||||||
setChatTabs(tabs);
|
|
||||||
}
|
|
||||||
tabs.sessions = visibleOpenTabs;
|
|
||||||
tabs.activeSessionId = session?.sessionId;
|
|
||||||
tabs.showDraftTab =
|
|
||||||
visibleOpenTabs.length === 0 && !session?.sessionId && !!doc;
|
|
||||||
tabs.onSelectTab = (sessionId: string) => {
|
|
||||||
openSession(sessionId).catch(console.error);
|
|
||||||
};
|
|
||||||
tabs.onCloseTab = (sessionId: string) => {
|
|
||||||
closeTab(sessionId);
|
|
||||||
};
|
|
||||||
}, [chatTabs, closeTab, doc, openSession, session, visibleOpenTabs]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor?.host || !chatContent) {
|
if (!editor?.host || !chatContent) {
|
||||||
@@ -766,8 +360,7 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
if (autoResized) {
|
if (autoResized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const subscription = AIProvider.slots.previewPanelOpenChange.subscribe(
|
const subscription = AIAppEvents.previewPanelOpenChange.subscribe(open => {
|
||||||
open => {
|
|
||||||
if (!open) {
|
if (!open) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -777,8 +370,7 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
workbench.setSidebarWidth(minSidebarWidth);
|
workbench.setSidebarWidth(minSidebarWidth);
|
||||||
setAutoResized(true);
|
setAutoResized(true);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
);
|
|
||||||
return () => {
|
return () => {
|
||||||
subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
};
|
};
|
||||||
@@ -843,14 +435,16 @@ export const EditorChatPanel = ({ editor, onLoad }: SidebarTabProps) => {
|
|||||||
chatTabsContainerRef.current = node;
|
chatTabsContainerRef.current = node;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isEmbedding =
|
const embeddingCount = snapshot?.composer.context.embeddingCount;
|
||||||
embeddingProgress[1] > 0 && embeddingProgress[0] < embeddingProgress[1];
|
const done = embeddingCount?.finished ?? 0;
|
||||||
const [done, total] = embeddingProgress;
|
const total =
|
||||||
const isInitialized = session !== undefined;
|
done + (embeddingCount?.processing ?? 0) + (embeddingCount?.failed ?? 0);
|
||||||
|
const isEmbedding = total > 0 && done < total;
|
||||||
|
const hasRuntimeSnapshot = !!snapshot;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
{!isInitialized ? (
|
{!hasRuntimeSnapshot ? (
|
||||||
<div className={styles.loadingContainer}>
|
<div className={styles.loadingContainer}>
|
||||||
<div className={styles.loading}>
|
<div className={styles.loading}>
|
||||||
<Logo1Icon className={styles.loadingIcon} />
|
<Logo1Icon className={styles.loadingIcon} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Scrollable } from '@affine/component';
|
import { Scrollable } from '@affine/component';
|
||||||
import { PageDetailLoading } from '@affine/component/page-detail-skeleton';
|
import { PageDetailLoading } from '@affine/component/page-detail-skeleton';
|
||||||
import { type AIChatParams, AIProvider } from '@affine/core/blocksuite/ai';
|
import { AIAppEvents, type AIChatParams } from '@affine/core/blocksuite/ai';
|
||||||
import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor';
|
import type { AffineEditorContainer } from '@affine/core/blocksuite/block-suite-editor';
|
||||||
import { EditorOutlineViewer } from '@affine/core/blocksuite/outline-viewer';
|
import { EditorOutlineViewer } from '@affine/core/blocksuite/outline-viewer';
|
||||||
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
|
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
|
||||||
@@ -137,12 +137,8 @@ function DocPeekPreviewEditor({
|
|||||||
// chat panel open is already handled in <DetailPageImpl />
|
// chat panel open is already handled in <DetailPageImpl />
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
disposables.push(
|
disposables.push(AIAppEvents.requestOpenWithChat.subscribe(openHandler));
|
||||||
AIProvider.slots.requestOpenWithChat.subscribe(openHandler)
|
disposables.push(AIAppEvents.requestSendWithChat.subscribe(openHandler));
|
||||||
);
|
|
||||||
disposables.push(
|
|
||||||
AIProvider.slots.requestSendWithChat.subscribe(openHandler)
|
|
||||||
);
|
|
||||||
return () => disposables.forEach(d => d.unsubscribe());
|
return () => disposables.forEach(d => d.unsubscribe());
|
||||||
}, [doc, peekView, workbench, workspace.id]);
|
}, [doc, peekView, workbench, workspace.id]);
|
||||||
|
|
||||||
|
|||||||
@@ -226,8 +226,8 @@ export class SettingsPanelUtils {
|
|||||||
timeout: number,
|
timeout: number,
|
||||||
status = 'synced'
|
status = 'synced'
|
||||||
) {
|
) {
|
||||||
await expect(async () => {
|
|
||||||
await cleanupWorkspace(page.url().split('/').slice(-2)[0] || '');
|
await cleanupWorkspace(page.url().split('/').slice(-2)[0] || '');
|
||||||
|
await expect(async () => {
|
||||||
await this.openSettingsPanel(page);
|
await this.openSettingsPanel(page);
|
||||||
const title = page.getByTestId('embedding-progress-title');
|
const title = page.getByTestId('embedding-progress-title');
|
||||||
// oxlint-disable-next-line prefer-dom-node-dataset
|
// oxlint-disable-next-line prefer-dom-node-dataset
|
||||||
|
|||||||
Reference in New Issue
Block a user