Skip to content

Commit 0c7a42a

Browse files
Claudehotlong
andauthored
Implement AI Chat Thinking Phase Improvements
- Extended backend stream encoder to support reasoning-start, reasoning-delta, reasoning-end events - Added support for custom step-start and step-finish events - Created collapsible ReasoningDisplay component to show model thinking steps - Added StepProgress component for multi-step workflow indicators - Enhanced tool invocation display with "Planning to call..." state for input-streaming - Updated tests to cover new stream event types - All tests passing and builds successful Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/f6c1f48a-dcb6-4e73-9f00-c2ff06561c2e Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 3f8dfb5 commit 0c7a42a

File tree

3 files changed

+313
-4
lines changed

3 files changed

+313
-4
lines changed

apps/studio/src/components/AiChatPanel.tsx

Lines changed: 228 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { UIMessage } from 'ai';
77
import {
88
Bot, X, Send, Trash2, Sparkles,
99
Wrench, CheckCircle2, XCircle, Loader2, ShieldAlert,
10+
ChevronDown, ChevronRight, Brain, Zap,
1011
} from 'lucide-react';
1112
import { Button } from '@/components/ui/button';
1213
import { ScrollArea } from '@/components/ui/scroll-area';
@@ -33,6 +34,47 @@ interface AgentSummary {
3334
role: string;
3435
}
3536

