From 1c1dade2d5ea418192673f06f2140c28f2b5133d Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Sat, 28 Jun 2025 16:27:08 +0800 Subject: [PATCH] feat(server): add morph doc edit tool (#12789) ## 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. --- .docker/selfhost/schema.json | 5 + packages/backend/server/package.json | 1 + .../server/src/plugins/copilot/config.ts | 6 + .../src/plugins/copilot/providers/index.ts | 2 + .../src/plugins/copilot/providers/morph.ts | 163 ++++++++++++++++++ .../src/plugins/copilot/providers/provider.ts | 15 +- .../src/plugins/copilot/providers/types.ts | 1 + .../src/plugins/copilot/providers/utils.ts | 8 + .../src/plugins/copilot/tools/doc-edit.ts | 79 +++++++++ .../server/src/plugins/copilot/tools/index.ts | 1 + packages/frontend/admin/src/config.json | 4 + yarn.lock | 13 ++ 12 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 packages/backend/server/src/plugins/copilot/providers/morph.ts create mode 100644 packages/backend/server/src/plugins/copilot/tools/doc-edit.ts diff --git a/.docker/selfhost/schema.json b/.docker/selfhost/schema.json index 533740398f..889f49fcdf 100644 --- a/.docker/selfhost/schema.json +++ b/.docker/selfhost/schema.json @@ -732,6 +732,11 @@ }, "default": {} }, + "providers.morph": { + "type": "object", + "description": "The config for the morph provider.\n@default {}", + "default": {} + }, "unsplash": { "type": "object", "description": "The config for the unsplash key.\n@default {\"key\":\"\"}", diff --git a/packages/backend/server/package.json b/packages/backend/server/package.json index ce89af04a0..2efd0ebfa8 100644 --- a/packages/backend/server/package.json +++ b/packages/backend/server/package.json @@ -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", diff --git a/packages/backend/server/src/plugins/copilot/config.ts b/packages/backend/server/src/plugins/copilot/config.ts index 517d76c582..c36045a018 100644 --- a/packages/backend/server/src/plugins/copilot/config.ts +++ b/packages/backend/server/src/plugins/copilot/config.ts @@ -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; anthropic: ConfigItem; anthropicVertex: ConfigItem; + morph: ConfigItem; }; }; } @@ -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: { diff --git a/packages/backend/server/src/plugins/copilot/providers/index.ts b/packages/backend/server/src/plugins/copilot/providers/index.ts index fbaf1d5fe4..97cb547a4d 100644 --- a/packages/backend/server/src/plugins/copilot/providers/index.ts +++ b/packages/backend/server/src/plugins/copilot/providers/index.ts @@ -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 { diff --git a/packages/backend/server/src/plugins/copilot/providers/morph.ts b/packages/backend/server/src/plugins/copilot/providers/morph.ts new file mode 100644 index 0000000000..c832dda4bb --- /dev/null +++ b/packages/backend/server/src/plugins/copilot/providers/morph.ts @@ -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 { + 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 { + 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 { + 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); + } + } +} diff --git a/packages/backend/server/src/plugins/copilot/providers/provider.ts b/packages/backend/server/src/plugins/copilot/providers/provider.ts index 5dc7618e91..4f0b77bb6f 100644 --- a/packages/backend/server/src/plugins/copilot/providers/provider.ts +++ b/packages/backend/server/src/plugins/copilot/providers/provider.ts @@ -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 { 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 { 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, }); diff --git a/packages/backend/server/src/plugins/copilot/providers/types.ts b/packages/backend/server/src/plugins/copilot/providers/types.ts index cbe797dd49..0935967267 100644 --- a/packages/backend/server/src/plugins/copilot/providers/types.ts +++ b/packages/backend/server/src/plugins/copilot/providers/types.ts @@ -13,6 +13,7 @@ export enum CopilotProviderType { GeminiVertex = 'geminiVertex', OpenAI = 'openai', Perplexity = 'perplexity', + Morph = 'morph', } export const CopilotProviderSchema = z.object({ diff --git a/packages/backend/server/src/plugins/copilot/providers/utils.ts b/packages/backend/server/src/plugins/copilot/providers/utils.ts index 0d9092a35a..36e98635d7 100644 --- a/packages/backend/server/src/plugins/copilot/providers/utils.ts +++ b/packages/backend/server/src/plugins/copilot/providers/utils.ts @@ -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; doc_semantic_search: ReturnType; doc_keyword_search: ReturnType; web_search_exa: ReturnType; @@ -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`; diff --git a/packages/backend/server/src/plugins/copilot/tools/doc-edit.ts b/packages/backend/server/src/plugins/copilot/tools/doc-edit.ts new file mode 100644 index 0000000000..1520fcdec5 --- /dev/null +++ b/packages/backend/server/src/plugins/copilot/tools/doc-edit.ts @@ -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 +) => { + 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: `${content}\n${code_edit}`, + }, + ]); + return { result }; + } catch { + return 'Failed to apply edit to the doc'; + } + }, + }); +}; diff --git a/packages/backend/server/src/plugins/copilot/tools/index.ts b/packages/backend/server/src/plugins/copilot/tools/index.ts index 76f851f84c..8ac1c65364 100644 --- a/packages/backend/server/src/plugins/copilot/tools/index.ts +++ b/packages/backend/server/src/plugins/copilot/tools/index.ts @@ -1,3 +1,4 @@ +export * from './doc-edit'; export * from './doc-keyword-search'; export * from './doc-semantic-search'; export * from './error'; diff --git a/packages/frontend/admin/src/config.json b/packages/frontend/admin/src/config.json index 0e6184d0fa..ba2d0424bf 100644 --- a/packages/frontend/admin/src/config.json +++ b/packages/frontend/admin/src/config.json @@ -253,6 +253,10 @@ "type": "Object", "desc": "The config for the anthropic provider in Google Vertex AI." }, + "providers.morph": { + "type": "Object", + "desc": "The config for the morph provider." + }, "unsplash": { "type": "Object", "desc": "The config for the unsplash key." diff --git a/yarn.lock b/yarn.lock index 29b1cc58dd..3ecb979fec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -911,6 +911,7 @@ __metadata: "@ai-sdk/google": "npm:^1.2.18" "@ai-sdk/google-vertex": "npm:^2.2.23" "@ai-sdk/openai": "npm:^1.3.22" + "@ai-sdk/openai-compatible": "npm:^0.2.14" "@ai-sdk/perplexity": "npm:^1.1.9" "@apollo/server": "npm:^4.11.3" "@aws-sdk/client-s3": "npm:^3.779.0" @@ -1113,6 +1114,18 @@ __metadata: languageName: node linkType: hard +"@ai-sdk/openai-compatible@npm:^0.2.14": + version: 0.2.14 + resolution: "@ai-sdk/openai-compatible@npm:0.2.14" + dependencies: + "@ai-sdk/provider": "npm:1.1.3" + "@ai-sdk/provider-utils": "npm:2.2.8" + peerDependencies: + zod: ^3.0.0 + checksum: 10/a2b9fbe6c9a0a9edbe6c5d91fbb06708088c881060cff7018ce0bb7ca52d8f63a20dd334389099d9ea256482f2c22f9f1ff6be0de836d3af98a27274578f0be6 + languageName: node + linkType: hard + "@ai-sdk/openai@npm:^1.3.22": version: 1.3.22 resolution: "@ai-sdk/openai@npm:1.3.22"