Skip to content

Commit 53a070b

Browse files
authored
feat(chat): enhance streaming content parsing for incomplete response content matches (#15387)
- Refactored `addStreamResponse` method to maintain a complete text buffer for streamed responses, improving handling of incomplete matches. - Introduced `ProgressChatResponseContent` interface and its implementation to support progress updates in chat responses via incomplete matches - Updated `parseContents` function to differentiate between complete and incomplete matches, allowing for better content management during streaming. - Added unit tests for new functionality in `parse-contents-stream.spec.ts` and updated existing tests to utilize custom matchers for code content parsing. Fixes #15386
1 parent a34dc23 commit 53a070b

12 files changed

+390
-65
lines changed

examples/api-samples/src/browser/chat/ask-and-continue-chat-agent-contribution.ts

+7-2
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ import {
2121
MutableChatRequestModel,
2222
lastProgressMessage,
2323
QuestionResponseContentImpl,
24-
unansweredQuestions
24+
unansweredQuestions,
25+
ProgressChatResponseContentImpl
2526
} from '@theia/ai-chat';
2627
import { Agent, LanguageModelMessage, PromptTemplate } from '@theia/ai-core';
2728
import { injectable, interfaces, postConstruct } from '@theia/core/shared/inversify';
@@ -129,10 +130,14 @@ export class AskAndContinueChatAgent extends AbstractStreamParsingChatAgent {
129130
contentFactory: (content: string, request: MutableChatRequestModel) => {
130131
const question = content.replace(/^<question>\n|<\/question>$/g, '');
131132
const parsedQuestion = JSON.parse(question);
133+
132134
return new QuestionResponseContentImpl(parsedQuestion.question, parsedQuestion.options, request, selectedOption => {
133135
this.handleAnswer(selectedOption, request);
134136
});
135-
}
137+
},
138+
incompleteContentFactory: (content: string, request: MutableChatRequestModel) =>
139+
// Display a progress indicator while the question is being parsed
140+
new ProgressChatResponseContentImpl('Preparing question...')
136141
});
137142
}
138143

packages/ai-chat-ui/src/browser/ai-chat-ui-frontend-module.ts

