diff --git a/bridge/agent_event_handler.py b/bridge/agent_event_handler.py
index 8bc7f62..b04c77b 100644
--- a/bridge/agent_event_handler.py
+++ b/bridge/agent_event_handler.py
@@ -94,15 +94,15 @@ class AgentEventHandler:
def _send_to_channel(self, message):
"""
- Try to send message to channel
-
- Args:
- message: Message to send
+ Try to send intermediate message to channel.
+ Skipped in SSE mode because thinking text is already streamed via on_event.
"""
+ if self.context and self.context.get("on_event"):
+ return
+
if self.channel:
try:
from bridge.reply import Reply, ReplyType
- # Create a Reply object for the message
reply = Reply(ReplyType.TEXT, message)
self.channel._send(reply, self.context)
except Exception as e:
diff --git a/channel/channel.py b/channel/channel.py
index 42d613f..f01189e 100644
--- a/channel/channel.py
+++ b/channel/channel.py
@@ -57,11 +57,14 @@ class Channel(object):
if context and "channel_type" not in context:
context["channel_type"] = self.channel_type
+ # Read on_event callback injected by the channel (e.g. web SSE)
+ on_event = context.get("on_event") if context else None
+
# Use agent bridge to handle the query
return Bridge().fetch_agent_reply(
query=query,
context=context,
- on_event=None,
+ on_event=on_event,
clear_history=False
)
except Exception as e:
diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css
index 3775b5d..2390569 100644
--- a/channel/web/static/css/console.css
+++ b/channel/web/static/css/console.css
@@ -82,6 +82,146 @@
.msg-content hr { border: none; height: 1px; background: #e2e8f0; margin: 1.2em 0; }
.dark .msg-content hr { background: rgba(255,255,255,0.1); }
+/* SSE Streaming cursor */
+@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
+.sse-streaming::after {
+ content: '▋';
+ display: inline-block;
+ margin-left: 2px;
+ color: #4ABE6E;
+ animation: blink 0.9s step-end infinite;
+ font-size: 0.85em;
+ vertical-align: middle;
+}
+
+/* Agent steps (thinking summaries + tool indicators) */
+.agent-steps:empty { display: none; }
+.agent-steps:not(:empty) {
+ margin-bottom: 0.625rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px dashed rgba(0, 0, 0, 0.08);
+}
+.dark .agent-steps:not(:empty) { border-bottom-color: rgba(255, 255, 255, 0.08); }
+
+.agent-step {
+ font-size: 0.75rem;
+ line-height: 1.4;
+ color: #94a3b8;
+ margin-bottom: 0.25rem;
+}
+.agent-step:last-child { margin-bottom: 0; }
+
+/* Thinking step - collapsible */
+.agent-thinking-step .thinking-header {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ cursor: pointer;
+ user-select: none;
+}
+.agent-thinking-step .thinking-header.no-toggle { cursor: default; }
+.agent-thinking-step .thinking-header:not(.no-toggle):hover { color: #64748b; }
+.dark .agent-thinking-step .thinking-header:not(.no-toggle):hover { color: #cbd5e1; }
+.agent-thinking-step .thinking-header i:first-child { font-size: 0.625rem; margin-top: 1px; }
+.agent-thinking-step .thinking-chevron {
+ font-size: 0.5rem;
+ margin-left: auto;
+ transition: transform 0.2s ease;
+ opacity: 0.5;
+}
+.agent-thinking-step.expanded .thinking-chevron { transform: rotate(90deg); }
+.agent-thinking-step .thinking-full {
+ display: none;
+ margin-top: 0.375rem;
+ margin-left: 1rem;
+ padding: 0.5rem;
+ background: rgba(0, 0, 0, 0.02);
+ border-radius: 6px;
+ border: 1px solid rgba(0, 0, 0, 0.04);
+ font-size: 0.75rem;
+ line-height: 1.5;
+ color: #94a3b8;
+ max-height: 200px;
+ overflow-y: auto;
+}
+.dark .agent-thinking-step .thinking-full {
+ background: rgba(255, 255, 255, 0.02);
+ border-color: rgba(255, 255, 255, 0.04);
+}
+.agent-thinking-step.expanded .thinking-full { display: block; }
+.agent-thinking-step .thinking-full p { margin: 0.25em 0; }
+.agent-thinking-step .thinking-full p:first-child { margin-top: 0; }
+.agent-thinking-step .thinking-full p:last-child { margin-bottom: 0; }
+
+/* Tool step - collapsible */
+.agent-tool-step .tool-header {
+ display: flex;
+ align-items: center;
+ gap: 0.375rem;
+ cursor: pointer;
+ user-select: none;
+ padding: 1px 0;
+ border-radius: 4px;
+}
+.agent-tool-step .tool-header:hover { color: #64748b; }
+.dark .agent-tool-step .tool-header:hover { color: #cbd5e1; }
+.agent-tool-step .tool-icon { font-size: 0.625rem; }
+.agent-tool-step .tool-chevron {
+ font-size: 0.5rem;
+ margin-left: auto;
+ transition: transform 0.2s ease;
+ opacity: 0.5;
+}
+.agent-tool-step.expanded .tool-chevron { transform: rotate(90deg); }
+.agent-tool-step .tool-time {
+ font-size: 0.65rem;
+ opacity: 0.6;
+ margin-left: 0.25rem;
+}
+
+/* Tool detail panel */
+.agent-tool-step .tool-detail {
+ display: none;
+ margin-top: 0.375rem;
+ margin-left: 1rem;
+ padding: 0.5rem;
+ background: rgba(0, 0, 0, 0.02);
+ border-radius: 6px;
+ border: 1px solid rgba(0, 0, 0, 0.04);
+}
+.dark .agent-tool-step .tool-detail {
+ background: rgba(255, 255, 255, 0.02);
+ border-color: rgba(255, 255, 255, 0.04);
+}
+.agent-tool-step.expanded .tool-detail { display: block; }
+.tool-detail-section { margin-bottom: 0.375rem; }
+.tool-detail-section:last-child { margin-bottom: 0; }
+.tool-detail-label {
+ font-size: 0.625rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ opacity: 0.6;
+ margin-bottom: 0.125rem;
+}
+.tool-detail-content {
+ font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace;
+ font-size: 0.7rem;
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-break: break-all;
+ max-height: 200px;
+ overflow-y: auto;
+ margin: 0;
+ padding: 0.25rem 0;
+ background: transparent;
+ color: inherit;
+}
+.tool-error-text { color: #f87171; }
+
+/* Tool failed state */
+.agent-tool-step.tool-failed .tool-name { color: #f87171; }
+
/* Chat Input */
#chat-input {
resize: none; height: 42px; max-height: 180px;
diff --git a/channel/web/static/js/console.js b/channel/web/static/js/console.js
index 7746160..8b326cb 100644
--- a/channel/web/static/js/console.js
+++ b/channel/web/static/js/console.js
@@ -225,6 +225,7 @@ function renderMarkdown(text) {
let sessionId = generateSessionId();
let isPolling = false;
let loadingContainers = {};
+let activeStreams = {}; // request_id -> EventSource
let isComposing = false;
let appConfig = { use_agent: false, title: 'CowAgent', subtitle: '' };
@@ -310,13 +311,17 @@ function sendMessage() {
fetch('/message', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ session_id: sessionId, message: text, timestamp: timestamp.toISOString() })
+ body: JSON.stringify({ session_id: sessionId, message: text, stream: true, timestamp: timestamp.toISOString() })
})
.then(r => r.json())
.then(data => {
if (data.status === 'success') {
- loadingContainers[data.request_id] = loadingEl;
- if (!isPolling) startPolling();
+ if (data.stream) {
+ startSSE(data.request_id, loadingEl, timestamp);
+ } else {
+ loadingContainers[data.request_id] = loadingEl;
+ if (!isPolling) startPolling();
+ }
} else {
loadingEl.remove();
addBotMessage(t('error_send'), new Date());
@@ -328,6 +333,163 @@ function sendMessage() {
});
}
+function startSSE(requestId, loadingEl, timestamp) {
+ const es = new EventSource(`/stream?request_id=${encodeURIComponent(requestId)}`);
+ activeStreams[requestId] = es;
+
+ let botEl = null;
+ let stepsEl = null; // .agent-steps (thinking summaries + tool indicators)
+ let contentEl = null; // .answer-content (final streaming answer)
+ let accumulatedText = '';
+ let currentToolEl = null;
+
+ function ensureBotEl() {
+ if (botEl) return;
+ if (loadingEl) { loadingEl.remove(); loadingEl = null; }
+ botEl = document.createElement('div');
+ botEl.className = 'flex gap-3 px-4 sm:px-6 py-3';
+ botEl.dataset.requestId = requestId;
+ botEl.innerHTML = `
+
+
${argsStr}
+ ${escapeHtml(String(item.result))}`;
+ }
+
+ if (isError) currentToolEl.classList.add('tool-failed');
+ currentToolEl = null;
+ }
+
+ } else if (item.type === 'done') {
+ es.close();
+ delete activeStreams[requestId];
+
+ const finalText = item.content || accumulatedText;
+
+ if (!botEl && finalText) {
+ if (loadingEl) { loadingEl.remove(); loadingEl = null; }
+ addBotMessage(finalText, new Date((item.timestamp || Date.now() / 1000) * 1000), requestId);
+ } else if (botEl) {
+ contentEl.classList.remove('sse-streaming');
+ if (finalText) contentEl.innerHTML = renderMarkdown(finalText);
+ applyHighlighting(botEl);
+ }
+ scrollChatToBottom();
+
+ } else if (item.type === 'error') {
+ es.close();
+ delete activeStreams[requestId];
+ if (loadingEl) { loadingEl.remove(); loadingEl = null; }
+ addBotMessage(t('error_send'), new Date());
+ }
+ };
+
+ es.onerror = function() {
+ es.close();
+ delete activeStreams[requestId];
+ if (loadingEl) { loadingEl.remove(); loadingEl = null; }
+ if (!botEl) {
+ addBotMessage(t('error_send'), new Date());
+ } else if (accumulatedText) {
+ contentEl.classList.remove('sse-streaming');
+ contentEl.innerHTML = renderMarkdown(accumulatedText);
+ applyHighlighting(botEl);
+ }
+ };
+}
+
function startPolling() {
if (isPolling) return;
isPolling = true;
@@ -379,7 +541,7 @@ function addBotMessage(content, timestamp, requestId) {
el.className = 'flex gap-3 px-4 sm:px-6 py-3';
if (requestId) el.dataset.requestId = requestId;
el.innerHTML = `
-
+
+