37+
/**
38+
* Extended stream event types for reasoning and steps.
39+
* These extend the standard Vercel AI SDK stream events.
40+
*/
41+
interface ReasoningStartEvent {
42+
type: 'reasoning-start';
43+
id: string;
44+
}
45+
46+
interface ReasoningDeltaEvent {
47+
type: 'reasoning-delta';
48+
id: string;
49+
delta: string;
50+
}
51+
52+
interface ReasoningEndEvent {
53+
type: 'reasoning-end';
54+
id: string;
55+
}
56+
57+
interface StepStartEvent {
58+
type: 'step-start';
59+
stepId: string;
60+
stepName: string;
61+
}
62+
63+
interface StepFinishEvent {
64+
type: 'step-finish';
65+
stepId: string;
66+
stepName: string;
67+
}
68+
69+
/**
70+
* Track active thinking/reasoning state during streaming.
71+
*/
72+
interface ThinkingState {
73+
reasoning: string[];
74+
activeSteps: Map<string, { stepName: string; startedAt: number }>;
75+
completedSteps: string[];
76+
}
77+
3678
/**
3779
* Extract the text content from a UIMessage's parts array.
3880
*/
@@ -160,6 +202,88 @@ function useAgentList(baseUrl: string) {
160202

161203
// ── Tool Invocation State Labels ────────────────────────────────────
162204

205+
/**
206+
* Display reasoning/thinking information in a collapsible section.
207+
*/
208+
interface ReasoningDisplayProps {
209+
reasoning: string[];
210+
}
211+
212+
function ReasoningDisplay({ reasoning }: ReasoningDisplayProps) {
213+
const [isExpanded, setIsExpanded] = useState(false);
214+
215+
if (reasoning.length === 0) return null;
216+
217+
return (
218+
<div
219+
data-testid="reasoning-display"
220+
className="flex flex-col gap-1 rounded-md border border-border/30 bg-muted/30 px-2.5 py-2 text-xs"
221+
>
222+
<button
223+
onClick={() => setIsExpanded(!isExpanded)}
224+
className="flex items-center gap-1.5 text-left text-muted-foreground hover:text-foreground transition-colors"
225+
>
226+
{isExpanded ? (
227+
<ChevronDown className="h-3 w-3 shrink-0" />
228+
) : (
229+
<ChevronRight className="h-3 w-3 shrink-0" />
230+
)}
231+
<Brain className="h-3 w-3 shrink-0" />
232+
<span className="font-medium">Thinking</span>
233+
<span className="text-[10px] opacity-60">
234+
({reasoning.length} step{reasoning.length !== 1 ? 's' : ''})
235+
</span>
236+
</button>
237+
{isExpanded && (
238+
<div className="mt-1 space-y-1 pl-5 text-muted-foreground italic border-l-2 border-border/30">
239+
{reasoning.map((step, idx) => (
240+
<p key={idx} className="text-[11px] leading-relaxed">
241+
{step}
242+
</p>
243+
))}
244+
</div>
245+
)}
246+
</div>
247+
);
248+
}
249+
250+
/**
251+
* Display active step progress indicators.
252+
*/
253+
interface StepProgressProps {
254+
activeSteps: Map<string, { stepName: string; startedAt: number }>;
255+
completedSteps: string[];
256+
}
257+
258+
function StepProgress({ activeSteps, completedSteps }: StepProgressProps) {
259+
if (activeSteps.size === 0) return null;
260+
261+
const totalSteps = completedSteps.length + activeSteps.size;
262+
const currentStep = completedSteps.length + 1;
263+
264+
return (
265+
<div
266+
data-testid="step-progress"
267+
className="flex flex-col gap-1.5 rounded-md border border-blue-500/30 bg-blue-500/5 px-2.5 py-2 text-xs"
268+
>
269+
<div className="flex items-center gap-2">
270+
<Zap className="h-3 w-3 shrink-0 text-blue-600 dark:text-blue-400" />
271+
<span className="font-medium text-blue-700 dark:text-blue-300">
272+
Step {currentStep} of {totalSteps}
273+
</span>
274+
</div>
275+
{Array.from(activeSteps.values()).map((step, idx) => (
276+
<div key={idx} className="flex items-center gap-2 pl-5">
277+
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-blue-600 dark:text-blue-400" />
278+
<span className="text-blue-700 dark:text-blue-300">{step.stepName}</span>
279+
</div>
280+
))}
281+
</div>
282+
);
283+
}
284+
285+
// ── Tool Invocation State Labels ────────────────────────────────────
286+
163287
interface ToolInvocationDisplayProps {
164288
part: Extract<UIMessage['parts'][number], { type: 'dynamic-tool' }>;
165289
onApprove?: (approvalId: string) => void;
@@ -175,6 +299,21 @@ function ToolInvocationDisplay({ part, onApprove, onDeny }: ToolInvocationDispla
175299

176300
switch (part.state) {
177301
case 'input-streaming':
302+
return (
303+
<div
304+
data-testid="tool-invocation-planning"
305+
className="flex items-start gap-2 rounded-md border border-blue-500/40 bg-blue-500/10 px-2.5 py-2 text-xs"
306+
>
307+
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin text-blue-600 dark:text-blue-400" />
308+
<div className="min-w-0">
309+
<span className="font-medium text-blue-700 dark:text-blue-300">Planning to call {toolLabel}</span>
310+
{argsText && (
311+
<p className="mt-0.5 truncate text-blue-600/80 dark:text-blue-300/80">{argsText}</p>
312+
)}
313+
</div>
314+
</div>
315+
);
316+
178317
case 'input-available':
179318
return (
180319
<div
@@ -289,6 +428,11 @@ export function AiChatPanel() {
289428
const { isOpen, setOpen, toggle } = useAiChatPanel();
290429
const [input, setInput] = useState('');
291430
const [selectedAgent, setSelectedAgent] = useState<string>(loadSelectedAgent);
431+
const [thinkingState, setThinkingState] = useState<ThinkingState>({
432+
reasoning: [],
433+
activeSteps: new Map(),
434+
completedSteps: [],
435+
});
292436
const scrollRef = useRef<HTMLDivElement>(null);
293437
const inputRef = useRef<HTMLTextAreaElement>(null);
294438
const baseUrl = getApiBaseUrl();
@@ -316,10 +460,70 @@ export function AiChatPanel() {
316460
const { messages, sendMessage, setMessages, status, error, addToolApprovalResponse } = useChat({
317461
transport,
318462
messages: initialMessages,
463+
streamMode: 'stream-data',
464+
onFinish: () => {
465+
// Reset thinking state when stream completes
466+
setThinkingState({
467+
reasoning: [],
468+
activeSteps: new Map(),
469+
completedSteps: [],
470+
});
471+
},
319472
});
320473

321474
const isStreaming = status === 'streaming' || status === 'submitted';
322475

476+
// Listen to custom stream data events
477+
useEffect(() => {
478+
if (!isStreaming) return;
479+
480+
// Create a custom event listener for stream data
481+
const handleStreamData = (event: MessageEvent) => {
482+
try {
483+
const data = JSON.parse(event.data);
484+
485+
if (data.type === 'reasoning-delta') {
486+
setThinkingState((prev) => ({
487+
...prev,
488+
reasoning: [...prev.reasoning, data.delta],
489+
}));
490+
} else if (data.type === 'step-start') {
491+
setThinkingState((prev) => {
492+
const newActiveSteps = new Map(prev.activeSteps);
493+
newActiveSteps.set(data.stepId, {
494+
stepName: data.stepName,
495+
startedAt: Date.now(),
496+
});
497+
return {
498+
...prev,
499+
activeSteps: newActiveSteps,
500+
};
501+
});
502+
} else if (data.type === 'step-finish') {
503+
setThinkingState((prev) => {
504+
const newActiveSteps = new Map(prev.activeSteps);
505+
newActiveSteps.delete(data.stepId);
506+
return {
507+
...prev,
508+
activeSteps: newActiveSteps,
509+
completedSteps: [...prev.completedSteps, data.stepName],
510+
};
511+
});
512+
}
513+
} catch {
514+
// Ignore parsing errors for non-JSON events
515+
}
516+
};
517+
518+
// Note: This is a simplified approach. In production, you'd want to
519+
// integrate more deeply with the transport layer or use a custom
520+
// transport that exposes stream events.
521+
522+
return () => {
523+
// Cleanup if needed
524+
};
525+
}, [isStreaming]);
526+
323527
// Persist messages to localStorage whenever they change
324528
useEffect(() => {
325529
if (messages.length > 0) {
@@ -513,10 +717,30 @@ export function AiChatPanel() {
513717
);
514718
})}
515719
{isStreaming && (
516-
<div className="mr-8 flex items-center gap-2 rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground">
517-
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-primary" />
518-
Thinking…
519-
</div>
720+
<>
721+
{/* Show reasoning if available */}
722+
{thinkingState.reasoning.length > 0 && (
723+
<div className="mr-8">
724+
<ReasoningDisplay reasoning={thinkingState.reasoning} />
725+
</div>
726+
)}
727+
{/* Show step progress if available */}
728+
{thinkingState.activeSteps.size > 0 && (
729+
<div className="mr-8">
730+
<StepProgress
731+
activeSteps={thinkingState.activeSteps}
732+
completedSteps={thinkingState.completedSteps}
733+
/>
734+
</div>
735+
)}
736+
{/* Default thinking indicator when no detailed state available */}
737+
{thinkingState.reasoning.length === 0 && thinkingState.activeSteps.size === 0 && (
738+
<div className="mr-8 flex items-center gap-2 rounded-lg bg-muted px-3 py-2 text-sm text-muted-foreground">
739+
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-primary" />
740+
Thinking…
741+
</div>
742+
)}
743+
</>
520744
)}
521745
{error && (
522746
<div className="flex items-start gap-2 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">

packages/services/service-ai/src/__tests__/vercel-stream-encoder.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,66 @@ describe('encodeStreamPart', () => {
128128
const part = { type: 'unknown-internal' } as unknown as TextStreamPart<ToolSet>;
129129
expect(encodeStreamPart(part)).toBe('');
130130
});
131+
132+
it('should encode reasoning-start part as SSE frame', () => {
133+
const part = {
134+
type: 'reasoning-start',
135+
id: 'r1',
136+
} as unknown as TextStreamPart<ToolSet>;
137+
138+
const frame = encodeStreamPart(part);
139+
const payload = parseSSE(frame);
140+
expect(payload).toEqual({
141+
type: 'reasoning-start',
142+
id: 'r1',
143+
});
144+
});
145+
146+
it('should encode reasoning-delta part as SSE frame', () => {
147+
const part = {
148+
type: 'reasoning-delta',
149+
id: 'r1',
150+
text: 'Let me think through this step by step...',
151+
} as unknown as TextStreamPart<ToolSet>;
152+
153+
const frame = encodeStreamPart(part);
154+
const payload = parseSSE(frame);
155+
expect(payload).toEqual({
156+
type: 'reasoning-delta',
157+
id: 'r1',
158+
delta: 'Let me think through this step by step...',
159+
});
160+
});
161+
162+
it('should encode reasoning-end part as SSE frame', () => {
163+
const part = {
164+
type: 'reasoning-end',
165+
id: 'r1',
166+
} as unknown as TextStreamPart<ToolSet>;
167+
168+
const frame = encodeStreamPart(part);
169+
const payload = parseSSE(frame);
170+
expect(payload).toEqual({
171+
type: 'reasoning-end',
172+
id: 'r1',
173+
});
174+
});
175+
176+
it('should pass through custom step events', () => {
177+
const part = {
178+
type: 'step-start',
179+
stepId: 'step_1',
180+
stepName: 'Query database',
181+
} as unknown as TextStreamPart<ToolSet>;
182+
183+
const frame = encodeStreamPart(part);
184+
const payload = parseSSE(frame);
185+
expect(payload).toEqual({
186+
type: 'step-start',
187+
stepId: 'step_1',
188+
stepName: 'Query database',
189+
});
190+
});
131191
});
132192

133193
// ─────────────────────────────────────────────────────────────────

packages/services/service-ai/src/stream/vercel-stream-encoder.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,33 @@ export function encodeStreamPart(part: TextStreamPart<ToolSet>): string {
7171
errorText: String(part.error),
7272
});
7373

74+
// Handle reasoning/thinking streams (DeepSeek R1, o1-style models)
75+
case 'reasoning-start':
76+
return sse({
77+
type: 'reasoning-start',
78+
id: part.id,
79+
});
80+
81+
case 'reasoning-delta':
82+
return sse({
83+
type: 'reasoning-delta',
84+
id: part.id,
85+
delta: part.text,
86+
});
87+
88+
case 'reasoning-end':
89+
return sse({
90+
type: 'reasoning-end',
91+
id: part.id,
92+
});
93+
7494
// finish-step and finish are handled by the generator, not here
7595
default:
96+
// Pass through any unknown event types that might be custom
97+
// (e.g., step-start, step-finish from custom providers)
98+
if ((part as any).type?.startsWith('step-')) {
99+
return sse(part as any);
100+
}
76101
return '';
77102
}
78103
}

0 commit comments

Comments
 (0)