+2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
MarkdownPartRenderer,
3636
ToolCallPartRenderer,
3737
ThinkingPartRenderer,
38+
ProgressPartRenderer,
3839
} from './chat-response-renderer';
3940
import {
4041
GitHubSelectionResolver,
@@ -89,6 +90,7 @@ export default new ContainerModule((bind, _unbind, _isBound, rebind) => {
8990
bind(ChatResponsePartRenderer).to(ErrorPartRenderer).inSingletonScope();
9091
bind(ChatResponsePartRenderer).to(ThinkingPartRenderer).inSingletonScope();
9192
bind(ChatResponsePartRenderer).to(QuestionPartRenderer).inSingletonScope();
93+
bind(ChatResponsePartRenderer).to(ProgressPartRenderer).inSingletonScope();
9294
[CommandContribution, MenuContribution].forEach(serviceIdentifier =>
9395
bind(serviceIdentifier).to(ChatViewMenuContribution).inSingletonScope()
9496
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// *****************************************************************************
2+
// Copyright (C) 2025 EclipseSource GmbH.
3+
//
4+
// This program and the accompanying materials are made available under the
5+
// terms of the Eclipse Public License v. 2.0 which is available at
6+
// http://www.eclipse.org/legal/epl-2.0.
7+
//
8+
// This Source Code may also be made available under the following Secondary
9+
// Licenses when the conditions for such availability set forth in the Eclipse
10+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
11+
// with the GNU Classpath Exception which is available at
12+
// https://www.gnu.org/software/classpath/license.html.
13+
//
14+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15+
// *****************************************************************************
16+
17+
import { ChatProgressMessage } from '@theia/ai-chat';
18+
import * as React from '@theia/core/shared/react';
19+
20+
export type ProgressMessageProps = Omit<ChatProgressMessage, 'kind' | 'id' | 'show'>;
21+
22+
export const ProgressMessage = (c: ProgressMessageProps) => (
23+
<div className='theia-ResponseNode-ProgressMessage'>
24+
<Indicator {...c} /> {c.content}
25+
</div>
26+
);
27+
28+
export const Indicator = (progressMessage: ProgressMessageProps) => (
29+
<span className='theia-ResponseNode-ProgressMessage-Indicator'>
30+
{progressMessage.status === 'inProgress' &&
31+
<i className={'fa fa-spinner fa-spin ' + progressMessage.status}></i>
32+
}
33+
{progressMessage.status === 'completed' &&
34+
<i className={'fa fa-check ' + progressMessage.status}></i>
35+
}
36+
{progressMessage.status === 'failed' &&
37+
<i className={'fa fa-warning ' + progressMessage.status}></i>
38+
}
39+
</span>
40+
);

packages/ai-chat-ui/src/browser/chat-response-renderer/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ export * from './markdown-part-renderer';
2222
export * from './text-part-renderer';
2323
export * from './toolcall-part-renderer';
2424
export * from './thinking-part-renderer';
25+
export * from './progress-part-renderer';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// *****************************************************************************
2+
// Copyright (C) 2025 EclipseSource GmbH.
3+
//
4+
// This program and the accompanying materials are made available under the
5+
// terms of the Eclipse Public License v. 2.0 which is available at
6+
// http://www.eclipse.org/legal/epl-2.0.
7+
//
8+
// This Source Code may also be made available under the following Secondary
9+
// Licenses when the conditions for such availability set forth in the Eclipse
10+
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
11+
// with the GNU Classpath Exception which is available at
12+
// https://www.gnu.org/software/classpath/license.html.
13+
//
14+
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15+
// *****************************************************************************
16+
17+
import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
18+
import { injectable } from '@theia/core/shared/inversify';
19+
import { ChatResponseContent, ProgressChatResponseContent } from '@theia/ai-chat/lib/common';
20+
import { ReactNode } from '@theia/core/shared/react';
21+
import * as React from '@theia/core/shared/react';
22+
import { ProgressMessage } from '../chat-progress-message';
23+
24+
@injectable()
25+
export class ProgressPartRenderer implements ChatResponsePartRenderer<ProgressChatResponseContent> {
26+
27+
canHandle(response: ChatResponseContent): number {
28+
if (ProgressChatResponseContent.is(response)) {
29+
return 10;
30+
}
31+
return -1;
32+
}
33+
34+
render(response: ProgressChatResponseContent): ReactNode {
35+
return (
36+
<ProgressMessage content={response.message} status='inProgress' />
37+
);
38+
}
39+
40+
}

packages/ai-chat-ui/src/browser/chat-tree-view/chat-view-tree-widget.tsx

+1-21
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
ChatAgent,
1818
ChatAgentService,
1919
ChatModel,
20-
ChatProgressMessage,
2120
ChatRequestModel,
2221
ChatResponseContent,
2322
ChatResponseModel,
@@ -53,6 +52,7 @@ import { ChatNodeToolbarActionContribution } from '../chat-node-toolbar-action-c
5352
import { ChatResponsePartRenderer } from '../chat-response-part-renderer';
5453
import { useMarkdownRendering } from '../chat-response-renderer/markdown-part-renderer';
5554
import { AIVariableService } from '@theia/ai-core';
55+
import { ProgressMessage } from '../chat-progress-message';
5656

5757
// TODO Instead of directly operating on the ChatRequestModel we could use an intermediate view model
5858
export interface RequestNode extends TreeNode {
@@ -474,23 +474,3 @@ const HoverableLabel = (
474474
</span>
475475
);
476476
};
477-
478-
const ProgressMessage = (c: ChatProgressMessage) => (
479-
<div className='theia-ResponseNode-ProgressMessage'>
480-
<Indicator {...c} /> {c.content}
481-
</div>
482-
);
483-
484-
const Indicator = (progressMessage: ChatProgressMessage) => (
485-
<span className='theia-ResponseNode-ProgressMessage-Indicator'>
486-
{progressMessage.status === 'inProgress' &&
487-
<i className={'fa fa-spinner fa-spin ' + progressMessage.status}></i>
488-
}
489-
{progressMessage.status === 'completed' &&
490-
<i className={'fa fa-check ' + progressMessage.status}></i>
491-
}
492-
{progressMessage.status === 'failed' &&
493-
<i className={'fa fa-warning ' + progressMessage.status}></i>
494-
}
495-
</span>
496-
);

packages/ai-chat/src/common/chat-agents.ts

+28-21
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ import {
6060
ChatRequestModel,
6161
ThinkingChatResponseContentImpl
6262
} from './chat-model';
63-
import { findFirstMatch, parseContents } from './parse-contents';
63+
import { parseContents } from './parse-contents';
6464
import { DefaultResponseContentFactory, ResponseContentMatcher, ResponseContentMatcherProvider } from './response-content-matcher';
6565
import { ChatToolRequest, ChatToolRequestService } from './chat-tool-request-service';
6666

@@ -389,28 +389,35 @@ export abstract class AbstractStreamParsingChatAgent extends AbstractChatAgent {
389389
}
390390

391391
protected async addStreamResponse(languageModelResponse: LanguageModelStreamResponse, request: MutableChatRequestModel): Promise<void> {
392-
for await (const token of languageModelResponse.stream) {
393-
const newContents = this.parse(token, request);
394-
if (isArray(newContents)) {
395-
request.response.response.addContents(newContents);
396-
} else {
397-
request.response.response.addContent(newContents);
398-
}
392+
let completeTextBuffer = '';
393+
let startIndex = Math.max(0, request.response.response.content.length - 1);
399394

400-
const lastContent = request.response.response.content.pop();
401-
if (lastContent === undefined) {
402-
return;
403-
}
404-
const text = lastContent.asString?.();
405-
if (text === undefined) {
406-
return;
407-
}
408-
409-
const result: ChatResponseContent[] = findFirstMatch(this.contentMatchers, text) ? this.parseContents(text, request) : [];
410-
if (result.length > 0) {
411-
request.response.response.addContents(result);
395+
for await (const token of languageModelResponse.stream) {
396+
const newContent = this.parse(token, request);
397+
398+
if (!(isTextResponsePart(token) && token.content)) {
399+
// For non-text tokens (like tool calls), add them directly
400+
if (isArray(newContent)) {
401+
request.response.response.addContents(newContent);
402+
} else {
403+
request.response.response.addContent(newContent);
404+
}
405+
// And reset the marker index and the text buffer as we skip matching across non-text tokens
406+
startIndex = request.response.response.content.length - 1;
407+
completeTextBuffer = '';
412408
} else {
413-
request.response.response.addContent(lastContent);
409+
// parse the entire text so far (since beginning of the stream or last non-text token)
410+
// and replace the entire content with the currently parsed content parts
411+
completeTextBuffer += token.content;
412+
413+
const parsedContents = this.parseContents(completeTextBuffer, request);
414+
const contentBeforeMarker = startIndex > 0
415+
? request.response.response.content.slice(0, startIndex)
416+
: [];
417+
418+
request.response.response.clearContent();
419+
request.response.response.addContents(contentBeforeMarker);
420+
request.response.response.addContents(parsedContents);
414421
}
415422
}
416423
}

packages/ai-chat/src/common/chat-model.ts

+60
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,12 @@ export interface ThinkingChatResponseContent
278278
signature: string;
279279
}
280280

281+
export interface ProgressChatResponseContent
282+
extends Required<ChatResponseContent> {
283+
kind: 'progress';
284+
message: string;
285+
}
286+
281287
export interface Location {
282288
uri: URI;
283289
position: Position;
@@ -414,6 +420,17 @@ export namespace ThinkingChatResponseContent {
414420
}
415421
}
416422

423+
export namespace ProgressChatResponseContent {
424+
export function is(obj: unknown): obj is ProgressChatResponseContent {
425+
return (
426+
ChatResponseContent.is(obj) &&
427+
obj.kind === 'progress' &&
428+
'message' in obj &&
429+
typeof obj.message === 'string'
430+
);
431+
}
432+
}
433+
417434
export type QuestionResponseHandler = (
418435
selectedOption: { text: string, value?: string },
419436
) => void;
@@ -1127,6 +1144,12 @@ class ChatResponseImpl implements ChatResponse {
11271144
return this._content;
11281145
}
11291146

1147+
clearContent(): void {
1148+
this._content = [];
1149+
this._updateResponseRepresentation();
1150+
this._onDidChangeEmitter.fire();
1151+
}
1152+
11301153
addContents(contents: ChatResponseContent[]): void {
11311154
contents.forEach(c => this.doAddContent(c));
11321155
this._onDidChangeEmitter.fire();
@@ -1353,3 +1376,40 @@ export class ErrorChatResponseModel extends MutableChatResponseModel {
13531376
this.error(error);
13541377
}
13551378
}
1379+
1380+
export class ProgressChatResponseContentImpl implements ProgressChatResponseContent {
1381+
readonly kind = 'progress';
1382+
protected _message: string;
1383+
1384+
constructor(message: string) {
1385+
this._message = message;
1386+
}
1387+
1388+
get message(): string {
1389+
return this._message;
1390+
}
1391+
1392+
asString(): string {
1393+
return JSON.stringify({
1394+
type: 'progress',
1395+
message: this.message
1396+
});
1397+
}
1398+
1399+
asDisplayString(): string | undefined {
1400+
return `<Progress>${this.message}</Progress>`;
1401+
}
1402+
1403+
merge(nextChatResponseContent: ProgressChatResponseContent): boolean {
1404+
this._message = nextChatResponseContent.message;
1405+
return true;
1406+
}
1407+
1408+
toLanguageModelMessage(): TextMessage {
1409+
return {
1410+
actor: 'ai',
1411+
type: 'text',
1412+
text: this.message
1413+
};
1414+
}
1415+
}

0 commit comments

Comments
 (0)