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:
DarkSky
2025-06-28 16:27:08 +08:00
committed by GitHub
parent e2a799c70a
commit 1c1dade2d5
12 changed files with 297 additions and 1 deletions
@@ -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 {
@@ -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`;