Skip to content

Commit 2c9ed83

Browse files
guguclaude
andcommitted
Stream AI reasoning as ephemeral thinking chunks
Backend emits NDJSON-framed chunks (thinking / thinking_reset / thinking_commit / text) so intermediate tool-loop reasoning is shown transiently in the AI panel and replaced between iterations, leaving only the final answer persistent in the chat history. Also fixes pre-existing biome lint violations in the touched files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0ac237d commit 2c9ed83

6 files changed

Lines changed: 110 additions & 37 deletions

File tree

backend/src/entities/ai/use-cases/request-info-from-table-with-ai-v7.use.case.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,8 @@ export class RequestInfoFromTableWithAIUseCaseV7
171171

172172
for await (const chunk of stream) {
173173
if (chunk.type === 'text' && chunk.content) {
174-
response.write(chunk.content);
175174
accumulatedContent += chunk.content;
176-
totalAccumulatedResponse += chunk.content;
175+
this.writeChunk(response, { type: 'thinking', content: chunk.content });
177176
}
178177

179178
if (chunk.type === 'tool_call' && chunk.toolCall) {
@@ -191,13 +190,15 @@ export class RequestInfoFromTableWithAIUseCaseV7
191190
);
192191

193192
if (pendingToolCalls.length === 0) {
193+
this.writeChunk(response, { type: 'thinking_commit' });
194+
totalAccumulatedResponse += accumulatedContent;
194195
break;
195196
}
196197

198+
this.writeChunk(response, { type: 'thinking_reset' });
199+
197200
for (const toolCall of pendingToolCalls) {
198-
this.logger.log(
199-
`Tool call: ${toolCall.name}, arguments=${JSON.stringify(toolCall.arguments)}`,
200-
);
201+
this.logger.log(`Tool call: ${toolCall.name}, arguments=${JSON.stringify(toolCall.arguments)}`);
201202
}
202203

203204
const toolResults = await this.executeToolCalls(
@@ -209,9 +210,7 @@ export class RequestInfoFromTableWithAIUseCaseV7
209210
);
210211

211212
for (const toolResult of toolResults) {
212-
this.logger.log(
213-
`Tool result for ${toolResult.toolCallId}: resultLength=${toolResult.result.length}`,
214-
);
213+
this.logger.log(`Tool result for ${toolResult.toolCallId}: resultLength=${toolResult.result.length}`);
215214
}
216215

217216
if (this.aiProvider === AIProviderType.OPENAI && lastResponseId) {
@@ -227,9 +226,7 @@ export class RequestInfoFromTableWithAIUseCaseV7
227226

228227
depth++;
229228
} catch (loopError) {
230-
this.logger.error(
231-
`Error in tool loop at depth ${depth + 1}: ${loopError.message}`,
232-
);
229+
this.logger.error(`Error in tool loop at depth ${depth + 1}: ${loopError.message}`);
233230
throw loopError;
234231
}
235232
}
@@ -238,13 +235,24 @@ export class RequestInfoFromTableWithAIUseCaseV7
238235
this.logger.warn(`Tool loop reached max depth (${this.maxDepth})`);
239236
const maxDepthMessage =
240237
'\n\nYour question is too complex to process at this time. Please try simplifying it or breaking it down into smaller parts.';
241-
response.write(maxDepthMessage);
238+
this.writeChunk(response, { type: 'text', content: maxDepthMessage });
242239
totalAccumulatedResponse += maxDepthMessage;
243240
}
244241

245242
return { lastResponseId, accumulatedResponse: totalAccumulatedResponse };
246243
}
247244

245+
private writeChunk(
246+
response: Response,
247+
chunk:
248+
| { type: 'thinking'; content: string }
249+
| { type: 'thinking_reset' }
250+
| { type: 'thinking_commit' }
251+
| { type: 'text'; content: string },
252+
): void {
253+
response.write(JSON.stringify(chunk) + '\n');
254+
}
255+
248256
private async executeToolCalls(
249257
toolCalls: AIToolCall[],
250258
dataAccessObject: IDataAccessObject | IDataAccessObjectAgent,
@@ -306,9 +314,7 @@ export class RequestInfoFromTableWithAIUseCaseV7
306314
result = encodeError({ error: `Unknown tool: ${toolCall.name}` });
307315
}
308316
} catch (error) {
309-
this.logger.error(
310-
`Tool call ${toolCall.name} (${toolCall.id}) failed: ${error.message}`,
311-
);
317+
this.logger.error(`Tool call ${toolCall.name} (${toolCall.id}) failed: ${error.message}`);
312318
result = encodeError({ error: error.message });
313319
}
314320

frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,21 @@
298298
}
299299
}
300300

301+
.ai-thinking {
302+
font-size: 12px;
303+
line-height: 1.5;
304+
color: rgba(0, 0, 0, 0.5);
305+
font-style: italic;
306+
white-space: pre-wrap;
307+
word-break: break-word;
308+
}
309+
310+
@media (prefers-color-scheme: dark) {
311+
.ai-thinking {
312+
color: rgba(255, 255, 255, 0.5);
313+
}
314+
}
315+
301316
.ai-error-message {
302317
border-radius: 8px;
303318
padding: 8px 8px 0;

frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.html

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ <h2 class="mat-heading-2 ai-panel-sidebar__title">
99
<span *ngIf="!isExpanded">AI insights</span>
1010
</h2>
1111
<div class="ai-panel-sidebar__actions">
12-
<button mat-icon-button (click)="toggleExpand()">
12+
<button type="button" mat-icon-button (click)="toggleExpand()">
1313
<mat-icon>{{isExpanded ? 'close_fullscreen' : 'open_in_full'}}</mat-icon>
1414
</button>
15-
<button mat-icon-button (click)="handleClose()">
15+
<button type="button" mat-icon-button (click)="handleClose()">
1616
<mat-icon>close</mat-icon>
1717
</button>
1818
</div>
@@ -23,7 +23,10 @@ <h2 class="mat-heading-2 ai-panel-sidebar__title">
2323
<div class="ai-message-chain">
2424
<div *ngFor="let message of messagesChain" class="{{message.type}}-message">
2525
<span style="white-space: pre-wrap" *ngIf="message.type == 'user'">{{message.text}}</span>
26-
<markdown *ngIf="message.type == 'ai'" mermaid [data]="message.text"></markdown>
26+
<ng-container *ngIf="message.type == 'ai'">
27+
<div *ngIf="message.thinking && !message.text" class="ai-thinking">{{ message.thinking }}</div>
28+
<markdown *ngIf="message.text" mermaid [data]="message.text"></markdown>
29+
</ng-container>
2730
</div>
2831
</div>
2932
</div>
@@ -54,7 +57,7 @@ <h2 class="mat-heading-2 ai-panel-sidebar__title">
5457

5558
<div class="ai-completions" *ngIf="showCompletions && activeCompletions.length"
5659
(mouseleave)="onCompletionMouseLeave()">
57-
<button *ngFor="let completion of activeCompletions"
60+
<button type="button" *ngFor="let completion of activeCompletions"
5861
class="ai-completion-item"
5962
(mouseenter)="onCompletionHover(completion)"
6063
(mousedown)="selectCompletion(completion)">
@@ -67,7 +70,7 @@ <h2 class="mat-heading-2 ai-panel-sidebar__title">
6770
<div class="ai-category" *ngFor="let category of suggestionCategories">
6871
<p class="ai-category__title">{{category.title}}</p>
6972
<div class="ai-category__chips">
70-
<button *ngFor="let suggestion of category.suggestions"
73+
<button type="button" *ngFor="let suggestion of category.suggestions"
7174
class="suggestion-chip"
7275
[class.suggestion-chip_active]="activeSuggestion?.title === suggestion.title"
7376
(click)="onSuggestionChipClick(suggestion)">

frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { Angulartics2, Angulartics2Module } from 'angulartics2';
1919
import { MarkdownModule } from 'ngx-markdown';
2020
import posthog from 'posthog-js';
2121
import { AiService } from 'src/app/services/ai.service';
22+
import { AiStreamChunk } from 'src/app/services/api.service';
2223
import { ConnectionsService } from 'src/app/services/connections.service';
2324
import { TableStateService } from 'src/app/services/table-state.service';
2425
import { TablesService } from 'src/app/services/tables.service';
@@ -53,6 +54,7 @@ export class DbTableAiPanelComponent implements OnInit, AfterViewInit, OnDestroy
5354
public messagesChain: {
5455
type: string;
5556
text: string;
57+
thinking?: string;
5658
}[] = [];
5759
public aiSuggestions: { title: string; prompt: string; completions: string[] }[] = [];
5860
public suggestionCategories: {
@@ -69,7 +71,7 @@ export class DbTableAiPanelComponent implements OnInit, AfterViewInit, OnDestroy
6971

7072
private _aiService = inject(AiService);
7173
private _abortController: AbortController | null = null;
72-
private _loadingStepsInterval: any = null;
74+
private _loadingStepsInterval: ReturnType<typeof setInterval> | null = null;
7375
private _loadingSteps: string[] = [
7476
'Connecting to database',
7577
'Analyzing table structure',
@@ -109,7 +111,7 @@ export class DbTableAiPanelComponent implements OnInit, AfterViewInit, OnDestroy
109111

110112
async ngAfterViewInit() {
111113
const mermaid = await import('mermaid');
112-
const mermaidAPI: any = mermaid.default ?? mermaid;
114+
const mermaidAPI = (mermaid.default ?? mermaid) as { initialize: (config: Record<string, unknown>) => void };
113115
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
114116
mermaidAPI.initialize({
115117
startOnLoad: false,
@@ -216,7 +218,7 @@ export class DbTableAiPanelComponent implements OnInit, AfterViewInit, OnDestroy
216218

217219
if (response) {
218220
this.threadID = response.threadId;
219-
const aiMessage = { type: 'ai', text: '' };
221+
const aiMessage = { type: 'ai', text: '', thinking: '' };
220222
this.messagesChain.push(aiMessage);
221223
this.stopLoadingSteps();
222224

@@ -269,7 +271,7 @@ export class DbTableAiPanelComponent implements OnInit, AfterViewInit, OnDestroy
269271
);
270272

271273
if (stream) {
272-
const aiMessage = { type: 'ai', text: '' };
274+
const aiMessage = { type: 'ai', text: '', thinking: '' };
273275
this.messagesChain.push(aiMessage);
274276
this.stopLoadingSteps();
275277

@@ -296,9 +298,22 @@ export class DbTableAiPanelComponent implements OnInit, AfterViewInit, OnDestroy
296298
}
297299
}
298300

299-
private async _consumeStream(stream: AsyncGenerator<string>, message: { type: string; text: string }) {
301+
private async _consumeStream(
302+
stream: AsyncGenerator<AiStreamChunk>,
303+
message: { type: string; text: string; thinking?: string },
304+
) {
300305
for await (const chunk of stream) {
301-
message.text += chunk;
306+
if (chunk.type === 'thinking') {
307+
message.thinking = (message.thinking ?? '') + chunk.content;
308+
} else if (chunk.type === 'thinking_reset') {
309+
message.thinking = '';
310+
} else if (chunk.type === 'thinking_commit') {
311+
message.text += message.thinking ?? '';
312+
message.thinking = '';
313+
} else if (chunk.type === 'text') {
314+
message.text += chunk.content;
315+
message.thinking = '';
316+
}
302317
}
303318
}
304319

frontend/src/app/services/ai.service.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { Injectable, inject } from '@angular/core';
2-
import { ApiService } from './api.service';
2+
import { AiStreamChunk, ApiService } from './api.service';
33

44
export interface AiStreamResponse {
55
threadId: string;
6-
stream: AsyncGenerator<string>;
6+
stream: AsyncGenerator<AiStreamChunk>;
77
}
88

99
@Injectable({
@@ -38,7 +38,7 @@ export class AiService {
3838
threadId: string,
3939
message: string,
4040
signal?: AbortSignal,
41-
): Promise<AsyncGenerator<string> | null> {
41+
): Promise<AsyncGenerator<AiStreamChunk> | null> {
4242
const result = await this._api.postStream(
4343
`/ai/v4/request/${connectionId}`,
4444
{ user_message: message },

frontend/src/app/services/api.service.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,24 @@ export interface ApiResponse<T> {
1919
status: number;
2020
}
2121

22+
export type AiStreamChunk =
23+
| { type: 'thinking'; content: string }
24+
| { type: 'thinking_reset' }
25+
| { type: 'thinking_commit' }
26+
| { type: 'text'; content: string };
27+
28+
function tryParseChunk(line: string): AiStreamChunk | null {
29+
try {
30+
const parsed = JSON.parse(line);
31+
if (parsed && typeof parsed.type === 'string') {
32+
return parsed as AiStreamChunk;
33+
}
34+
} catch {
35+
// ignore malformed line
36+
}
37+
return null;
38+
}
39+
2240
@Injectable({
2341
providedIn: 'root',
2442
})
@@ -51,7 +69,7 @@ export class ApiService {
5169
return result?.data ?? null;
5270
}
5371

54-
async postResponse<T>(url: string, body?: unknown, options?: ApiRequestOptions): Promise<ApiResponse<T> | null> {
72+
postResponse<T>(url: string, body?: unknown, options?: ApiRequestOptions): Promise<ApiResponse<T> | null> {
5573
return this._fetchResponse<T>('POST', url, body, options);
5674
}
5775

@@ -69,7 +87,7 @@ export class ApiService {
6987
url: string,
7088
body?: unknown,
7189
options?: ApiRequestOptions,
72-
): Promise<{ headers: Headers; stream: AsyncGenerator<string> } | null> {
90+
): Promise<{ headers: Headers; stream: AsyncGenerator<AiStreamChunk> } | null> {
7391
try {
7492
const fullUrl = this._buildUrl(url, options?.params);
7593
const headers = this._buildHeaders(options?.headers);
@@ -96,19 +114,35 @@ export class ApiService {
96114
const reader = response.body.getReader();
97115
const decoder = new TextDecoder();
98116

99-
async function* textStream(): AsyncGenerator<string> {
117+
async function* chunkStream(): AsyncGenerator<AiStreamChunk> {
118+
let buffer = '';
100119
try {
101120
while (true) {
102121
const { done, value } = await reader.read();
103-
if (done) break;
104-
yield decoder.decode(value, { stream: true });
122+
if (done) {
123+
const tail = buffer.trim();
124+
if (tail) {
125+
const parsed = tryParseChunk(tail);
126+
if (parsed) yield parsed;
127+
}
128+
break;
129+
}
130+
buffer += decoder.decode(value, { stream: true });
131+
let newlineIdx: number;
132+
while ((newlineIdx = buffer.indexOf('\n')) >= 0) {
133+
const line = buffer.slice(0, newlineIdx).trim();
134+
buffer = buffer.slice(newlineIdx + 1);
135+
if (!line) continue;
136+
const parsed = tryParseChunk(line);
137+
if (parsed) yield parsed;
138+
}
105139
}
106140
} finally {
107141
reader.releaseLock();
108142
}
109143
}
110144

111-
return { headers: response.headers, stream: textStream() };
145+
return { headers: response.headers, stream: chunkStream() };
112146
} catch (err: unknown) {
113147
if ((err as Error).name === 'AbortError') return null;
114148
this._handleError(err, options);
@@ -188,15 +222,15 @@ export class ApiService {
188222

189223
const gclidMatch = document.cookie.match(/(?:^|;\s*)autoadmin_gclid=([^;]*)/);
190224
if (gclidMatch?.[1]) {
191-
headers['GCLID'] = gclidMatch[1];
225+
headers.GCLID = gclidMatch[1];
192226
}
193227

194228
const pathSegments = location.pathname.split('/');
195229
const connectionId = pathSegments.length >= 3 ? pathSegments[2] : null;
196230
if (connectionId) {
197231
const masterKey = localStorage.getItem(`${connectionId}__masterKey`);
198232
if (masterKey) {
199-
headers['masterpwd'] = masterKey;
233+
headers.masterpwd = masterKey;
200234
}
201235
}
202236

0 commit comments

Comments
 (0)