mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-05 03:19:52 +08:00
feat: improve copilot plugin (#3459)
This commit is contained in:
@@ -76,7 +76,7 @@ await Promise.all(
|
|||||||
globalThis.__pluginPackageJson__.push(packageJson);
|
globalThis.__pluginPackageJson__.push(packageJson);
|
||||||
logger.debug(`registering plugin ${pluginName}`);
|
logger.debug(`registering plugin ${pluginName}`);
|
||||||
logger.debug(`package.json: ${packageJson}`);
|
logger.debug(`package.json: ${packageJson}`);
|
||||||
if (!release) {
|
if (!release && process.env.NODE_ENV === 'production') {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
const pluginCompartment = new Compartment(createGlobalThis(), {});
|
const pluginCompartment = new Compartment(createGlobalThis(), {});
|
||||||
|
|||||||
@@ -191,7 +191,12 @@ const LayoutPanel = memo(function LayoutPanel(
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Panel>
|
</Panel>
|
||||||
<PanelResizeHandle />
|
<PanelResizeHandle />
|
||||||
<Panel defaultSize={100 - node.splitPercentage}>
|
<Panel
|
||||||
|
defaultSize={100 - node.splitPercentage}
|
||||||
|
style={{
|
||||||
|
overflow: 'scroll',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<LayoutPanel node={node.second} editorProps={props.editorProps} />
|
<LayoutPanel node={node.second} editorProps={props.editorProps} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const HeaderItem = (): ReactElement => {
|
|||||||
return {
|
return {
|
||||||
direction: 'horizontal',
|
direction: 'horizontal',
|
||||||
first: 'editor',
|
first: 'editor',
|
||||||
second: '@affine/copilot',
|
second: '@affine/copilot-plugin',
|
||||||
splitPercentage: 70,
|
splitPercentage: 70,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,22 +11,29 @@ import {
|
|||||||
|
|
||||||
import { IndexedDBChatMessageHistory } from './langchain/message-history';
|
import { IndexedDBChatMessageHistory } from './langchain/message-history';
|
||||||
import { chatPrompt, followupQuestionPrompt } from './prompts';
|
import { chatPrompt, followupQuestionPrompt } from './prompts';
|
||||||
|
import { followupQuestionParser } from './prompts/output-parser';
|
||||||
|
|
||||||
declare global {
|
type ChatAI = {
|
||||||
interface WindowEventMap {
|
// Core chat AI
|
||||||
'llm-start': CustomEvent;
|
conversationChain: ConversationChain;
|
||||||
'llm-new-token': CustomEvent<{ token: string }>;
|
// Followup AI, used to generate followup questions
|
||||||
}
|
followupChain: LLMChain<string>;
|
||||||
}
|
// Chat history, used to store messages
|
||||||
|
chatHistory: IndexedDBChatMessageHistory;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatAIConfig = {
|
||||||
|
events: {
|
||||||
|
llmStart: () => void;
|
||||||
|
llmNewToken: (token: string) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export async function createChatAI(
|
export async function createChatAI(
|
||||||
room: string,
|
room: string,
|
||||||
openAIApiKey: string
|
openAIApiKey: string,
|
||||||
): Promise<{
|
config: ChatAIConfig
|
||||||
conversationChain: ConversationChain;
|
): Promise<ChatAI> {
|
||||||
followupChain: LLMChain<string>;
|
|
||||||
chatHistory: IndexedDBChatMessageHistory;
|
|
||||||
}> {
|
|
||||||
if (!openAIApiKey) {
|
if (!openAIApiKey) {
|
||||||
console.warn('OpenAI API key not set, chat will not work');
|
console.warn('OpenAI API key not set, chat will not work');
|
||||||
}
|
}
|
||||||
@@ -44,25 +51,11 @@ export async function createChatAI(
|
|||||||
openAIApiKey: openAIApiKey,
|
openAIApiKey: openAIApiKey,
|
||||||
callbacks: [
|
callbacks: [
|
||||||
{
|
{
|
||||||
async handleLLMStart(llm, prompts, runId, parentRunId, extraParams) {
|
async handleLLMStart() {
|
||||||
console.log(
|
config.events.llmStart();
|
||||||
'handleLLMStart',
|
|
||||||
llm,
|
|
||||||
prompts,
|
|
||||||
runId,
|
|
||||||
parentRunId,
|
|
||||||
extraParams
|
|
||||||
);
|
|
||||||
window.dispatchEvent(new CustomEvent('llm-start'));
|
|
||||||
},
|
},
|
||||||
async handleLLMNewToken(token, runId, parentRunId) {
|
async handleLLMNewToken(token) {
|
||||||
console.log('handleLLMNewToken', token, runId, parentRunId);
|
config.events.llmNewToken(token);
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent('llm-new-token', { detail: { token } })
|
|
||||||
);
|
|
||||||
},
|
|
||||||
async handleLLMEnd(output, runId, parentRunId) {
|
|
||||||
console.log('handleLLMEnd', output, runId, parentRunId);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -77,6 +70,9 @@ export async function createChatAI(
|
|||||||
const followupPromptTemplate = new PromptTemplate({
|
const followupPromptTemplate = new PromptTemplate({
|
||||||
template: followupQuestionPrompt,
|
template: followupQuestionPrompt,
|
||||||
inputVariables: ['human_conversation', 'ai_conversation'],
|
inputVariables: ['human_conversation', 'ai_conversation'],
|
||||||
|
partialVariables: {
|
||||||
|
format_instructions: followupQuestionParser.getFormatInstructions(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const followupChain = new LLMChain({
|
const followupChain = new LLMChain({
|
||||||
@@ -101,5 +97,5 @@ export async function createChatAI(
|
|||||||
conversationChain,
|
conversationChain,
|
||||||
followupChain,
|
followupChain,
|
||||||
chatHistory,
|
chatHistory,
|
||||||
} as const;
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,56 @@
|
|||||||
import type { IndexedDBChatMessageHistory } from '@affine/copilot/core/langchain/message-history';
|
|
||||||
import { atom, useAtomValue } from 'jotai';
|
import { atom, useAtomValue } from 'jotai';
|
||||||
import { atomWithDefault, atomWithStorage } from 'jotai/utils';
|
import { atomWithDefault, atomWithStorage } from 'jotai/utils';
|
||||||
import type { WritableAtom } from 'jotai/vanilla';
|
import type { WritableAtom } from 'jotai/vanilla';
|
||||||
|
import type { PrimitiveAtom } from 'jotai/vanilla';
|
||||||
import type { LLMChain } from 'langchain/chains';
|
import type { LLMChain } from 'langchain/chains';
|
||||||
import { type ConversationChain } from 'langchain/chains';
|
import { type ConversationChain } from 'langchain/chains';
|
||||||
import { type BufferMemory } from 'langchain/memory';
|
import { type BufferMemory } from 'langchain/memory';
|
||||||
import type { BaseMessage } from 'langchain/schema';
|
import type { BaseMessage } from 'langchain/schema';
|
||||||
import { AIMessage } from 'langchain/schema';
|
import { AIMessage } from 'langchain/schema';
|
||||||
import { HumanMessage } from 'langchain/schema';
|
import { HumanMessage } from 'langchain/schema';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
|
import type { ChatAIConfig } from '../chat';
|
||||||
import { createChatAI } from '../chat';
|
import { createChatAI } from '../chat';
|
||||||
|
import type { IndexedDBChatMessageHistory } from '../langchain/message-history';
|
||||||
const followupResponseSchema = z.array(z.string());
|
import { followupQuestionParser } from '../prompts/output-parser';
|
||||||
|
|
||||||
export const openAIApiKeyAtom = atomWithStorage<string | null>(
|
export const openAIApiKeyAtom = atomWithStorage<string | null>(
|
||||||
'com.affine.copilot.openai.token',
|
'com.affine.copilot.openai.token',
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
export const chatAtom = atom(async get => {
|
const conversationBaseWeakMap = new WeakMap<
|
||||||
const openAIApiKey = get(openAIApiKeyAtom);
|
ConversationChain,
|
||||||
if (!openAIApiKey) {
|
PrimitiveAtom<BaseMessage[]>
|
||||||
throw new Error('OpenAI API key not set, chat will not work');
|
>();
|
||||||
}
|
|
||||||
return createChatAI('default-copilot', openAIApiKey);
|
|
||||||
});
|
|
||||||
|
|
||||||
const conversationWeakMap = new WeakMap<
|
const conversationWeakMap = new WeakMap<
|
||||||
ConversationChain,
|
ConversationChain,
|
||||||
WritableAtom<BaseMessage[], [string], Promise<void>>
|
WritableAtom<BaseMessage[], [string], Promise<void>>
|
||||||
>();
|
>();
|
||||||
|
|
||||||
const getConversationAtom = (chat: ConversationChain) => {
|
export const chatAtom = atom(async get => {
|
||||||
if (conversationWeakMap.has(chat)) {
|
const openAIApiKey = get(openAIApiKeyAtom);
|
||||||
return conversationWeakMap.get(chat) as WritableAtom<
|
if (!openAIApiKey) {
|
||||||
BaseMessage[],
|
throw new Error('OpenAI API key not set, chat will not work');
|
||||||
[string],
|
|
||||||
Promise<void>
|
|
||||||
>;
|
|
||||||
}
|
}
|
||||||
const conversationBaseAtom = atom<BaseMessage[]>([]);
|
const events: ChatAIConfig['events'] = {
|
||||||
conversationBaseAtom.onMount = setAtom => {
|
llmStart: () => {
|
||||||
if (!chat) {
|
throw new Error('llmStart not set');
|
||||||
throw new Error();
|
},
|
||||||
}
|
llmNewToken: () => {
|
||||||
const memory = chat.memory as BufferMemory;
|
throw new Error('llmNewToken not set');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const chatAI = await createChatAI('default-copilot', openAIApiKey, {
|
||||||
|
events,
|
||||||
|
});
|
||||||
|
getOrCreateConversationAtom(chatAI.conversationChain);
|
||||||
|
const baseAtom = conversationBaseWeakMap.get(chatAI.conversationChain);
|
||||||
|
if (!baseAtom) {
|
||||||
|
throw new TypeError();
|
||||||
|
}
|
||||||
|
baseAtom.onMount = setAtom => {
|
||||||
|
const memory = chatAI.conversationChain.memory as BufferMemory;
|
||||||
memory.chatHistory
|
memory.chatHistory
|
||||||
.getMessages()
|
.getMessages()
|
||||||
.then(messages => {
|
.then(messages => {
|
||||||
@@ -54,23 +59,27 @@ const getConversationAtom = (chat: ConversationChain) => {
|
|||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
});
|
});
|
||||||
const llmStart = (): void => {
|
events.llmStart = () => {
|
||||||
setAtom(conversations => [...conversations, new AIMessage('')]);
|
setAtom(conversations => [...conversations, new AIMessage('')]);
|
||||||
};
|
};
|
||||||
const llmNewToken = (event: CustomEvent<{ token: string }>): void => {
|
events.llmNewToken = token => {
|
||||||
setAtom(conversations => {
|
setAtom(conversations => {
|
||||||
const last = conversations[conversations.length - 1] as AIMessage;
|
const last = conversations[conversations.length - 1] as AIMessage;
|
||||||
last.content += event.detail.token;
|
last.content += token;
|
||||||
return [...conversations];
|
return [...conversations];
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
window.addEventListener('llm-start', llmStart);
|
|
||||||
window.addEventListener('llm-new-token', llmNewToken);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('llm-start', llmStart);
|
|
||||||
window.removeEventListener('llm-new-token', llmNewToken);
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
return chatAI;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getOrCreateConversationAtom = (chat: ConversationChain) => {
|
||||||
|
if (conversationWeakMap.has(chat)) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
return conversationWeakMap.get(chat)!;
|
||||||
|
}
|
||||||
|
const conversationBaseAtom = atom<BaseMessage[]>([]);
|
||||||
|
conversationBaseWeakMap.set(chat, conversationBaseAtom);
|
||||||
|
|
||||||
const conversationAtom = atom<BaseMessage[], [string], Promise<void>>(
|
const conversationAtom = atom<BaseMessage[], [string], Promise<void>>(
|
||||||
get => get(conversationBaseAtom),
|
get => get(conversationBaseAtom),
|
||||||
@@ -105,7 +114,9 @@ const getConversationAtom = (chat: ConversationChain) => {
|
|||||||
const followingUpWeakMap = new WeakMap<
|
const followingUpWeakMap = new WeakMap<
|
||||||
LLMChain<string>,
|
LLMChain<string>,
|
||||||
{
|
{
|
||||||
questionsAtom: ReturnType<typeof atomWithDefault<Promise<string[]>>>;
|
questionsAtom: ReturnType<
|
||||||
|
typeof atomWithDefault<Promise<string[]> | string[]>
|
||||||
|
>;
|
||||||
generateChatAtom: WritableAtom<null, [], void>;
|
generateChatAtom: WritableAtom<null, [], void>;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
@@ -115,12 +126,10 @@ const getFollowingUpAtoms = (
|
|||||||
chatHistory: IndexedDBChatMessageHistory
|
chatHistory: IndexedDBChatMessageHistory
|
||||||
) => {
|
) => {
|
||||||
if (followingUpWeakMap.has(followupLLMChain)) {
|
if (followingUpWeakMap.has(followupLLMChain)) {
|
||||||
return followingUpWeakMap.get(followupLLMChain) as {
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
questionsAtom: ReturnType<typeof atomWithDefault<Promise<string[]>>>;
|
return followingUpWeakMap.get(followupLLMChain)!;
|
||||||
generateChatAtom: WritableAtom<null, [], void>;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
const baseAtom = atomWithDefault<Promise<string[]>>(async () => {
|
const baseAtom = atomWithDefault<Promise<string[]> | string[]>(async () => {
|
||||||
return chatHistory?.getFollowingUp() ?? [];
|
return chatHistory?.getFollowingUp() ?? [];
|
||||||
});
|
});
|
||||||
const setAtom = atom<null, [], void>(null, async (get, set) => {
|
const setAtom = atom<null, [], void>(null, async (get, set) => {
|
||||||
@@ -137,10 +146,9 @@ const getFollowingUpAtoms = (
|
|||||||
ai_conversation: aiMessage,
|
ai_conversation: aiMessage,
|
||||||
human_conversation: humanMessage,
|
human_conversation: humanMessage,
|
||||||
});
|
});
|
||||||
const followingUp = JSON.parse(response.text);
|
const followingUp = await followupQuestionParser.parse(response.text);
|
||||||
followupResponseSchema.parse(followingUp);
|
set(baseAtom, followingUp.followupQuestions);
|
||||||
set(baseAtom, followingUp);
|
chatHistory.saveFollowingUp(followingUp.followupQuestions).catch(() => {
|
||||||
chatHistory.saveFollowingUp(followingUp).catch(() => {
|
|
||||||
console.error('failed to save followup');
|
console.error('failed to save followup');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -155,11 +163,11 @@ const getFollowingUpAtoms = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function useChatAtoms(): {
|
export function useChatAtoms(): {
|
||||||
conversationAtom: ReturnType<typeof getConversationAtom>;
|
conversationAtom: ReturnType<typeof getOrCreateConversationAtom>;
|
||||||
followingUpAtoms: ReturnType<typeof getFollowingUpAtoms>;
|
followingUpAtoms: ReturnType<typeof getFollowingUpAtoms>;
|
||||||
} {
|
} {
|
||||||
const chat = useAtomValue(chatAtom);
|
const chat = useAtomValue(chatAtom);
|
||||||
const conversationAtom = getConversationAtom(chat.conversationChain);
|
const conversationAtom = getOrCreateConversationAtom(chat.conversationChain);
|
||||||
const followingUpAtoms = getFollowingUpAtoms(
|
const followingUpAtoms = getFollowingUpAtoms(
|
||||||
chat.followupChain,
|
chat.followupChain,
|
||||||
chat.chatHistory
|
chat.chatHistory
|
||||||
|
|||||||
@@ -18,12 +18,10 @@ You can only give one reply for each conversation turn.
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export const followupQuestionPrompt = `Rules you must follow:
|
export const followupQuestionPrompt = `Rules you must follow:
|
||||||
- You only respond in JSON format
|
Read the following conversation between AI and Human and generate at most 3 follow-up messages or questions the Human can ask
|
||||||
- Read the following conversation between AI and Human and generate at most 3 follow-up messages or questions the Human can ask
|
Each message in your response should be concise, no more than 15 words
|
||||||
- Your response MUST be a valid JSON array of strings like this: ["some question", "another question"]
|
You MUST reply in the same written language as the conversation
|
||||||
- Each message in your response should be concise, no more than 15 words
|
{format_instructions}
|
||||||
- You MUST reply in the same written language as the conversation
|
|
||||||
- Don't output anything other text
|
|
||||||
The conversation is inside triple quotes:
|
The conversation is inside triple quotes:
|
||||||
\`\`\`
|
\`\`\`
|
||||||
Human: {human_conversation}
|
Human: {human_conversation}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { StructuredOutputParser } from 'langchain/output_parsers';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const followupQuestionParser = StructuredOutputParser.fromZodSchema(
|
||||||
|
z.object({
|
||||||
|
followupQuestions: z.array(z.string()),
|
||||||
|
})
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user