mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-31 12:19:48 +02:00
[eric] production push 1.0.24: removed non-Claude MCP upfront-load warning (context-cost only, zero functionality impact), fixed fullscreen
+ webview popup dark-screen via new-window → exitFullscreen so parent stays interactive, ErrorSlime extracted to shared component and rendered on all chat error cards (Network / Servers maxed / Plan limit / Subscription), Network issue classifier tightened to real errno codes + retry CTA removed since edit_message-based replay was truncating successful tool history — copy now directs user to send-new-message once WS auto-reconnects
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
.DS_Store
|
||||
.worktrees
|
||||
.env
|
||||
.local-stash/
|
||||
backend/data/**
|
||||
!backend/data/outputs/
|
||||
!backend/data/outputs/*.json
|
||||
|
||||
@@ -53,6 +53,58 @@ def _delete_session_file(session_id: str):
|
||||
os.remove(path)
|
||||
|
||||
|
||||
# Patterns that indicate an upstream transient problem (overload / rate limit /
|
||||
# infra blip) — safe to silently retry with backoff. Checked against the
|
||||
# stringified exception from claude_agent_sdk / Claude CLI.
|
||||
_TRANSIENT_CAPACITY_PATTERNS = re.compile(
|
||||
r"(?:\b(?:429|500|502|503|504|529)\b"
|
||||
r"|overloaded"
|
||||
r"|service\s+(?:temporarily\s+)?unavailable"
|
||||
r"|at\s+capacity"
|
||||
r"|try\s+again\s+shortly"
|
||||
r"|internal\s+server\s+error"
|
||||
r"|rate[_\s-]?limit(?:_error)?"
|
||||
r"|ECONNRESET|ETIMEDOUT|ENETUNREACH|fetch\s+failed"
|
||||
r"|upstream\s+connect\s+error)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Patterns that look rate-limit-ish but are actually non-transient (user quota,
|
||||
# auth). Must NOT retry — upgrading or reauthing is required.
|
||||
_NON_TRANSIENT_PATTERNS = re.compile(
|
||||
r"(?:usage\s+cap\s+exceeded"
|
||||
r"|reached\s+your\s+OpenSwarm.*plan\s+limit"
|
||||
r"|no\s+active\s+subscription"
|
||||
r"|subscription\s+(?:canceled|past_due)"
|
||||
r"|invalid.*token"
|
||||
r"|missing\s+bearer\s+token"
|
||||
r"|401|403)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
# The Claude CLI's underlying ProcessError stringifies to a generic
|
||||
# "Command failed with exit code 1 / Check stderr output for details" —
|
||||
# the real cause (rate_limit_error / No pool capacity available / 429
|
||||
# / overloaded) only surfaces in the subprocess's stderr stream, which
|
||||
# we capture via the SDK's `stderr` callback and pass in as extra_text.
|
||||
# Classify against both so we catch capacity errors regardless of which
|
||||
# channel carried the message.
|
||||
combined = f"{exc!s}\n{extra_text}".strip()
|
||||
if not combined:
|
||||
return False
|
||||
if _NON_TRANSIENT_PATTERNS.search(combined):
|
||||
return False
|
||||
if _TRANSIENT_CAPACITY_PATTERNS.search(combined):
|
||||
return True
|
||||
# Pool-exhaustion copy from the OpenSwarm proxy ("No pool capacity
|
||||
# available. Try again shortly.") — matches the capacity family too.
|
||||
if re.search(r"no\s+pool\s+capacity", combined, re.IGNORECASE):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _load_all_session_data() -> list[tuple[str, dict]]:
|
||||
results = []
|
||||
if not os.path.exists(SESSIONS_DIR):
|
||||
@@ -1029,11 +1081,27 @@ class AgentManager:
|
||||
api_type = _api_type_for_session
|
||||
session.provider = api_type
|
||||
|
||||
# Capture the Claude CLI's stderr into a buffer so the retry
|
||||
# classifier can see the real cause of a process crash (e.g.
|
||||
# "No pool capacity available" from the OpenSwarm proxy, or the
|
||||
# Anthropic SDK's 429/overloaded error body). Without this the
|
||||
# SDK's ProcessError only stringifies to "Command failed with
|
||||
# exit code 1 / Check stderr output for details", which masks
|
||||
# transient capacity issues.
|
||||
_stderr_buffer: list[str] = []
|
||||
|
||||
def _stderr_cb(line: str) -> None:
|
||||
_stderr_buffer.append(line)
|
||||
# Cap the buffer so a runaway subprocess can't balloon RAM.
|
||||
if len(_stderr_buffer) > 500:
|
||||
del _stderr_buffer[:250]
|
||||
|
||||
options_kwargs = {
|
||||
"model": resolved_model,
|
||||
"max_buffer_size": 5 * 1024 * 1024,
|
||||
"permission_mode": "default",
|
||||
"can_use_tool": can_use_tool,
|
||||
"stderr": _stderr_cb,
|
||||
"hooks": {
|
||||
"PreToolUse": [HookMatcher(matcher=None, hooks=[pre_tool_hook])],
|
||||
"PostToolUse": [HookMatcher(matcher=None, hooks=[post_tool_hook])],
|
||||
@@ -1230,193 +1298,265 @@ class AgentManager:
|
||||
stream_block_index_map = {}
|
||||
_turn_number = 0
|
||||
_first_event = True
|
||||
# True between the first non-ResultMessage of a turn and the
|
||||
# following ResultMessage; False at turn boundaries. The retry
|
||||
# layer below only retries at boundaries — resuming mid-turn via
|
||||
# sdk_session_id would risk duplicating user-visible output.
|
||||
_current_turn_emitted = False
|
||||
|
||||
async for message in query(
|
||||
prompt=prompt_stream(),
|
||||
options=options,
|
||||
):
|
||||
if _first_event:
|
||||
logger.info(f"[MCP-DEBUG] First event received: {type(message).__name__}")
|
||||
_first_event = False
|
||||
# Silently absorb transient upstream capacity errors (429/500/503/
|
||||
# 529/overloaded/network blips) by waiting with exponential
|
||||
# backoff and restarting the query with resume=sdk_session_id.
|
||||
# The session keeps its conversation state across retries so the
|
||||
# user just sees a pause, not a red error card. Hard errors
|
||||
# (auth, plan limit, invalid args) fall through to the existing
|
||||
# error handler unchanged.
|
||||
_CAPACITY_BACKOFFS = [5, 15, 45, 90, 180]
|
||||
|
||||
# Log system messages (MCP server status, errors, etc.)
|
||||
if isinstance(message, SystemMessage):
|
||||
raw = message.__dict__ if hasattr(message, '__dict__') else str(message)
|
||||
logger.info(f"[MCP-DEBUG] SystemMessage: {raw}")
|
||||
async def _run_streaming_turn():
|
||||
nonlocal stream_text_msg_id, stream_tool_msg_ids_ordered, stream_block_index_map
|
||||
nonlocal _turn_number, _first_event, _current_turn_emitted
|
||||
async for message in query(
|
||||
prompt=prompt_stream(),
|
||||
options=options,
|
||||
):
|
||||
if isinstance(message, ResultMessage):
|
||||
_current_turn_emitted = False
|
||||
else:
|
||||
_current_turn_emitted = True
|
||||
|
||||
if isinstance(message, StreamEvent):
|
||||
event = message.event
|
||||
event_type = event.get("type")
|
||||
if _first_event:
|
||||
logger.info(f"[MCP-DEBUG] First event received: {type(message).__name__}")
|
||||
_first_event = False
|
||||
|
||||
if event_type == "content_block_start":
|
||||
block = event.get("content_block", {})
|
||||
index = event.get("index")
|
||||
block_type = block.get("type")
|
||||
# Log system messages (MCP server status, errors, etc.)
|
||||
if isinstance(message, SystemMessage):
|
||||
raw = message.__dict__ if hasattr(message, '__dict__') else str(message)
|
||||
logger.info(f"[MCP-DEBUG] SystemMessage: {raw}")
|
||||
|
||||
if block_type == "text":
|
||||
if stream_text_msg_id is None:
|
||||
stream_text_msg_id = uuid4().hex
|
||||
if isinstance(message, StreamEvent):
|
||||
event = message.event
|
||||
event_type = event.get("type")
|
||||
|
||||
if event_type == "content_block_start":
|
||||
block = event.get("content_block", {})
|
||||
index = event.get("index")
|
||||
block_type = block.get("type")
|
||||
|
||||
if block_type == "text":
|
||||
if stream_text_msg_id is None:
|
||||
stream_text_msg_id = uuid4().hex
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_start", {
|
||||
"session_id": session_id,
|
||||
"message_id": stream_text_msg_id,
|
||||
"role": "assistant",
|
||||
})
|
||||
stream_block_index_map[index] = stream_text_msg_id
|
||||
|
||||
elif block_type == "thinking":
|
||||
# Reasoning trace from thinking-capable models
|
||||
# (GPT-5.3 Codex, Gemini 3 Pro/Flash, Claude
|
||||
# with extended thinking). Rendered as a
|
||||
# collapsible "thinking" message in the UI via
|
||||
# the existing stream infrastructure — the
|
||||
# frontend already handles role="thinking" for
|
||||
# the DynamicIsland/agent card rendering.
|
||||
thinking_msg_id = uuid4().hex
|
||||
stream_block_index_map[index] = thinking_msg_id
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_start", {
|
||||
"session_id": session_id,
|
||||
"message_id": stream_text_msg_id,
|
||||
"role": "assistant",
|
||||
"message_id": thinking_msg_id,
|
||||
"role": "thinking",
|
||||
})
|
||||
stream_block_index_map[index] = stream_text_msg_id
|
||||
|
||||
elif block_type == "thinking":
|
||||
# Reasoning trace from thinking-capable models
|
||||
# (GPT-5.3 Codex, Gemini 3 Pro/Flash, Claude
|
||||
# with extended thinking). Rendered as a
|
||||
# collapsible "thinking" message in the UI via
|
||||
# the existing stream infrastructure — the
|
||||
# frontend already handles role="thinking" for
|
||||
# the DynamicIsland/agent card rendering.
|
||||
thinking_msg_id = uuid4().hex
|
||||
stream_block_index_map[index] = thinking_msg_id
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_start", {
|
||||
elif block_type == "tool_use":
|
||||
tool_msg_id = uuid4().hex
|
||||
stream_tool_msg_ids_ordered.append(tool_msg_id)
|
||||
stream_block_index_map[index] = tool_msg_id
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_start", {
|
||||
"session_id": session_id,
|
||||
"message_id": tool_msg_id,
|
||||
"role": "tool_call",
|
||||
"tool_name": block.get("name", ""),
|
||||
})
|
||||
|
||||
elif event_type == "content_block_delta":
|
||||
index = event.get("index")
|
||||
delta = event.get("delta", {})
|
||||
delta_type = delta.get("type")
|
||||
msg_id = stream_block_index_map.get(index)
|
||||
|
||||
if msg_id and delta_type == "text_delta":
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
|
||||
"session_id": session_id,
|
||||
"message_id": msg_id,
|
||||
"delta": delta.get("text", ""),
|
||||
})
|
||||
elif msg_id and delta_type == "thinking_delta":
|
||||
# Thinking content streams as thinking_delta
|
||||
# with a "thinking" field (not "text")
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
|
||||
"session_id": session_id,
|
||||
"message_id": msg_id,
|
||||
"delta": delta.get("thinking", ""),
|
||||
})
|
||||
elif msg_id and delta_type == "input_json_delta":
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
|
||||
"session_id": session_id,
|
||||
"message_id": msg_id,
|
||||
"delta": delta.get("partial_json", ""),
|
||||
})
|
||||
|
||||
elif event_type == "content_block_stop":
|
||||
index = event.get("index")
|
||||
msg_id = stream_block_index_map.get(index)
|
||||
if msg_id and msg_id != stream_text_msg_id:
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_end", {
|
||||
"session_id": session_id,
|
||||
"message_id": msg_id,
|
||||
})
|
||||
|
||||
elif event_type == "message_stop":
|
||||
if stream_text_msg_id:
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_end", {
|
||||
"session_id": session_id,
|
||||
"message_id": stream_text_msg_id,
|
||||
})
|
||||
|
||||
elif isinstance(message, AssistantMessage):
|
||||
content_parts = []
|
||||
thinking_parts = []
|
||||
tool_uses = []
|
||||
for block in message.content:
|
||||
if isinstance(block, ThinkingBlock):
|
||||
thinking_text = getattr(block, "thinking", None) or getattr(block, "text", None) or ""
|
||||
if thinking_text:
|
||||
thinking_parts.append(thinking_text)
|
||||
elif isinstance(block, TextBlock):
|
||||
content_parts.append(block.text)
|
||||
elif isinstance(block, ToolUseBlock):
|
||||
tool_uses.append({
|
||||
"id": block.id,
|
||||
"tool": block.name,
|
||||
"input": block.input,
|
||||
})
|
||||
|
||||
# Emit thinking trace as a separate message so the
|
||||
# frontend can render it as a collapsible reasoning
|
||||
# bubble (GPT-5.3 Codex, Gemini 3 Pro/Flash).
|
||||
if thinking_parts:
|
||||
thinking_msg = Message(
|
||||
role="thinking",
|
||||
content="\n".join(thinking_parts),
|
||||
branch_id=session.active_branch_id,
|
||||
)
|
||||
session.messages.append(thinking_msg)
|
||||
await ws_manager.send_to_session(session_id, "agent:message", {
|
||||
"session_id": session_id,
|
||||
"message_id": thinking_msg_id,
|
||||
"role": "thinking",
|
||||
"message": thinking_msg.model_dump(mode="json"),
|
||||
})
|
||||
|
||||
elif block_type == "tool_use":
|
||||
tool_msg_id = uuid4().hex
|
||||
stream_tool_msg_ids_ordered.append(tool_msg_id)
|
||||
stream_block_index_map[index] = tool_msg_id
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_start", {
|
||||
if content_parts:
|
||||
asst_msg = Message(
|
||||
id=stream_text_msg_id or uuid4().hex,
|
||||
role="assistant",
|
||||
content="\n".join(content_parts),
|
||||
branch_id=session.active_branch_id,
|
||||
)
|
||||
session.messages.append(asst_msg)
|
||||
await ws_manager.send_to_session(session_id, "agent:message", {
|
||||
"session_id": session_id,
|
||||
"message_id": tool_msg_id,
|
||||
"role": "tool_call",
|
||||
"tool_name": block.get("name", ""),
|
||||
"message": asst_msg.model_dump(mode="json"),
|
||||
})
|
||||
|
||||
elif event_type == "content_block_delta":
|
||||
index = event.get("index")
|
||||
delta = event.get("delta", {})
|
||||
delta_type = delta.get("type")
|
||||
msg_id = stream_block_index_map.get(index)
|
||||
|
||||
if msg_id and delta_type == "text_delta":
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
|
||||
for i, tu in enumerate(tool_uses):
|
||||
msg_id = stream_tool_msg_ids_ordered[i] if i < len(stream_tool_msg_ids_ordered) else uuid4().hex
|
||||
tool_msg = Message(id=msg_id, role="tool_call", content=tu, branch_id=session.active_branch_id)
|
||||
session.messages.append(tool_msg)
|
||||
await ws_manager.send_to_session(session_id, "agent:message", {
|
||||
"session_id": session_id,
|
||||
"message_id": msg_id,
|
||||
"delta": delta.get("text", ""),
|
||||
})
|
||||
elif msg_id and delta_type == "thinking_delta":
|
||||
# Thinking content streams as thinking_delta
|
||||
# with a "thinking" field (not "text")
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
|
||||
"session_id": session_id,
|
||||
"message_id": msg_id,
|
||||
"delta": delta.get("thinking", ""),
|
||||
})
|
||||
elif msg_id and delta_type == "input_json_delta":
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_delta", {
|
||||
"session_id": session_id,
|
||||
"message_id": msg_id,
|
||||
"delta": delta.get("partial_json", ""),
|
||||
"message": tool_msg.model_dump(mode="json"),
|
||||
})
|
||||
|
||||
elif event_type == "content_block_stop":
|
||||
index = event.get("index")
|
||||
msg_id = stream_block_index_map.get(index)
|
||||
if msg_id and msg_id != stream_text_msg_id:
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_end", {
|
||||
"session_id": session_id,
|
||||
"message_id": msg_id,
|
||||
})
|
||||
_turn_number += 1
|
||||
_analytics("turn.completed", {
|
||||
"turn_number": _turn_number,
|
||||
"tool_calls_in_turn": len(tool_uses),
|
||||
"model": session.model,
|
||||
}, session_id=session_id, dashboard_id=session.dashboard_id)
|
||||
|
||||
elif event_type == "message_stop":
|
||||
stream_text_msg_id = None
|
||||
stream_tool_msg_ids_ordered = []
|
||||
stream_block_index_map = {}
|
||||
|
||||
elif isinstance(message, ResultMessage):
|
||||
session.sdk_session_id = getattr(message, "session_id", None)
|
||||
cost = getattr(message, "total_cost_usd", None)
|
||||
if cost is not None:
|
||||
session.cost_usd = cost
|
||||
await ws_manager.send_to_session(session_id, "agent:cost_update", {
|
||||
"session_id": session_id,
|
||||
"cost_usd": session.cost_usd,
|
||||
})
|
||||
# Extract token usage from ResultMessage
|
||||
usage = getattr(message, "usage", None) or {}
|
||||
if isinstance(usage, dict):
|
||||
inp = usage.get("input_tokens", 0) or 0
|
||||
out = usage.get("output_tokens", 0) or 0
|
||||
cache_create = usage.get("cache_creation_input_tokens", 0) or 0
|
||||
cache_read = usage.get("cache_read_input_tokens", 0) or 0
|
||||
session.tokens["input"] = inp + cache_create + cache_read
|
||||
session.tokens["output"] = out
|
||||
|
||||
capacity_retry_attempt = 0
|
||||
while True:
|
||||
try:
|
||||
await _run_streaming_turn()
|
||||
break
|
||||
except Exception as e:
|
||||
stderr_snapshot = "\n".join(_stderr_buffer[-50:])
|
||||
if (
|
||||
_is_transient_capacity_error(e, extra_text=stderr_snapshot)
|
||||
and capacity_retry_attempt < len(_CAPACITY_BACKOFFS)
|
||||
):
|
||||
wait = _CAPACITY_BACKOFFS[capacity_retry_attempt]
|
||||
capacity_retry_attempt += 1
|
||||
mid_stream = _current_turn_emitted
|
||||
logger.warning(
|
||||
f"Transient upstream error on session {session_id} "
|
||||
f"(attempt {capacity_retry_attempt}/{len(_CAPACITY_BACKOFFS)}, "
|
||||
f"mid_stream={mid_stream}); sleeping {wait}s before retry. "
|
||||
f"exc={e!r} stderr_tail={stderr_snapshot[-400:]!r}"
|
||||
)
|
||||
# Finalize any in-flight stream messages so the UI
|
||||
# doesn't leave them pinned as "still streaming" while
|
||||
# we wait and restart. On resume the CLI re-runs the
|
||||
# last turn from scratch (Anthropic doesn't persist
|
||||
# in-progress responses), so the partial assistant
|
||||
# text / tool call we emitted is now orphaned — cap
|
||||
# it with stream_end and start the fresh turn under a
|
||||
# new message id.
|
||||
if stream_text_msg_id:
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_end", {
|
||||
"session_id": session_id,
|
||||
"message_id": stream_text_msg_id,
|
||||
})
|
||||
|
||||
elif isinstance(message, AssistantMessage):
|
||||
content_parts = []
|
||||
thinking_parts = []
|
||||
tool_uses = []
|
||||
for block in message.content:
|
||||
if isinstance(block, ThinkingBlock):
|
||||
thinking_text = getattr(block, "thinking", None) or getattr(block, "text", None) or ""
|
||||
if thinking_text:
|
||||
thinking_parts.append(thinking_text)
|
||||
elif isinstance(block, TextBlock):
|
||||
content_parts.append(block.text)
|
||||
elif isinstance(block, ToolUseBlock):
|
||||
tool_uses.append({
|
||||
"id": block.id,
|
||||
"tool": block.name,
|
||||
"input": block.input,
|
||||
stream_text_msg_id = None
|
||||
for _tool_msg_id in stream_tool_msg_ids_ordered:
|
||||
await ws_manager.send_to_session(session_id, "agent:stream_end", {
|
||||
"session_id": session_id,
|
||||
"message_id": _tool_msg_id,
|
||||
})
|
||||
|
||||
# Emit thinking trace as a separate message so the
|
||||
# frontend can render it as a collapsible reasoning
|
||||
# bubble (GPT-5.3 Codex, Gemini 3 Pro/Flash).
|
||||
if thinking_parts:
|
||||
thinking_msg = Message(
|
||||
role="thinking",
|
||||
content="\n".join(thinking_parts),
|
||||
branch_id=session.active_branch_id,
|
||||
)
|
||||
session.messages.append(thinking_msg)
|
||||
await ws_manager.send_to_session(session_id, "agent:message", {
|
||||
"session_id": session_id,
|
||||
"message": thinking_msg.model_dump(mode="json"),
|
||||
})
|
||||
|
||||
if content_parts:
|
||||
asst_msg = Message(
|
||||
id=stream_text_msg_id or uuid4().hex,
|
||||
role="assistant",
|
||||
content="\n".join(content_parts),
|
||||
branch_id=session.active_branch_id,
|
||||
)
|
||||
session.messages.append(asst_msg)
|
||||
await ws_manager.send_to_session(session_id, "agent:message", {
|
||||
"session_id": session_id,
|
||||
"message": asst_msg.model_dump(mode="json"),
|
||||
})
|
||||
|
||||
for i, tu in enumerate(tool_uses):
|
||||
msg_id = stream_tool_msg_ids_ordered[i] if i < len(stream_tool_msg_ids_ordered) else uuid4().hex
|
||||
tool_msg = Message(id=msg_id, role="tool_call", content=tu, branch_id=session.active_branch_id)
|
||||
session.messages.append(tool_msg)
|
||||
await ws_manager.send_to_session(session_id, "agent:message", {
|
||||
"session_id": session_id,
|
||||
"message": tool_msg.model_dump(mode="json"),
|
||||
})
|
||||
|
||||
_turn_number += 1
|
||||
_analytics("turn.completed", {
|
||||
"turn_number": _turn_number,
|
||||
"tool_calls_in_turn": len(tool_uses),
|
||||
"model": session.model,
|
||||
}, session_id=session_id, dashboard_id=session.dashboard_id)
|
||||
|
||||
stream_text_msg_id = None
|
||||
stream_tool_msg_ids_ordered = []
|
||||
stream_block_index_map = {}
|
||||
|
||||
elif isinstance(message, ResultMessage):
|
||||
session.sdk_session_id = getattr(message, "session_id", None)
|
||||
cost = getattr(message, "total_cost_usd", None)
|
||||
if cost is not None:
|
||||
session.cost_usd = cost
|
||||
await ws_manager.send_to_session(session_id, "agent:cost_update", {
|
||||
"session_id": session_id,
|
||||
"cost_usd": session.cost_usd,
|
||||
})
|
||||
# Extract token usage from ResultMessage
|
||||
usage = getattr(message, "usage", None) or {}
|
||||
if isinstance(usage, dict):
|
||||
inp = usage.get("input_tokens", 0) or 0
|
||||
out = usage.get("output_tokens", 0) or 0
|
||||
cache_create = usage.get("cache_creation_input_tokens", 0) or 0
|
||||
cache_read = usage.get("cache_read_input_tokens", 0) or 0
|
||||
session.tokens["input"] = inp + cache_create + cache_read
|
||||
session.tokens["output"] = out
|
||||
stream_tool_msg_ids_ordered = []
|
||||
stream_block_index_map = {}
|
||||
_current_turn_emitted = False
|
||||
await asyncio.sleep(wait)
|
||||
_stderr_buffer.clear()
|
||||
if session.sdk_session_id:
|
||||
options_kwargs["resume"] = session.sdk_session_id
|
||||
options = ClaudeAgentOptions(**options_kwargs)
|
||||
continue
|
||||
raise
|
||||
|
||||
session.status = "completed"
|
||||
except asyncio.CancelledError:
|
||||
|
||||
@@ -15,7 +15,7 @@ from backend.apps.analytics.collector import init as init_collector, shutdown as
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
APP_VERSION = "1.0.23"
|
||||
APP_VERSION = "1.0.24"
|
||||
|
||||
_heartbeat_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
@@ -526,6 +526,14 @@ app.on('web-contents-created', (_event, contents) => {
|
||||
action: 'allow',
|
||||
overrideBrowserWindowOptions: {
|
||||
parent: mainWindow || undefined,
|
||||
width: 520,
|
||||
height: 680,
|
||||
center: true,
|
||||
fullscreen: false,
|
||||
fullscreenable: false,
|
||||
resizable: true,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -533,6 +541,9 @@ app.on('web-contents-created', (_event, contents) => {
|
||||
contents.on('did-create-window', (childWindow) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed() && !childWindow.isDestroyed()) {
|
||||
childWindow.setParentWindow(mainWindow);
|
||||
// Belt-and-suspenders: if the parent was fullscreen when window.open
|
||||
// fired, Electron can still spawn the child fullscreen. Force it back.
|
||||
if (childWindow.isFullScreen()) childWindow.setFullScreen(false);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openswarm",
|
||||
"version": "1.0.23",
|
||||
"version": "1.0.24",
|
||||
"description": "OpenSwarm — AI Agent Orchestrator",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
|
||||
/** Cute slime with × eyes and a red error badge — error / warning illustration. */
|
||||
export const ErrorSlime: React.FC<{ size?: number }> = ({ size = 22 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 28 28" fill="none" style={{ flexShrink: 0 }}>
|
||||
<path
|
||||
d="M4 20 Q4 7 14 7 Q24 7 24 20 Q22 22 19 21.5 Q16 23 14 22 Q12 23 9 21.5 Q6 22 4 20Z"
|
||||
fill="#E8927A"
|
||||
/>
|
||||
<ellipse cx="11" cy="11" rx="3.5" ry="2" fill="#F0A68E" opacity="0.6" />
|
||||
<line x1="9.5" y1="13" x2="11.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
|
||||
<line x1="11.5" y1="13" x2="9.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
|
||||
<line x1="16.5" y1="13" x2="18.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
|
||||
<line x1="18.5" y1="13" x2="16.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
|
||||
<path d="M12 18.5 Q14 17.5 16 18.5" stroke="#4a2020" strokeWidth="1" strokeLinecap="round" fill="none" />
|
||||
<circle cx="22" cy="5" r="4" fill="#ef4444" stroke="rgba(0,0,0,0.15)" strokeWidth="0.5" />
|
||||
<text x="22" y="6.8" textAnchor="middle" fontSize="5.5" fill="white" fontWeight="bold" fontFamily="sans-serif">!</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default ErrorSlime;
|
||||
@@ -43,6 +43,7 @@ import { fetchOutputs } from '@/shared/state/outputsSlice';
|
||||
import { setInstalling } from '@/shared/state/updateSlice';
|
||||
import { findBrowserByWebContentsId } from '@/shared/browserRegistry';
|
||||
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { ErrorSlime } from '@/app/components/ErrorSlime';
|
||||
|
||||
const SIDEBAR_MIN = 160;
|
||||
const SIDEBAR_MAX = 400;
|
||||
@@ -58,32 +59,6 @@ const CUSTOMIZATION_ITEMS = [
|
||||
|
||||
const CUSTOMIZATION_PATHS = new Set(CUSTOMIZATION_ITEMS.map((i) => i.path));
|
||||
|
||||
|
||||
|
||||
/** Cute slime with × eyes and a red error badge — warning banner icon. */
|
||||
const ErrorSlime: React.FC<{ size?: number }> = ({ size = 22 }) => (
|
||||
<svg width={size} height={size} viewBox="0 0 28 28" fill="none" style={{ flexShrink: 0 }}>
|
||||
{/* Body — smooth dome blob sitting on a wobbly base */}
|
||||
<path
|
||||
d="M4 20 Q4 7 14 7 Q24 7 24 20 Q22 22 19 21.5 Q16 23 14 22 Q12 23 9 21.5 Q6 22 4 20Z"
|
||||
fill="#E8927A"
|
||||
/>
|
||||
{/* Subtle highlight on top of the dome */}
|
||||
<ellipse cx="11" cy="11" rx="3.5" ry="2" fill="#F0A68E" opacity="0.6" />
|
||||
{/* × left eye */}
|
||||
<line x1="9.5" y1="13" x2="11.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
|
||||
<line x1="11.5" y1="13" x2="9.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
|
||||
{/* × right eye */}
|
||||
<line x1="16.5" y1="13" x2="18.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
|
||||
<line x1="18.5" y1="13" x2="16.5" y2="15.5" stroke="#4a2020" strokeWidth="1.4" strokeLinecap="round" />
|
||||
{/* Small frown */}
|
||||
<path d="M12 18.5 Q14 17.5 16 18.5" stroke="#4a2020" strokeWidth="1" strokeLinecap="round" fill="none" />
|
||||
{/* Red error badge — top right */}
|
||||
<circle cx="22" cy="5" r="4" fill="#ef4444" stroke="rgba(0,0,0,0.15)" strokeWidth="0.5" />
|
||||
<text x="22" y="6.8" textAnchor="middle" fontSize="5.5" fill="white" fontWeight="bold" fontFamily="sans-serif">!</text>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const AppShell: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useMemo, useState, useCallback } from 'react';
|
||||
import React, { useEffect, useRef, useMemo, useState, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
@@ -59,18 +59,20 @@ function stringifyContent(content: any): string {
|
||||
return JSON.stringify(content);
|
||||
}
|
||||
|
||||
const thinkingDotsKeyframes = `
|
||||
@keyframes thinking-bounce {
|
||||
0%, 80%, 100% { transform: scale(0); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
const thinkingShimmerKeyframes = `
|
||||
@keyframes thinking-shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
`;
|
||||
|
||||
const ThinkingBubble: React.FC = () => {
|
||||
const c = useClaudeTokens();
|
||||
const shimmerBase = c.text.tertiary;
|
||||
const shimmerHighlight = c.text.primary;
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-start', my: 0.75 }}>
|
||||
<style>{thinkingDotsKeyframes}</style>
|
||||
<style>{thinkingShimmerKeyframes}</style>
|
||||
<Box
|
||||
sx={{
|
||||
bgcolor: c.bg.surface,
|
||||
@@ -81,23 +83,25 @@ const ThinkingBubble: React.FC = () => {
|
||||
boxShadow: c.shadow.sm,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
minHeight: 36,
|
||||
}}
|
||||
>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Box
|
||||
key={i}
|
||||
sx={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: '50%',
|
||||
bgcolor: c.text.tertiary,
|
||||
animation: 'thinking-bounce 1.4s infinite ease-in-out both',
|
||||
animationDelay: `${i * 0.16}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 500,
|
||||
background: `linear-gradient(90deg, ${shimmerBase} 0%, ${shimmerBase} 40%, ${shimmerHighlight} 50%, ${shimmerBase} 60%, ${shimmerBase} 100%)`,
|
||||
backgroundSize: '200% 100%',
|
||||
WebkitBackgroundClip: 'text',
|
||||
backgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
color: 'transparent',
|
||||
animation: 'thinking-shimmer 2s linear infinite',
|
||||
}}
|
||||
>
|
||||
Thinking…
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
@@ -336,13 +340,25 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
|
||||
setShowScrollButton(false);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isAtBottomRef.current) {
|
||||
const scrollRafRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (!isAtBottomRef.current) return;
|
||||
if (scrollRafRef.current != null) return;
|
||||
scrollRafRef.current = requestAnimationFrame(() => {
|
||||
scrollRafRef.current = null;
|
||||
if (!isAtBottomRef.current) return;
|
||||
const el = scrollContainerRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
});
|
||||
}, [session?.messages.length, session?.streamingMessage?.content]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (scrollRafRef.current != null) {
|
||||
cancelAnimationFrame(scrollRafRef.current);
|
||||
scrollRafRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSend = (prompt: string, images?: Array<{ data: string; media_type: string }>, contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>, forcedTools?: string[], attachedSkills?: Array<{ id: string; name: string; content: string }>, selectedBrowserIds?: string[]) => {
|
||||
if (!id) return;
|
||||
// Sending a message is a clear intent signal: the user wants to see
|
||||
|
||||
@@ -205,27 +205,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
const connectionMode = useAppSelector((state) => state.settings.data.connection_mode);
|
||||
const toolItems = useAppSelector((state) => state.tools.items);
|
||||
|
||||
// Count the total number of enabled MCP tool permissions (non-deny) across
|
||||
// all enabled MCP servers. Used to warn users before they switch to a
|
||||
// non-Claude model that can't leverage the deferred-tool pool — those
|
||||
// models get every schema upfront and may exhaust context fast.
|
||||
const enabledMcpToolCount = useMemo(() => {
|
||||
let count = 0;
|
||||
for (const id in toolItems) {
|
||||
const t = toolItems[id];
|
||||
if (t.enabled && t.mcp_config && t.tool_permissions) {
|
||||
for (const name in t.tool_permissions) {
|
||||
if (t.tool_permissions[name] !== 'deny') count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}, [toolItems]);
|
||||
|
||||
// One-time dismissible warning when picking a non-Claude model with many MCPs.
|
||||
const [mcpWarningOpen, setMcpWarningOpen] = useState(false);
|
||||
const MCP_WARNING_LS_KEY = 'openswarm:nonClaudeMcpWarningDismissed';
|
||||
const MCP_WARNING_THRESHOLD = 20;
|
||||
|
||||
// Build flat model list with provider grouping. Group names come from the
|
||||
// backend's /agents/models response verbatim — "OpenSwarm Pro" for
|
||||
@@ -1166,19 +1145,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
};
|
||||
onProviderChange(providerMap[provLower] || provLower);
|
||||
}
|
||||
// OpenSwarm Pro routes Claude models through our proxy —
|
||||
// they're still Claude, so they support the deferred tool
|
||||
// loader. Only warn when the user picks a truly non-Claude
|
||||
// provider (GPT/Gemini/etc).
|
||||
const provLowerForWarn = prov.toLowerCase();
|
||||
const isClaudeProvider = provLowerForWarn === 'anthropic' || provLowerForWarn === 'openswarm pro';
|
||||
if (!isClaudeProvider && enabledMcpToolCount > MCP_WARNING_THRESHOLD) {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && !window.localStorage.getItem(MCP_WARNING_LS_KEY)) {
|
||||
setMcpWarningOpen(true);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
setModelAnchor(null);
|
||||
}}
|
||||
>
|
||||
@@ -1449,40 +1415,6 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
<Snackbar
|
||||
open={mcpWarningOpen}
|
||||
autoHideDuration={12000}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
onClose={(_, reason) => {
|
||||
if (reason === 'clickaway') return;
|
||||
setMcpWarningOpen(false);
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(MCP_WARNING_LS_KEY, '1');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
severity="warning"
|
||||
variant="filled"
|
||||
onClose={() => {
|
||||
setMcpWarningOpen(false);
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(MCP_WARNING_LS_KEY, '1');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}}
|
||||
sx={{ fontSize: '0.78rem', maxWidth: 520 }}
|
||||
>
|
||||
Non-Claude models don't support the deferred tool loader — all
|
||||
{' '}{enabledMcpToolCount} MCP tool schemas will be sent upfront,
|
||||
which may exhaust context on long sessions. Disable MCPs you
|
||||
don't need in Settings → Tools.
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext';
|
||||
import { SKILL_COLOR } from '@/app/components/richEditorUtils';
|
||||
import ViewBubble from './ViewBubble';
|
||||
import PlanPicker from '@/app/components/PlanPicker';
|
||||
import { ErrorSlime } from '@/app/components/ErrorSlime';
|
||||
|
||||
const streamingCursorKeyframes = `
|
||||
@keyframes blink-cursor {
|
||||
@@ -110,14 +111,20 @@ function parseOpenSwarmError(text: string): OpenSwarmErrorInfo | null {
|
||||
ctaAction: 'settings',
|
||||
};
|
||||
}
|
||||
// Network issues (keep last so it doesn't swallow the specific cases above)
|
||||
if (/ECONNREFUSED|ENETUNREACH|fetch failed|ETIMEDOUT|network|Could not reach/i.test(text)) {
|
||||
// Genuine, hard network failures only. The bare word `network` used to
|
||||
// match anything mentioning "network" (Python traces, MCP tool output,
|
||||
// ffmpeg lines, etc.), and `fetch failed` / `ETIMEDOUT` alone fire for
|
||||
// transient upstream blips the backend now silently retries — surfacing
|
||||
// a card for those just confuses the user. So: require the specific
|
||||
// errno codes at word boundaries, and only match `fetch failed` when
|
||||
// paired with a concrete cause so we don't swallow every Node-level
|
||||
// transient. The backend's capacity/transient retry layer handles the
|
||||
// rest without ever reaching this classifier.
|
||||
if (/\b(?:ECONNREFUSED|ENETUNREACH|ENOTFOUND|EAI_AGAIN)\b|Could\s+not\s+reach\s+OpenSwarm|Unable\s+to\s+connect\s+to\s+OpenSwarm/i.test(text)) {
|
||||
return {
|
||||
kind: 'network',
|
||||
title: 'Network issue',
|
||||
detail: "Can't reach the OpenSwarm service. Check your internet connection and try again.",
|
||||
ctaLabel: 'Try again',
|
||||
ctaAction: 'retry',
|
||||
title: 'Connection issue',
|
||||
detail: "We couldn't reach the service. Once your connection is back, send a new message to continue.",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -662,6 +669,17 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
? parseElementContext(rawText)
|
||||
: { userMessage: rawText, elements: [] };
|
||||
|
||||
const renderedMarkdown = useMemo(() => (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ children, ...props }) => (
|
||||
<a {...props} style={{ cursor: 'pointer' }}>{children}</a>
|
||||
),
|
||||
}}
|
||||
>{rawText}</ReactMarkdown>
|
||||
), [rawText]);
|
||||
|
||||
// Detect friendly OpenSwarm / upstream errors and render a card instead of
|
||||
// raw "API Error: ..." text. Checks both the wrapped format the Claude CLI
|
||||
// uses ("API Error: NNN …") and the raw JSON body.
|
||||
@@ -871,9 +889,12 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
gap: 0.7,
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary }}>
|
||||
{openswarmError.title}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ErrorSlime size={22} />
|
||||
<Typography sx={{ fontSize: '0.92rem', fontWeight: 600, color: c.text.primary }}>
|
||||
{openswarmError.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography sx={{ fontSize: '0.82rem', color: c.text.secondary, lineHeight: 1.5 }}>
|
||||
{openswarmError.detail}
|
||||
</Typography>
|
||||
@@ -914,14 +935,22 @@ const MessageBubble: React.FC<Props> = React.memo(({ message, editing = false, o
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ children, ...props }) => (
|
||||
<a {...props} style={{ cursor: 'pointer' }}>{children}</a>
|
||||
),
|
||||
}}
|
||||
>{rawText}</ReactMarkdown>
|
||||
{isStreaming ? (
|
||||
<Box
|
||||
component="div"
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
{rawText}
|
||||
</Box>
|
||||
) : (
|
||||
renderedMarkdown
|
||||
)}
|
||||
{isStreaming && <StreamingCursor />}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -245,6 +245,16 @@ const BrowserCard: React.FC<Props> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// When a webview popup spawns while the app is in document fullscreen,
|
||||
// Chromium's compositor shifts to the popup and the parent surface goes
|
||||
// black with no fullscreenchange event. Drop fullscreen first so the
|
||||
// popup renders normally and stays interactive.
|
||||
const onNewWindow = () => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
wv.addEventListener('did-navigate', onNavigate);
|
||||
wv.addEventListener('did-navigate-in-page', onNavigate);
|
||||
wv.addEventListener('page-title-updated', onTitleUpdate);
|
||||
@@ -252,6 +262,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
wv.addEventListener('did-stop-loading', onLoadStop);
|
||||
wv.addEventListener('page-favicon-updated', onFaviconUpdate);
|
||||
wv.addEventListener('ipc-message', onIpcMessage as any);
|
||||
wv.addEventListener('new-window', onNewWindow as any);
|
||||
|
||||
cleanups.push(() => {
|
||||
unregisterWebview(browserId, tabId);
|
||||
@@ -262,6 +273,7 @@ const BrowserCard: React.FC<Props> = ({
|
||||
wv.removeEventListener('did-stop-loading', onLoadStop);
|
||||
wv.removeEventListener('page-favicon-updated', onFaviconUpdate);
|
||||
wv.removeEventListener('ipc-message', onIpcMessage as any);
|
||||
wv.removeEventListener('new-window', onNewWindow as any);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -376,7 +376,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
boxShadow: c.shadow.lg,
|
||||
padding: isExpanded ? '4px' : '6px',
|
||||
userSelect: 'none' as const,
|
||||
overflow: inputOpen ? 'visible' : 'hidden',
|
||||
overflow: inputOpen || newAgentBounce ? 'visible' : 'hidden',
|
||||
width: viewPickerOpen ? 480 : isExpanded ? 360 : undefined,
|
||||
}}
|
||||
>
|
||||
@@ -618,8 +618,10 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
aria-label="New Agent"
|
||||
data-onboarding="new-agent-button"
|
||||
tabIndex={0}
|
||||
onClick={onNewAgent}
|
||||
onAnimationEnd={newAgentBounce ? onNewAgentBounceEnd : undefined}
|
||||
onClick={() => {
|
||||
if (newAgentBounce) onNewAgentBounceEnd?.();
|
||||
onNewAgent();
|
||||
}}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -634,10 +636,14 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
|
||||
'&:hover': { bgcolor: c.accent.hover },
|
||||
'&:active': { bgcolor: c.accent.pressed },
|
||||
...(newAgentBounce && {
|
||||
animation: 'new-agent-bounce 0.7s ease-in-out 4',
|
||||
animation: 'new-agent-bounce 1.6s ease-out infinite',
|
||||
'@keyframes new-agent-bounce': {
|
||||
'0%, 100%': { transform: 'translateY(0)' },
|
||||
'50%': { transform: 'translateY(-8px)' },
|
||||
'0%': { transform: 'translateY(0)' },
|
||||
'15%': { transform: 'translateY(-10px)' },
|
||||
'30%': { transform: 'translateY(0)' },
|
||||
'42%': { transform: 'translateY(-4px)' },
|
||||
'55%': { transform: 'translateY(0)' },
|
||||
'100%': { transform: 'translateY(0)' },
|
||||
},
|
||||
}),
|
||||
}}
|
||||
|
||||
@@ -36,8 +36,8 @@ class WebSocketManager {
|
||||
private reconnectDelay = 1000;
|
||||
private maxReconnectDelay = 30000;
|
||||
private listeners: Map<string, Set<(data: any) => void>> = new Map();
|
||||
private deltaBuffer: Map<string, { sessionId: string; messageId: string; accumulated: string }> = new Map();
|
||||
private flushScheduled = false;
|
||||
private interpolatorState: Map<string, { sessionId: string; messageId: string; targetText: string; displayedLength: number }> = new Map();
|
||||
private interpolatorRafId: number | null = null;
|
||||
|
||||
constructor(url: string, options?: WSManagerOptions) {
|
||||
this.url = url;
|
||||
@@ -45,24 +45,60 @@ class WebSocketManager {
|
||||
}
|
||||
|
||||
private bufferDelta(sessionId: string, messageId: string, delta: string) {
|
||||
const existing = this.deltaBuffer.get(messageId);
|
||||
const existing = this.interpolatorState.get(messageId);
|
||||
if (existing) {
|
||||
existing.accumulated += delta;
|
||||
existing.targetText += delta;
|
||||
} else {
|
||||
this.deltaBuffer.set(messageId, { sessionId, messageId, accumulated: delta });
|
||||
}
|
||||
if (!this.flushScheduled) {
|
||||
this.flushScheduled = true;
|
||||
requestAnimationFrame(() => this.flushDeltas());
|
||||
this.interpolatorState.set(messageId, { sessionId, messageId, targetText: delta, displayedLength: 0 });
|
||||
}
|
||||
this.scheduleInterpolator();
|
||||
}
|
||||
|
||||
private flushDeltas() {
|
||||
this.flushScheduled = false;
|
||||
for (const [, { sessionId, messageId, accumulated }] of this.deltaBuffer) {
|
||||
store.dispatch(streamDelta({ sessionId, messageId, delta: accumulated }));
|
||||
private scheduleInterpolator() {
|
||||
if (this.interpolatorRafId != null) return;
|
||||
this.interpolatorRafId = requestAnimationFrame(() => this.tickInterpolator());
|
||||
}
|
||||
|
||||
// Drain each message's pending text at a paced, roughly-uniform rate so
|
||||
// bursty server emissions paint as a smooth stream of characters instead of
|
||||
// visible chunks. Rate adapts to backlog: small backlog → ~2 chars/frame
|
||||
// (~120cps, typewriter feel); large backlog → up to 40 chars/frame so we
|
||||
// catch up fast without pinning the main thread.
|
||||
private tickInterpolator() {
|
||||
this.interpolatorRafId = null;
|
||||
let workRemaining = false;
|
||||
for (const state of this.interpolatorState.values()) {
|
||||
const remaining = state.targetText.length - state.displayedLength;
|
||||
if (remaining <= 0) continue;
|
||||
const step = Math.min(Math.max(Math.ceil(remaining / 6), 2), 40);
|
||||
const nextLength = Math.min(state.displayedLength + step, state.targetText.length);
|
||||
const deltaSlice = state.targetText.slice(state.displayedLength, nextLength);
|
||||
state.displayedLength = nextLength;
|
||||
store.dispatch(streamDelta({ sessionId: state.sessionId, messageId: state.messageId, delta: deltaSlice }));
|
||||
if (state.displayedLength < state.targetText.length) workRemaining = true;
|
||||
}
|
||||
if (workRemaining) this.scheduleInterpolator();
|
||||
}
|
||||
|
||||
// Flush remaining pending text synchronously. Pass a messageId to flush
|
||||
// only that stream (used on stream_end so the tail isn't paced).
|
||||
private flushInterpolator(messageId?: string) {
|
||||
const drain = (state: { sessionId: string; messageId: string; targetText: string; displayedLength: number }) => {
|
||||
if (state.displayedLength >= state.targetText.length) return;
|
||||
const tail = state.targetText.slice(state.displayedLength);
|
||||
state.displayedLength = state.targetText.length;
|
||||
store.dispatch(streamDelta({ sessionId: state.sessionId, messageId: state.messageId, delta: tail }));
|
||||
};
|
||||
if (messageId) {
|
||||
const state = this.interpolatorState.get(messageId);
|
||||
if (state) {
|
||||
drain(state);
|
||||
this.interpolatorState.delete(messageId);
|
||||
}
|
||||
} else {
|
||||
for (const state of this.interpolatorState.values()) drain(state);
|
||||
this.interpolatorState.clear();
|
||||
}
|
||||
this.deltaBuffer.clear();
|
||||
}
|
||||
|
||||
connect() {
|
||||
@@ -97,6 +133,11 @@ class WebSocketManager {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.interpolatorRafId != null) {
|
||||
cancelAnimationFrame(this.interpolatorRafId);
|
||||
this.interpolatorRafId = null;
|
||||
}
|
||||
this.flushInterpolator();
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
@@ -146,7 +187,7 @@ class WebSocketManager {
|
||||
|
||||
case 'agent:message':
|
||||
if (session_id && data.message) {
|
||||
if (this.deltaBuffer.size > 0) this.flushDeltas();
|
||||
if (this.interpolatorState.size > 0) this.flushInterpolator();
|
||||
store.dispatch(addMessage({ sessionId: session_id, message: data.message }));
|
||||
}
|
||||
break;
|
||||
@@ -170,7 +211,7 @@ class WebSocketManager {
|
||||
|
||||
case 'agent:stream_end':
|
||||
if (session_id && data.message_id) {
|
||||
if (this.deltaBuffer.size > 0) this.flushDeltas();
|
||||
this.flushInterpolator(data.message_id);
|
||||
store.dispatch(streamEnd({
|
||||
sessionId: session_id,
|
||||
messageId: data.message_id,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user