mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
feat(server): add morph doc edit tool (#12789)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced support for the Morph provider in the copilot module, enabling integration with the Morph LLM API. - Added a new document editing tool that allows users to propose and apply edits to existing documents via AI assistance. - **Configuration** - Added configuration options for the Morph provider in both backend and admin interfaces. - **Enhancements** - Expanded tool support to include document editing capabilities within the copilot feature set. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
"@ai-sdk/google": "^1.2.18",
|
||||
"@ai-sdk/google-vertex": "^2.2.23",
|
||||
"@ai-sdk/openai": "^1.3.22",
|
||||
"@ai-sdk/openai-compatible": "^0.2.14",
|
||||
"@ai-sdk/perplexity": "^1.1.9",
|
||||
"@apollo/server": "^4.11.3",
|
||||
"@aws-sdk/client-s3": "^3.779.0",
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from './providers/anthropic';
|
||||
import type { FalConfig } from './providers/fal';
|
||||
import { GeminiGenerativeConfig, GeminiVertexConfig } from './providers/gemini';
|
||||
import { MorphConfig } from './providers/morph';
|
||||
import { OpenAIConfig } from './providers/openai';
|
||||
import { PerplexityConfig } from './providers/perplexity';
|
||||
import { VertexSchema } from './providers/types';
|
||||
@@ -31,6 +32,7 @@ declare global {
|
||||
perplexity: ConfigItem<PerplexityConfig>;
|
||||
anthropic: ConfigItem<AnthropicOfficialConfig>;
|
||||
anthropicVertex: ConfigItem<AnthropicVertexConfig>;
|
||||
morph: ConfigItem<MorphConfig>;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -82,6 +84,10 @@ defineModuleConfig('copilot', {
|
||||
default: {},
|
||||
schema: VertexSchema,
|
||||
},
|
||||
'providers.morph': {
|
||||
desc: 'The config for the morph provider.',
|
||||
default: {},
|
||||
},
|
||||
unsplash: {
|
||||
desc: 'The config for the unsplash key.',
|
||||
default: {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from './anthropic';
|
||||
import { FalProvider } from './fal';
|
||||
import { GeminiGenerativeProvider, GeminiVertexProvider } from './gemini';
|
||||
import { MorphProvider } from './morph';
|
||||
import { OpenAIProvider } from './openai';
|
||||
import { PerplexityProvider } from './perplexity';
|
||||
|
||||
@@ -15,6 +16,7 @@ export const CopilotProviders = [
|
||||
PerplexityProvider,
|
||||
AnthropicOfficialProvider,
|
||||
AnthropicVertexProvider,
|
||||
MorphProvider,
|
||||
];
|
||||
|
||||
export {
|
||||
|
||||
163
packages/backend/server/src/plugins/copilot/providers/morph.ts
Normal file
163
packages/backend/server/src/plugins/copilot/providers/morph.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
createOpenAICompatible,
|
||||
OpenAICompatibleProvider as VercelOpenAICompatibleProvider,
|
||||
} from '@ai-sdk/openai-compatible';
|
||||
import { AISDKError, generateText, streamText } from 'ai';
|
||||
|
||||
import {
|
||||
CopilotProviderSideError,
|
||||
metrics,
|
||||
UserFriendlyError,
|
||||
} from '../../../base';
|
||||
import { CopilotProvider } from './provider';
|
||||
import type {
|
||||
CopilotChatOptions,
|
||||
ModelConditions,
|
||||
PromptMessage,
|
||||
} from './types';
|
||||
import { CopilotProviderType, ModelInputType, ModelOutputType } from './types';
|
||||
import { chatToGPTMessage, CitationParser, TextStreamParser } from './utils';
|
||||
|
||||
export const DEFAULT_DIMENSIONS = 256;
|
||||
|
||||
export type MorphConfig = {
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
export class MorphProvider extends CopilotProvider<MorphConfig> {
|
||||
readonly type = CopilotProviderType.Morph;
|
||||
|
||||
readonly models = [
|
||||
{
|
||||
id: 'morph-v2',
|
||||
capabilities: [
|
||||
{
|
||||
input: [ModelInputType.Text],
|
||||
output: [ModelOutputType.Text],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
#instance!: VercelOpenAICompatibleProvider;
|
||||
|
||||
override configured(): boolean {
|
||||
return !!this.config.apiKey;
|
||||
}
|
||||
|
||||
protected override setup() {
|
||||
super.setup();
|
||||
this.#instance = createOpenAICompatible({
|
||||
name: this.type,
|
||||
apiKey: this.config.apiKey,
|
||||
baseURL: 'https://api.morphllm.com/v1',
|
||||
});
|
||||
}
|
||||
|
||||
private handleError(e: any) {
|
||||
if (e instanceof UserFriendlyError) {
|
||||
return e;
|
||||
} else if (e instanceof AISDKError) {
|
||||
return new CopilotProviderSideError({
|
||||
provider: this.type,
|
||||
kind: e.name || 'unknown',
|
||||
message: e.message,
|
||||
});
|
||||
} else {
|
||||
return new CopilotProviderSideError({
|
||||
provider: this.type,
|
||||
kind: 'unexpected_response',
|
||||
message: e?.message || 'Unexpected morph response',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async text(
|
||||
cond: ModelConditions,
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): Promise<string> {
|
||||
const fullCond = {
|
||||
...cond,
|
||||
outputType: ModelOutputType.Text,
|
||||
};
|
||||
await this.checkParams({ messages, cond: fullCond, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
|
||||
try {
|
||||
metrics.ai.counter('chat_text_calls').add(1, { model: model.id });
|
||||
|
||||
const [system, msgs] = await chatToGPTMessage(messages);
|
||||
|
||||
const modelInstance = this.#instance(model.id);
|
||||
|
||||
const { text } = await generateText({
|
||||
model: modelInstance,
|
||||
system,
|
||||
messages: msgs,
|
||||
abortSignal: options.signal,
|
||||
});
|
||||
|
||||
return text.trim();
|
||||
} catch (e: any) {
|
||||
metrics.ai.counter('chat_text_errors').add(1, { model: model.id });
|
||||
throw this.handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async *streamText(
|
||||
cond: ModelConditions,
|
||||
messages: PromptMessage[],
|
||||
options: CopilotChatOptions = {}
|
||||
): AsyncIterable<string> {
|
||||
const fullCond = {
|
||||
...cond,
|
||||
outputType: ModelOutputType.Text,
|
||||
};
|
||||
await this.checkParams({ messages, cond: fullCond, options });
|
||||
const model = this.selectModel(fullCond);
|
||||
|
||||
try {
|
||||
metrics.ai.counter('chat_text_stream_calls').add(1, { model: model.id });
|
||||
const [system, msgs] = await chatToGPTMessage(messages);
|
||||
|
||||
const modelInstance = this.#instance(model.id);
|
||||
|
||||
const { fullStream } = streamText({
|
||||
model: modelInstance,
|
||||
system,
|
||||
messages: msgs,
|
||||
abortSignal: options.signal,
|
||||
});
|
||||
|
||||
const citationParser = new CitationParser();
|
||||
const textParser = new TextStreamParser();
|
||||
for await (const chunk of fullStream) {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta': {
|
||||
let result = textParser.parse(chunk);
|
||||
result = citationParser.parse(result);
|
||||
yield result;
|
||||
break;
|
||||
}
|
||||
case 'finish': {
|
||||
const result = citationParser.end();
|
||||
yield result;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
yield textParser.parse(chunk);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (options.signal?.aborted) {
|
||||
await fullStream.cancel();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
metrics.ai.counter('chat_text_stream_errors').add(1, { model: model.id });
|
||||
throw this.handleError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,15 @@ import {
|
||||
CopilotProviderNotSupported,
|
||||
OnEvent,
|
||||
} from '../../../base';
|
||||
import { DocReader } from '../../../core/doc';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import { IndexerService } from '../../indexer';
|
||||
import { CopilotContextService } from '../context';
|
||||
import {
|
||||
buildContentGetter,
|
||||
buildDocKeywordSearchGetter,
|
||||
buildDocSearchGetter,
|
||||
createDocEditTool,
|
||||
createDocKeywordSearchTool,
|
||||
createDocSemanticSearchTool,
|
||||
createExaCrawlTool,
|
||||
@@ -129,6 +132,8 @@ export abstract class CopilotProvider<C = any> {
|
||||
const tools: ToolSet = {};
|
||||
if (options?.tools?.length) {
|
||||
this.logger.debug(`getTools: ${JSON.stringify(options.tools)}`);
|
||||
const ac = this.moduleRef.get(AccessController, { strict: false });
|
||||
|
||||
for (const tool of options.tools) {
|
||||
const toolDef = this.getProviderSpecificTools(tool, model);
|
||||
if (toolDef) {
|
||||
@@ -136,8 +141,16 @@ export abstract class CopilotProvider<C = any> {
|
||||
continue;
|
||||
}
|
||||
switch (tool) {
|
||||
case 'docEdit': {
|
||||
const doc = this.moduleRef.get(DocReader, { strict: false });
|
||||
const getDocContent = buildContentGetter(ac, doc);
|
||||
tools.doc_edit = createDocEditTool(
|
||||
this.factory,
|
||||
getDocContent.bind(null, options)
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'docSemanticSearch': {
|
||||
const ac = this.moduleRef.get(AccessController, { strict: false });
|
||||
const context = this.moduleRef.get(CopilotContextService, {
|
||||
strict: false,
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum CopilotProviderType {
|
||||
GeminiVertex = 'geminiVertex',
|
||||
OpenAI = 'openai',
|
||||
Perplexity = 'perplexity',
|
||||
Morph = 'morph',
|
||||
}
|
||||
|
||||
export const CopilotProviderSchema = z.object({
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { ZodType } from 'zod';
|
||||
|
||||
import {
|
||||
createDocEditTool,
|
||||
createDocKeywordSearchTool,
|
||||
createDocSemanticSearchTool,
|
||||
createExaCrawlTool,
|
||||
@@ -382,6 +383,7 @@ export class CitationParser {
|
||||
}
|
||||
|
||||
export interface CustomAITools extends ToolSet {
|
||||
doc_edit: ReturnType<typeof createDocEditTool>;
|
||||
doc_semantic_search: ReturnType<typeof createDocSemanticSearchTool>;
|
||||
doc_keyword_search: ReturnType<typeof createDocKeywordSearchTool>;
|
||||
web_search_exa: ReturnType<typeof createExaSearchTool>;
|
||||
@@ -459,6 +461,12 @@ export class TextStreamParser {
|
||||
);
|
||||
result = this.addPrefix(result);
|
||||
switch (chunk.toolName) {
|
||||
case 'doc_edit': {
|
||||
if (chunk.result && typeof chunk.result === 'object') {
|
||||
result += `\n${chunk.result.result}\n`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'doc_semantic_search': {
|
||||
if (Array.isArray(chunk.result)) {
|
||||
result += `\nFound ${chunk.result.length} document${chunk.result.length !== 1 ? 's' : ''} related to “${chunk.args.query}”.\n`;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { DocReader } from '../../../core/doc';
|
||||
import { AccessController } from '../../../core/permission';
|
||||
import type { CopilotChatOptions, CopilotProviderFactory } from '../providers';
|
||||
|
||||
export const buildContentGetter = (ac: AccessController, doc: DocReader) => {
|
||||
const getDocContent = async (options: CopilotChatOptions, docId?: string) => {
|
||||
if (!options || !docId || !options.user || !options.workspace) {
|
||||
return undefined;
|
||||
}
|
||||
const canAccess = await ac
|
||||
.user(options.user)
|
||||
.workspace(options.workspace)
|
||||
.doc(docId)
|
||||
.can('Doc.Read');
|
||||
if (!canAccess) return undefined;
|
||||
const content = await doc.getFullDocContent(options.workspace, docId);
|
||||
return content?.summary.trim() || undefined;
|
||||
};
|
||||
return getDocContent;
|
||||
};
|
||||
|
||||
export const createDocEditTool = (
|
||||
factory: CopilotProviderFactory,
|
||||
getContent: (targetId?: string) => Promise<string | undefined>
|
||||
) => {
|
||||
return tool({
|
||||
description:
|
||||
"Use this tool to propose an edit to an existing doc.\n\nThis will be read by a less intelligent model, which will quickly apply the edit. You should make it clear what the edit is, while also minimizing the unchanged code you write.\nWhen writing the edit, you should specify each edit in sequence, with the special comment // ... existing code ... to represent unchanged code in between edited lines.\n\nYou should bias towards repeating as few lines of the original doc as possible to convey the change.\nEach edit should contain sufficient context of unchanged lines around the code you're editing to resolve ambiguity.\nIf you plan on deleting a section, you must provide surrounding context to indicate the deletion.\nDO NOT omit spans of pre-existing code without using the // ... existing code ... comment to indicate its absence.\n\nYou should specify the following arguments before the others: [target_id], [origin_content]",
|
||||
parameters: z.object({
|
||||
doc_id: z
|
||||
.string()
|
||||
.describe(
|
||||
'The target doc to modify. Always specify the target doc as the first argument. If the content to be modified does not include a specific document, the full text should be provided through origin_content.'
|
||||
)
|
||||
.optional(),
|
||||
origin_content: z
|
||||
.string()
|
||||
.describe(
|
||||
'The original content of the doc you are editing. If the original text is from a specific document, the target_id should be provided instead of this parameter.'
|
||||
)
|
||||
.optional(),
|
||||
instructions: z
|
||||
.string()
|
||||
.describe(
|
||||
'A single sentence instruction describing what you are going to do for the sketched edit. This is used to assist the less intelligent model in applying the edit. Please use the first person to describe what you are going to do. Dont repeat what you have said previously in normal messages. And use it to disambiguate uncertainty in the edit.'
|
||||
),
|
||||
code_edit: z
|
||||
.string()
|
||||
.describe(
|
||||
"Specify ONLY the precise lines of code that you wish to edit. NEVER specify or write out unchanged code. Instead, represent all unchanged code using the comment of the language you're editing in - example: // ... existing code ..."
|
||||
),
|
||||
}),
|
||||
execute: async ({ doc_id, origin_content, code_edit }) => {
|
||||
try {
|
||||
const provider = await factory.getProviderByModel('morph-v2');
|
||||
if (!provider) {
|
||||
return 'Editing docs is not supported';
|
||||
}
|
||||
|
||||
const content = origin_content || (await getContent(doc_id));
|
||||
if (!content) {
|
||||
return 'Doc not found or doc is empty';
|
||||
}
|
||||
const result = await provider.text({ modelId: 'morph-v2' }, [
|
||||
{
|
||||
role: 'user',
|
||||
content: `<code>${content}</code>\n<update>${code_edit}</update>`,
|
||||
},
|
||||
]);
|
||||
return { result };
|
||||
} catch {
|
||||
return 'Failed to apply edit to the doc';
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './doc-edit';
|
||||
export * from './doc-keyword-search';
|
||||
export * from './doc-semantic-search';
|
||||
export * from './error';
|
||||
|
||||
Reference in New Issue
Block a user