Skip to content

Commit 8799c0f

Browse files
committed
v42
1 parent 0c13618 commit 8799c0f

4 files changed

Lines changed: 204 additions & 115 deletions

File tree

backend/providers/claude/parser.py

Lines changed: 76 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,15 @@
66
"""
77

88
import logging
9-
from typing import Any
9+
10+
from claude_agent_sdk import (
11+
AssistantMessage,
12+
SystemMessage,
13+
TextBlock,
14+
ThinkingBlock,
15+
ToolUseBlock,
16+
)
17+
from claude_agent_sdk.types import StreamEvent
1018

1119
from providers.base import AIStreamParser, ParsedStreamMessage
1220

@@ -16,25 +24,23 @@
1624
class ClaudeStreamParser(AIStreamParser):
1725
"""Parser for Claude SDK streaming messages.
1826
19-
This wraps the existing parsing logic from sdk/stream_parser.py
20-
and adapts it to the unified ParsedStreamMessage format.
21-
2227
SDK Message Types:
28+
- StreamEvent: Partial message updates with raw Anthropic API events
2329
- AssistantMessage: content=[TextBlock, ThinkingBlock, ToolUseBlock, ...]
2430
- SystemMessage: subtype='sessionStarted', data={'session_id': ...}
2531
- ResultMessage: Final result with session_id, duration_ms, is_error
2632
"""
2733

2834
@staticmethod
2935
def parse_message(
30-
message: Any,
36+
message: AssistantMessage | SystemMessage | StreamEvent | object,
3137
current_response: str,
3238
current_thinking: str,
3339
) -> ParsedStreamMessage:
3440
"""Parse a streaming message from Claude SDK.
3541
3642
Args:
37-
message: SDK message object (AssistantMessage, SystemMessage, etc.)
43+
message: SDK message object (StreamEvent, AssistantMessage, SystemMessage, etc.)
3844
current_response: Accumulated response text so far
3945
current_thinking: Accumulated thinking text so far
4046
@@ -43,70 +49,77 @@ def parse_message(
4349
"""
4450
content_delta = ""
4551
thinking_delta = ""
46-
new_session_id = None
47-
skip_tool_called = False
48-
memory_entries = []
49-
anthropic_calls = []
52+
new_session_id: str | None = None
53+
memory_entries: list[str] = []
54+
55+
# Handle StreamEvent for partial message updates (real-time streaming)
56+
if isinstance(message, StreamEvent):
57+
event = message.event
58+
if isinstance(event, dict):
59+
event_type = event.get("type", "")
60+
61+
# Handle content_block_delta events (text streaming)
62+
if event_type == "content_block_delta":
63+
delta = event.get("delta", {})
64+
delta_type = delta.get("type", "")
65+
66+
if delta_type == "text_delta":
67+
content_delta = delta.get("text", "")
68+
elif delta_type == "thinking_delta":
69+
thinking_delta = delta.get("thinking", "")
70+
71+
# Extract session_id from StreamEvent
72+
if message.session_id:
73+
new_session_id = message.session_id
74+
75+
return ParsedStreamMessage(
76+
response_text=current_response + content_delta,
77+
thinking_text=current_thinking + thinking_delta,
78+
session_id=new_session_id,
79+
skip_used=False,
80+
memory_entries=memory_entries,
81+
anthropic_calls=[],
82+
)
5083

5184
# Extract session_id from SystemMessage
52-
if hasattr(message, "__class__") and message.__class__.__name__ == "SystemMessage":
53-
if hasattr(message, "data") and isinstance(message.data, dict):
54-
if "session_id" in message.data:
55-
new_session_id = message.data["session_id"]
56-
logger.debug(f"Extracted session_id: {new_session_id}")
57-
58-
# Handle content
59-
if hasattr(message, "text"):
60-
content_delta = message.text
61-
elif hasattr(message, "content"):
62-
if isinstance(message.content, str):
63-
content_delta = message.content
64-
elif isinstance(message.content, list):
65-
for block in message.content:
66-
block_type = getattr(block, "type", None) or (
67-
block.get("type") if isinstance(block, dict) else None
68-
)
69-
70-
# Check for tool calls
71-
# Note: MCP tools (skip, anthropic) are detected via PostToolUse hooks
72-
# Only memorize is detected here as fallback for memory_entries
73-
if block_type == "tool_use":
74-
tool_name = getattr(block, "name", None) or (
75-
block.get("name") if isinstance(block, dict) else None
76-
)
77-
78-
if tool_name and tool_name.endswith("__memorize"):
79-
tool_input = getattr(block, "input", None) or (
80-
block.get("input") if isinstance(block, dict) else None
81-
)
82-
if tool_input and isinstance(tool_input, dict):
83-
memory_entry = tool_input.get("memory_entry", "")
84-
if memory_entry:
85-
memory_entries.append(memory_entry)
86-
logger.info(f"Agent recorded memory: {memory_entry}")
87-
88-
# Handle thinking blocks
89-
block_class_name = block.__class__.__name__ if hasattr(block, "__class__") else ""
90-
if block_class_name == "ThinkingBlock" or (hasattr(block, "type") and block.type == "thinking"):
91-
if hasattr(block, "thinking"):
92-
thinking_delta = block.thinking
93-
elif hasattr(block, "text"):
94-
thinking_delta = block.text
95-
elif isinstance(block, dict) and block.get("type") == "thinking":
96-
thinking_delta = block.get("thinking", block.get("text", ""))
97-
else:
98-
# Handle text content blocks
99-
if hasattr(block, "text"):
100-
content_delta += block.text
101-
elif isinstance(block, dict) and block.get("type") == "text":
102-
content_delta += block.get("text", "")
85+
if isinstance(message, SystemMessage):
86+
if isinstance(message.data, dict) and "session_id" in message.data:
87+
new_session_id = message.data["session_id"]
88+
logger.debug(f"Extracted session_id: {new_session_id}")
89+
90+
# Handle AssistantMessage content
91+
if isinstance(message, AssistantMessage):
92+
# Track if we've already streamed content via StreamEvent
93+
# If so, skip adding text to avoid duplication
94+
skip_content = bool(current_response)
95+
skip_thinking = bool(current_thinking)
96+
97+
for block in message.content:
98+
# Check for memorize tool calls
99+
if isinstance(block, ToolUseBlock):
100+
if block.name.endswith("__memorize"):
101+
if isinstance(block.input, dict):
102+
memory_entry = block.input.get("memory_entry", "")
103+
if memory_entry:
104+
memory_entries.append(memory_entry)
105+
logger.info(f"Agent recorded memory: {memory_entry}")
106+
107+
# Handle thinking blocks (skip if already streamed)
108+
elif isinstance(block, ThinkingBlock):
109+
if not skip_thinking:
110+
thinking_delta = block.thinking
111+
112+
# Handle text blocks (skip if already streamed)
113+
elif isinstance(block, TextBlock):
114+
if not skip_content:
115+
content_delta += block.text
103116

104117
# Return accumulated text with deltas applied
105118
return ParsedStreamMessage(
106119
response_text=current_response + content_delta,
107120
thinking_text=current_thinking + thinking_delta,
108121
session_id=new_session_id,
109-
skip_used=skip_tool_called,
122+
skip_used=False,
110123
memory_entries=memory_entries,
111-
anthropic_calls=anthropic_calls,
124+
anthropic_calls=[],
112125
)

frontend/src/components/chat-room/message-list/MessageList.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface MessageListProps {
1111

1212
export const MessageList = memo(({ messages, roomId }: MessageListProps) => {
1313
const [expandedThinking, setExpandedThinking] = useState<Set<number | string>>(new Set());
14+
const seenMessagesRef = useRef<Set<number | string>>(new Set());
1415
const [copiedMessageId, setCopiedMessageId] = useState<number | string | null>(null);
1516
const [isAtBottom, setIsAtBottom] = useState(true);
1617
const virtuosoRef = useRef<VirtuosoHandle>(null);
@@ -24,9 +25,34 @@ export const MessageList = memo(({ messages, roomId }: MessageListProps) => {
2425
if (messages.length === 0) {
2526
setIsAtBottom(true);
2627
lastMessageCountRef.current = 0;
28+
seenMessagesRef.current = new Set();
29+
setExpandedThinking(new Set());
2730
}
2831
}, [messages.length]);
2932

33+
// Auto-expand thinking for new messages
34+
useEffect(() => {
35+
const messagesToExpand: (number | string)[] = [];
36+
37+
messages.forEach((msg) => {
38+
if (!seenMessagesRef.current.has(msg.id)) {
39+
seenMessagesRef.current.add(msg.id);
40+
// Auto-expand new messages with thinking
41+
if (msg.role === 'assistant' && msg.thinking) {
42+
messagesToExpand.push(msg.id);
43+
}
44+
}
45+
});
46+
47+
if (messagesToExpand.length > 0) {
48+
setExpandedThinking(prev => {
49+
const newSet = new Set(prev);
50+
messagesToExpand.forEach(id => newSet.add(id));
51+
return newSet;
52+
});
53+
}
54+
}, [messages]);
55+
3056
// Auto-scroll to bottom on new messages if user is at bottom
3157
useEffect(() => {
3258
if (messages.length > 0 && virtuosoRef.current) {

frontend/src/components/chat-room/message-list/MessageRow.tsx

Lines changed: 82 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -153,31 +153,36 @@ export const MessageRow = memo(({
153153
)}
154154

155155
<div className="flex flex-col gap-2 min-w-0">
156-
{/* Thinking block */}
157-
{message.role === 'assistant' && message.thinking && !message.is_typing && !message.is_chatting && (
158-
<button
159-
onClick={() => onToggleThinking(message.id)}
160-
className="flex items-center gap-2 text-xs font-medium text-slate-500 hover:text-slate-700 transition-colors ml-1 mb-1"
161-
>
162-
<svg
163-
className={`w-4 h-4 transition-transform ${expandedThinking.has(message.id) ? 'rotate-90' : ''}`}
164-
fill="none"
165-
stroke="currentColor"
166-
viewBox="0 0 24 24"
156+
{/* Thinking block - unified for both streaming and completed messages */}
157+
{message.role === 'assistant' && message.thinking && (
158+
<>
159+
<button
160+
onClick={() => onToggleThinking(message.id)}
161+
className="flex items-center gap-2 text-xs font-medium text-slate-500 hover:text-slate-700 transition-colors ml-1 mb-1"
167162
>
168-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
169-
</svg>
170-
<span>Thinking Process</span>
171-
</button>
172-
)}
163+
<svg
164+
className={`w-4 h-4 transition-transform ${expandedThinking.has(message.id) ? 'rotate-90' : ''}`}
165+
fill="none"
166+
stroke="currentColor"
167+
viewBox="0 0 24 24"
168+
>
169+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
170+
</svg>
171+
<span>Thinking Process</span>
172+
{(message.is_typing || message.is_chatting) && !message.content && (
173+
<span className="text-slate-400 italic">thinking...</span>
174+
)}
175+
</button>
173176

174-
{/* Expanded thinking content */}
175-
{message.role === 'assistant' && message.thinking && expandedThinking.has(message.id) && (
176-
<div className="pl-3 py-1 my-2 border-l-2 border-slate-300 text-slate-500 text-sm bg-slate-50/50 rounded-r-lg">
177-
<div className="whitespace-pre-wrap break-words leading-relaxed italic font-mono text-xs">
178-
{message.thinking}
179-
</div>
180-
</div>
177+
{/* Expanded thinking content */}
178+
{expandedThinking.has(message.id) && (
179+
<div className="pl-3 py-1 my-2 border-l-2 border-slate-300 text-slate-500 text-sm bg-slate-50/50 rounded-r-lg">
180+
<div className="whitespace-pre-wrap break-words leading-relaxed italic font-mono text-xs">
181+
{message.thinking}
182+
</div>
183+
</div>
184+
)}
185+
</>
181186
)}
182187

183188
{/* Image attachment - supports both new images array and legacy single image */}
@@ -202,27 +207,62 @@ export const MessageRow = memo(({
202207
>
203208
{message.is_typing || message.is_chatting ? (
204209
<div className="flex flex-col gap-2">
205-
<div className="flex items-center gap-2">
206-
<span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></span>
207-
<span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></span>
208-
<span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></span>
209-
<span className="text-sm text-slate-600 ml-1">{message.thinking ? 'thinking...' : 'chatting...'}</span>
210-
</div>
211-
{/* Show streaming thinking content */}
212-
{message.thinking && (
213-
<div className="pl-3 py-1 border-l-2 border-slate-300 text-slate-500 bg-slate-50/50 rounded-r-lg max-h-32 overflow-y-auto">
214-
<div className="whitespace-pre-wrap break-words leading-relaxed italic font-mono text-xs">
215-
{message.thinking}
216-
</div>
217-
</div>
218-
)}
219-
{/* Show streaming response content */}
220-
{message.content && (
221-
<div className="text-slate-700 text-sm">
222-
{message.content}
210+
{/* Show streaming response content with same styling as finished messages */}
211+
{message.content ? (
212+
<div className="prose prose-sm max-w-none break-words leading-relaxed select-text prose-p:leading-relaxed prose-pre:bg-slate-800 prose-pre:rounded-xl pr-1">
213+
<ReactMarkdown
214+
remarkPlugins={[remarkGfm, remarkBreaks]}
215+
components={{
216+
p: ({ children }) => <p className="mb-2 last:mb-0">{children}</p>,
217+
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
218+
em: ({ children }) => <em className="italic">{children}</em>,
219+
ul: ({ children }) => <ul className="list-disc list-inside mb-2">{children}</ul>,
220+
ol: ({ children }) => <ol className="list-decimal list-inside mb-2">{children}</ol>,
221+
li: ({ children }) => <li className="mb-1">{children}</li>,
222+
code: ({ inline, className, children, ...props }: any) => {
223+
const match = /language-(\w+)/.exec(className || '');
224+
const codeString = String(children).replace(/\n$/, '');
225+
const isInline = inline ?? (!className && !codeString.includes('\n'));
226+
227+
return isInline ? (
228+
<code className="bg-slate-200 text-slate-800 px-1.5 py-0.5 rounded text-sm font-mono" {...props}>
229+
{children}
230+
</code>
231+
) : (
232+
<SyntaxHighlighter
233+
style={oneDark}
234+
language={match ? match[1] : 'text'}
235+
PreTag="div"
236+
customStyle={{
237+
margin: 0,
238+
borderRadius: '0.75rem',
239+
fontSize: '0.875rem',
240+
}}
241+
{...props}
242+
>
243+
{codeString}
244+
</SyntaxHighlighter>
245+
);
246+
},
247+
pre: ({ children }) => (
248+
<div className="mb-2 overflow-hidden rounded-xl">
249+
{children}
250+
</div>
251+
),
252+
}}
253+
>
254+
{message.content}
255+
</ReactMarkdown>
223256
<span className="inline-block w-2 h-4 bg-slate-600 ml-0.5 animate-pulse"></span>
224257
</div>
225-
)}
258+
) : !message.thinking ? (
259+
<div className="flex items-center gap-2">
260+
<span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></span>
261+
<span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></span>
262+
<span className="w-2 h-2 bg-slate-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></span>
263+
<span className="text-sm text-slate-600 ml-1">chatting...</span>
264+
</div>
265+
) : null}
226266
</div>
227267
) : message.is_skipped ? (
228268
<div className="text-sm italic opacity-75">

0 commit comments

Comments
 (0)