From f6ace675e712c3d2227d1cd19e9c905a6e4d9ad6 Mon Sep 17 00:00:00 2001 From: Philip Langer Date: Wed, 2 Oct 2024 09:19:15 +0200 Subject: [PATCH] feat(ai): Make response parsing extensible (#14196) Turns the response parsing method into a more flexible algorithm that can work with multiple response content matchers. Each response content matcher has a start and end regexp to define a match, as well as a `contentFactory` function that turns the matched content into a `ChatResponseContent` object. Additionally, the parsing method has a fallback content factory that will be applied to all unmatched parts, e.g. markdown by default. Both, the response content matchers and the fallback content factory and the list of matchers are extensible via DI. Contributed on behalf of STMicroelectronics. --- .../src/browser/ai-chat-frontend-module.ts | 6 + packages/ai-chat/src/common/chat-agents.ts | 119 ++++++++------- packages/ai-chat/src/common/chat-model.ts | 18 ++- .../ai-chat/src/common/parse-contents.spec.ts | 142 ++++++++++++++++++ packages/ai-chat/src/common/parse-contents.ts | 92 ++++++++++++ .../src/common/response-content-matcher.ts | 102 +++++++++++++ 6 files changed, 414 insertions(+), 65 deletions(-) create mode 100644 packages/ai-chat/src/common/parse-contents.spec.ts create mode 100644 packages/ai-chat/src/common/parse-contents.ts create mode 100644 packages/ai-chat/src/common/response-content-matcher.ts diff --git a/packages/ai-chat/src/browser/ai-chat-frontend-module.ts b/packages/ai-chat/src/browser/ai-chat-frontend-module.ts index d4ed71579b514..c2231c64734aa 100644 --- a/packages/ai-chat/src/browser/ai-chat-frontend-module.ts +++ b/packages/ai-chat/src/browser/ai-chat-frontend-module.ts @@ -33,6 +33,7 @@ import { UniversalChatAgent } from '../common/universal-chat-agent'; import { aiChatPreferences } from './ai-chat-preferences'; import { ChatAgentsVariableContribution } from '../common/chat-agents-variable-contribution'; import { FrontendChatServiceImpl } from './frontend-chat-service'; +import { DefaultResponseContentMatcherProvider, DefaultResponseContentFactory, ResponseContentMatcherProvider } from '../common/response-content-matcher'; export default new ContainerModule(bind => { bindContributionProvider(bind, Agent); @@ -42,6 +43,11 @@ export default new ContainerModule(bind => { bind(ChatAgentService).toService(ChatAgentServiceImpl); bind(DefaultChatAgentId).toConstantValue({ id: OrchestratorChatAgentId }); + bindContributionProvider(bind, ResponseContentMatcherProvider); + bind(DefaultResponseContentMatcherProvider).toSelf().inSingletonScope(); + bind(ResponseContentMatcherProvider).toService(DefaultResponseContentMatcherProvider); + bind(DefaultResponseContentFactory).toSelf().inSingletonScope(); + bind(AIVariableContribution).to(ChatAgentsVariableContribution).inSingletonScope(); bind(ChatRequestParserImpl).toSelf().inSingletonScope(); diff --git a/packages/ai-chat/src/common/chat-agents.ts b/packages/ai-chat/src/common/chat-agents.ts index ee279125855be..ce6bbd741a17c 100644 --- a/packages/ai-chat/src/common/chat-agents.ts +++ b/packages/ai-chat/src/common/chat-agents.ts @@ -25,6 +25,7 @@ import { LanguageModel, LanguageModelRequirement, LanguageModelResponse, + LanguageModelStreamResponse, PromptService, ResolvedPromptTemplate, ToolRequest, @@ -37,19 +38,20 @@ import { LanguageModelStreamResponsePart, MessageActor, } from '@theia/ai-core/lib/common'; -import { CancellationToken, CancellationTokenSource, ILogger, isArray } from '@theia/core'; -import { inject, injectable } from '@theia/core/shared/inversify'; +import { CancellationToken, CancellationTokenSource, ContributionProvider, ILogger, isArray } from '@theia/core'; +import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify'; import { ChatAgentService } from './chat-agent-service'; import { ChatModel, ChatRequestModel, ChatRequestModelImpl, ChatResponseContent, - CodeChatResponseContentImpl, ErrorChatResponseContentImpl, MarkdownChatResponseContentImpl, ToolCallChatResponseContentImpl } from './chat-model'; +import { findFirstMatch, parseContents } from './parse-contents'; +import { DefaultResponseContentFactory, ResponseContentMatcher, ResponseContentMatcherProvider } from './response-content-matcher'; /** * A conversation consists of a sequence of ChatMessages. @@ -121,6 +123,14 @@ export abstract class AbstractChatAgent { @inject(ILogger) protected logger: ILogger; @inject(CommunicationRecordingService) protected recordingService: CommunicationRecordingService; @inject(PromptService) protected promptService: PromptService; + + @inject(ContributionProvider) @named(ResponseContentMatcherProvider) + protected contentMatcherProviders: ContributionProvider; + protected contentMatchers: ResponseContentMatcher[] = []; + + @inject(DefaultResponseContentFactory) + protected defaultContentFactory: DefaultResponseContentFactory; + constructor( public id: string, public languageModelRequirements: LanguageModelRequirement[], @@ -130,6 +140,11 @@ export abstract class AbstractChatAgent { public tags: String[] = ['Chat']) { } + @postConstruct() + init(): void { + this.contentMatchers = this.contentMatcherProviders.getContributions().flatMap(provider => provider.matchers); + } + async invoke(request: ChatRequestModelImpl): Promise { try { const languageModel = await this.getLanguageModel(this.defaultLanguageModelPurpose); @@ -189,6 +204,14 @@ export abstract class AbstractChatAgent { } } + protected parseContents(text: string): ChatResponseContent[] { + return parseContents( + text, + this.contentMatchers, + this.defaultContentFactory?.create.bind(this.defaultContentFactory) + ); + }; + protected handleError(request: ChatRequestModelImpl, error: Error): void { request.response.response.addContent(new ErrorChatResponseContentImpl(error)); request.response.error(error); @@ -281,9 +304,8 @@ export abstract class AbstractStreamParsingChatAgent extends AbstractChatAgent { protected override async addContentsToResponse(languageModelResponse: LanguageModelResponse, request: ChatRequestModelImpl): Promise { if (isLanguageModelTextResponse(languageModelResponse)) { - request.response.response.addContent( - new MarkdownChatResponseContentImpl(languageModelResponse.text) - ); + const contents = this.parseContents(languageModelResponse.text); + request.response.response.addContents(contents); request.response.complete(); this.recordingService.recordResponse({ agentId: this.id, @@ -295,57 +317,7 @@ export abstract class AbstractStreamParsingChatAgent extends AbstractChatAgent { return; } if (isLanguageModelStreamResponse(languageModelResponse)) { - for await (const token of languageModelResponse.stream) { - const newContents = this.parse(token, request.response.response.content); - if (isArray(newContents)) { - newContents.forEach(newContent => request.response.response.addContent(newContent)); - } else { - request.response.response.addContent(newContents); - } - - const lastContent = request.response.response.content.pop(); - if (lastContent === undefined) { - return; - } - const text = lastContent.asString?.(); - if (text === undefined) { - return; - } - let curSearchIndex = 0; - const result: ChatResponseContent[] = []; - while (curSearchIndex < text.length) { - // find start of code block: ```[language]\n[\n]``` - const codeStartIndex = text.indexOf('```', curSearchIndex); - if (codeStartIndex === -1) { - break; - } - - // find language specifier if present - const newLineIndex = text.indexOf('\n', codeStartIndex + 3); - const language = codeStartIndex + 3 < newLineIndex ? text.substring(codeStartIndex + 3, newLineIndex) : undefined; - - // find end of code block - const codeEndIndex = text.indexOf('```', codeStartIndex + 3); - if (codeEndIndex === -1) { - break; - } - - // add text before code block as markdown content - result.push(new MarkdownChatResponseContentImpl(text.substring(curSearchIndex, codeStartIndex))); - // add code block as code content - const codeText = text.substring(newLineIndex + 1, codeEndIndex).trimEnd(); - result.push(new CodeChatResponseContentImpl(codeText, language)); - curSearchIndex = codeEndIndex + 3; - } - - if (result.length > 0) { - result.forEach(r => { - request.response.response.addContent(r); - }); - } else { - request.response.response.addContent(lastContent); - } - } + await this.addStreamResponse(languageModelResponse, request); request.response.complete(); this.recordingService.recordResponse({ agentId: this.id, @@ -366,11 +338,38 @@ export abstract class AbstractStreamParsingChatAgent extends AbstractChatAgent { ); } - private parse(token: LanguageModelStreamResponsePart, previousContent: ChatResponseContent[]): ChatResponseContent | ChatResponseContent[] { + protected async addStreamResponse(languageModelResponse: LanguageModelStreamResponse, request: ChatRequestModelImpl): Promise { + for await (const token of languageModelResponse.stream) { + const newContents = this.parse(token, request.response.response.content); + if (isArray(newContents)) { + request.response.response.addContents(newContents); + } else { + request.response.response.addContent(newContents); + } + + const lastContent = request.response.response.content.pop(); + if (lastContent === undefined) { + return; + } + const text = lastContent.asString?.(); + if (text === undefined) { + return; + } + + const result: ChatResponseContent[] = findFirstMatch(this.contentMatchers, text) ? this.parseContents(text) : []; + if (result.length > 0) { + request.response.response.addContents(result); + } else { + request.response.response.addContent(lastContent); + } + } + } + + protected parse(token: LanguageModelStreamResponsePart, previousContent: ChatResponseContent[]): ChatResponseContent | ChatResponseContent[] { const content = token.content; // eslint-disable-next-line no-null/no-null if (content !== undefined && content !== null) { - return new MarkdownChatResponseContentImpl(content); + return this.defaultContentFactory.create(content); } const toolCalls = token.tool_calls; if (toolCalls !== undefined) { @@ -378,7 +377,7 @@ export abstract class AbstractStreamParsingChatAgent extends AbstractChatAgent { new ToolCallChatResponseContentImpl(toolCall.id, toolCall.function?.name, toolCall.function?.arguments, toolCall.finished, toolCall.result)); return toolCallContents; } - return new MarkdownChatResponseContentImpl(''); + return this.defaultContentFactory.create(''); } } diff --git a/packages/ai-chat/src/common/chat-model.ts b/packages/ai-chat/src/common/chat-model.ts index 777cb0bac21e7..c485c69c7c4c8 100644 --- a/packages/ai-chat/src/common/chat-model.ts +++ b/packages/ai-chat/src/common/chat-model.ts @@ -601,10 +601,20 @@ class ChatResponseImpl implements ChatResponse { return this._content; } + addContents(contents: ChatResponseContent[]): void { + contents.forEach(c => this.doAddContent(c)); + this._onDidChangeEmitter.fire(); + } + addContent(nextContent: ChatResponseContent): void { // TODO: Support more complex merges affecting different content than the last, e.g. via some kind of ProcessorRegistry // TODO: Support more of the built-in VS Code behavior, see // https://github.com/microsoft/vscode/blob/a2cab7255c0df424027be05d58e1b7b941f4ea60/src/vs/workbench/contrib/chat/common/chatModel.ts#L188-L244 + this.doAddContent(nextContent); + this._onDidChangeEmitter.fire(); + } + + protected doAddContent(nextContent: ChatResponseContent): void { if (ToolCallChatResponseContent.is(nextContent) && nextContent.id !== undefined) { const fittingTool = this._content.find(c => ToolCallChatResponseContent.is(c) && c.id === nextContent.id); if (fittingTool !== undefined) { @@ -613,10 +623,9 @@ class ChatResponseImpl implements ChatResponse { this._content.push(nextContent); } } else { - const lastElement = - this._content.length > 0 - ? this._content[this._content.length - 1] - : undefined; + const lastElement = this._content.length > 0 + ? this._content[this._content.length - 1] + : undefined; if (lastElement?.kind === nextContent.kind && ChatResponseContent.hasMerge(lastElement)) { const mergeSuccess = lastElement.merge(nextContent); if (!mergeSuccess) { @@ -627,7 +636,6 @@ class ChatResponseImpl implements ChatResponse { } } this._updateResponseRepresentation(); - this._onDidChangeEmitter.fire(); } protected _updateResponseRepresentation(): void { diff --git a/packages/ai-chat/src/common/parse-contents.spec.ts b/packages/ai-chat/src/common/parse-contents.spec.ts new file mode 100644 index 0000000000000..c0a009f8cb814 --- /dev/null +++ b/packages/ai-chat/src/common/parse-contents.spec.ts @@ -0,0 +1,142 @@ +// ***************************************************************************** +// Copyright (C) 2024 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import { expect } from 'chai'; +import { ChatResponseContent, CodeChatResponseContentImpl, MarkdownChatResponseContentImpl } from './chat-model'; +import { parseContents } from './parse-contents'; +import { CodeContentMatcher, ResponseContentMatcher } from './response-content-matcher'; + +export class CommandChatResponseContentImpl implements ChatResponseContent { + constructor(public readonly command: string) { } + kind = 'command'; +} + +export const CommandContentMatcher: ResponseContentMatcher = { + start: /^$/m, + end: /^<\/command>$/m, + contentFactory: (content: string) => { + const code = content.replace(/^\n|<\/command>$/g, ''); + return new CommandChatResponseContentImpl(code.trim()); + } +}; + +describe('parseContents', () => { + it('should parse code content', () => { + const text = '```typescript\nconsole.log("Hello World");\n```'; + const result = parseContents(text); + expect(result).to.deep.equal([new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript')]); + }); + + it('should parse markdown content', () => { + const text = 'Hello **World**'; + const result = parseContents(text); + expect(result).to.deep.equal([new MarkdownChatResponseContentImpl('Hello **World**')]); + }); + + it('should parse multiple content blocks', () => { + const text = '```typescript\nconsole.log("Hello World");\n```\nHello **World**'; + const result = parseContents(text); + expect(result).to.deep.equal([ + new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript'), + new MarkdownChatResponseContentImpl('\nHello **World**') + ]); + }); + + it('should parse multiple content blocks with different languages', () => { + const text = '```typescript\nconsole.log("Hello World");\n```\n```python\nprint("Hello World")\n```'; + const result = parseContents(text); + expect(result).to.deep.equal([ + new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript'), + new CodeChatResponseContentImpl('print("Hello World")', 'python') + ]); + }); + + it('should parse multiple content blocks with different languages and markdown', () => { + const text = '```typescript\nconsole.log("Hello World");\n```\nHello **World**\n```python\nprint("Hello World")\n```'; + const result = parseContents(text); + expect(result).to.deep.equal([ + new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript'), + new MarkdownChatResponseContentImpl('\nHello **World**\n'), + new CodeChatResponseContentImpl('print("Hello World")', 'python') + ]); + }); + + it('should parse content blocks with empty content', () => { + const text = '```typescript\n```\nHello **World**\n```python\nprint("Hello World")\n```'; + const result = parseContents(text); + expect(result).to.deep.equal([ + new CodeChatResponseContentImpl('', 'typescript'), + new MarkdownChatResponseContentImpl('\nHello **World**\n'), + new CodeChatResponseContentImpl('print("Hello World")', 'python') + ]); + }); + + it('should parse content with markdown, code, and markdown', () => { + const text = 'Hello **World**\n```typescript\nconsole.log("Hello World");\n```\nGoodbye **World**'; + const result = parseContents(text); + expect(result).to.deep.equal([ + new MarkdownChatResponseContentImpl('Hello **World**\n'), + new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript'), + new MarkdownChatResponseContentImpl('\nGoodbye **World**') + ]); + }); + + it('should handle text with no special content', () => { + const text = 'Just some plain text.'; + const result = parseContents(text); + expect(result).to.deep.equal([new MarkdownChatResponseContentImpl('Just some plain text.')]); + }); + + it('should handle text with only start code block', () => { + const text = '```typescript\nconsole.log("Hello World");'; + const result = parseContents(text); + expect(result).to.deep.equal([new MarkdownChatResponseContentImpl('```typescript\nconsole.log("Hello World");')]); + }); + + it('should handle text with only end code block', () => { + const text = 'console.log("Hello World");\n```'; + const result = parseContents(text); + expect(result).to.deep.equal([new MarkdownChatResponseContentImpl('console.log("Hello World");\n```')]); + }); + + it('should handle text with unmatched code block', () => { + const text = '```typescript\nconsole.log("Hello World");\n```\n```python\nprint("Hello World")'; + const result = parseContents(text); + expect(result).to.deep.equal([ + new CodeChatResponseContentImpl('console.log("Hello World");', 'typescript'), + new MarkdownChatResponseContentImpl('\n```python\nprint("Hello World")') + ]); + }); + + it('should parse code block without newline after language', () => { + const text = '```typescript console.log("Hello World");```'; + const result = parseContents(text); + expect(result).to.deep.equal([ + new MarkdownChatResponseContentImpl('```typescript console.log("Hello World");```') + ]); + }); + + it('should parse with matches of multiple different matchers and default', () => { + const text = '\nMY_SPECIAL_COMMAND\n\nHello **World**\n```python\nprint("Hello World")\n```\n\nMY_SPECIAL_COMMAND2\n'; + const result = parseContents(text, [CodeContentMatcher, CommandContentMatcher]); + expect(result).to.deep.equal([ + new CommandChatResponseContentImpl('MY_SPECIAL_COMMAND'), + new MarkdownChatResponseContentImpl('\nHello **World**\n'), + new CodeChatResponseContentImpl('print("Hello World")', 'python'), + new CommandChatResponseContentImpl('MY_SPECIAL_COMMAND2'), + ]); + }); +}); diff --git a/packages/ai-chat/src/common/parse-contents.ts b/packages/ai-chat/src/common/parse-contents.ts new file mode 100644 index 0000000000000..16f405495ce20 --- /dev/null +++ b/packages/ai-chat/src/common/parse-contents.ts @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2024 EclipseSource GmbH. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 + */ +import { ChatResponseContent } from './chat-model'; +import { CodeContentMatcher, MarkdownContentFactory, ResponseContentFactory, ResponseContentMatcher } from './response-content-matcher'; + +interface Match { + matcher: ResponseContentMatcher; + index: number; + content: string; +} + +export function parseContents( + text: string, + contentMatchers: ResponseContentMatcher[] = [CodeContentMatcher], + defaultContentFactory: ResponseContentFactory = MarkdownContentFactory +): ChatResponseContent[] { + const result: ChatResponseContent[] = []; + + let currentIndex = 0; + while (currentIndex < text.length) { + const remainingText = text.substring(currentIndex); + const match = findFirstMatch(contentMatchers, remainingText); + if (!match) { + // Add the remaining text as default content + if (remainingText.length > 0) { + result.push(defaultContentFactory(remainingText)); + } + break; + } + // We have a match + // 1. Add preceding text as default content + if (match.index > 0) { + const precedingContent = remainingText.substring(0, match.index); + if (precedingContent.trim().length > 0) { + result.push(defaultContentFactory(precedingContent)); + } + } + // 2. Add the matched content object + result.push(match.matcher.contentFactory(match.content)); + // Update currentIndex to the end of the end of the match + // And continue with the search after the end of the match + currentIndex += match.index + match.content.length; + } + + return result; +} + +export function findFirstMatch(contentMatchers: ResponseContentMatcher[], text: string): Match | undefined { + let firstMatch: { matcher: ResponseContentMatcher, index: number, content: string } | undefined; + for (const matcher of contentMatchers) { + const startMatch = matcher.start.exec(text); + if (!startMatch) { + // No start match found, try next matcher. + continue; + } + const endOfStartMatch = startMatch.index + startMatch[0].length; + if (endOfStartMatch >= text.length) { + // There is no text after the start match. + // No need to search for the end match yet, try next matcher. + continue; + } + const remainingTextAfterStartMatch = text.substring(endOfStartMatch); + const endMatch = matcher.end.exec(remainingTextAfterStartMatch); + if (!endMatch) { + // No end match found, try next matcher. + continue; + } + // Found start and end match. + // Record the full match, if it is the earliest found so far. + const index = startMatch.index; + const contentEnd = index + startMatch[0].length + endMatch.index + endMatch[0].length; + const content = text.substring(index, contentEnd); + if (!firstMatch || index < firstMatch.index) { + firstMatch = { matcher, index, content }; + } + } + return firstMatch; +} + diff --git a/packages/ai-chat/src/common/response-content-matcher.ts b/packages/ai-chat/src/common/response-content-matcher.ts new file mode 100644 index 0000000000000..3fb785e603c5f --- /dev/null +++ b/packages/ai-chat/src/common/response-content-matcher.ts @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2024 EclipseSource GmbH. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License v. 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0. + * + * This Source Code may also be made available under the following Secondary + * Licenses when the conditions for such availability set forth in the Eclipse + * Public License v. 2.0 are satisfied: GNU General Public License, version 2 + * with the GNU Classpath Exception which is available at + * https://www.gnu.org/software/classpath/license.html. + * + * SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 + */ +import { + ChatResponseContent, + CodeChatResponseContentImpl, + MarkdownChatResponseContentImpl +} from './chat-model'; +import { injectable } from '@theia/core/shared/inversify'; + +export type ResponseContentFactory = (content: string) => ChatResponseContent; + +export const MarkdownContentFactory: ResponseContentFactory = (content: string) => + new MarkdownChatResponseContentImpl(content); + +/** + * Default response content factory used if no other `ResponseContentMatcher` applies. + * By default, this factory creates a markdown content object. + * + * @see MarkdownChatResponseContentImpl + */ +@injectable() +export class DefaultResponseContentFactory { + create(content: string): ChatResponseContent { + return MarkdownContentFactory(content); + } +} + +/** + * Clients can contribute response content matchers to parse a chat response into specific + * `ChatResponseContent` instances. + */ +export interface ResponseContentMatcher { + /** Regular expression for finding the start delimiter. */ + start: RegExp; + /** Regular expression for finding the start delimiter. */ + end: RegExp; + /** + * The factory creating a response content from the matching content, + * from start index to end index of the match (including delimiters). + */ + contentFactory: ResponseContentFactory; +} + +export const CodeContentMatcher: ResponseContentMatcher = { + start: /^```.*?$/m, + end: /^```$/m, + contentFactory: (content: string) => { + const language = content.match(/^```(\w+)/)?.[1] || ''; + const code = content.replace(/^```(\w+)\n|```$/g, ''); + return new CodeChatResponseContentImpl(code.trim(), language); + } +}; + +/** + * Clients can contribute response content matchers to parse the response content. + * + * The default chat user interface will collect all contributed matchers and use them + * to parse the response into structured content parts (e.g. code blocks, markdown blocks), + * which are then rendered with a `ChatResponsePartRenderer` registered for the respective + * content part type. + * + * ### Example + * ```ts + * bind(ResponseContentMatcherProvider).to(MyResponseContentMatcherProvider); + * ... + * @injectable() + * export class MyResponseContentMatcherProvider implements ResponseContentMatcherProvider { + * readonly matchers: ResponseContentMatcher[] = [{ + * start: /^$/m, + * end: /^$/m, + * contentFactory: (content: string) => { + * const command = content.replace(/^\n|<\/command>$/g, ''); + * return new MyChatResponseContentImpl(command.trim()); + * } + * }]; + * } + * ``` + * + * @see ResponseContentMatcher + */ +export const ResponseContentMatcherProvider = Symbol('ResponseContentMatcherProvider'); +export interface ResponseContentMatcherProvider { + readonly matchers: ResponseContentMatcher[]; +} + +@injectable() +export class DefaultResponseContentMatcherProvider implements ResponseContentMatcherProvider { + readonly matchers: ResponseContentMatcher[] = [CodeContentMatcher]; +}