[eric] multi-provider agent loop, PostHog analytics, settings overhaul

Multi-provider support (WIP - not fully tested):
- Owned agent loop replacing claude_agent_sdk (agent_loop.py, mcp_client.py)
- Provider adapters: Anthropic (native), OpenAI-compat (any endpoint), Gemini (native + schema cleaning)
- 19 models across 9 providers (Anthropic, OpenAI, Google, xAI, Meta, DeepSeek, Mistral, Qwen, Cohere)
- OpenRouter integration for 300+ models via single API key
- Builtin tool reimplementations (Read, Write, Edit, Glob, Grep, Bash, WebSearch, WebFetch, AskUserQuestion)
- Standalone MCP client manager (stdio/sse/http)
- Frontend: grouped model dropdown, provider selection, dynamic context windows

Analytics (tested):
- PostHog integration as single analytics source
- Tracks: app.opened, session.started/completed, tool.called, tool.approval_resolved, error.occurred
- Rich session data: user messages, assistant messages, session titles, tools used, MCP servers, task categories
- PostHog dashboard with 14 insights created via API
- Usage stats in Settings (Usage tab) with pixel-art bars

Settings (tested):
- 4 tabs: General, Models, Usage, Commands
- Model Providers tab with OpenRouter (recommended), Anthropic, OpenAI, Google key fields
- "Get key" links for each provider
- Usage tab with session/cost/tool stats + analytics opt-in toggle
- analytics_opt_in defaults to true, installation_id auto-generated

Merged haik/updates-v1 (tested):
- Sub-agent spawning, chat branching, browser control improvements
- Settings: auto_select_mode, expand_new_chats, auto_reveal_sub_agents, dev_mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ciregenz
2026-03-21 13:50:21 -07:00
co-authored by Claude Opus 4.6
parent f6274d225f
commit b6f45e8412
49 changed files with 5934 additions and 541 deletions
+331
View File
@@ -0,0 +1,331 @@
"""Owned agent loop — replaces claude_agent_sdk's query() function.
Generalizes the pattern from browser_agent.py (lines 243-334) into a
provider-agnostic, streaming, HITL-aware tool-use loop.
"""
from __future__ import annotations
import json
import logging
import time
from typing import Any, Callable, Awaitable
from uuid import uuid4
from backend.apps.agents.providers.base import (
BaseProvider, ContentBlock, ModelResponse, ProviderMessage,
StreamEvent, ToolCall, ToolSchema,
)
logger = logging.getLogger(__name__)
# Type aliases for callbacks
ToolExecutor = Callable[[str, dict], Awaitable[list[dict]]]
# hitl_handler(tool_name, tool_input) -> (approved, updated_input_or_None)
HITLHandler = Callable[[str, dict], Awaitable[tuple[bool, dict | None]]]
# ws_emitter(event_type, data) -> None
WSEmitter = Callable[[str, dict], Awaitable[None]]
class AgentLoop:
"""Provider-agnostic agent loop with streaming and HITL support.
The loop:
1. Sends user message to the model
2. Streams the response (emitting WebSocket events)
3. If the model requests tool use:
a. For each tool call: check HITL permission → execute → collect result
b. Append tool results → go to step 2
4. If the model stops (end_turn/max_tokens): done
"""
def __init__(
self,
session_id: str,
provider: BaseProvider,
model: str,
system_prompt: str | None,
tools: list[ToolSchema],
tool_executor: ToolExecutor,
hitl_handler: HITLHandler,
ws_emitter: WSEmitter,
max_turns: int | None = None,
cwd: str | None = None,
):
self.session_id = session_id
self.provider = provider
self.model = model
self.system_prompt = system_prompt
self.tools = tools
self.tool_executor = tool_executor
self.hitl_handler = hitl_handler
self.ws_emitter = ws_emitter
self.max_turns = max_turns
self.cwd = cwd
# Conversation history in provider-agnostic format
self.messages: list[ProviderMessage] = []
# Token tracking
self.total_input_tokens = 0
self.total_output_tokens = 0
async def run(self, user_content: Any) -> None:
"""Run the agent loop for a single user turn."""
# Append user message
user_msg = self.provider.format_user_message(user_content)
self.messages.append(user_msg)
turn = 0
while True:
if self.max_turns and turn >= self.max_turns:
logger.info(f"Agent {self.session_id}: max turns ({self.max_turns}) reached")
break
turn += 1
# Stream the model response and collect it
response = await self._stream_and_collect()
# Track usage
self.total_input_tokens += response.usage.get("input_tokens", 0)
self.total_output_tokens += response.usage.get("output_tokens", 0)
# Append assistant message to conversation history
assistant_msg = self.provider.format_assistant_message(response)
self.messages.append(assistant_msg)
# If no tool use, we're done
if response.stop_reason != "tool_use":
break
# Execute tools
tool_results = await self._execute_tools(response)
if not tool_results:
break
# Append tool results
self.messages.append(ProviderMessage(role="tool_result", content=tool_results))
async def _stream_and_collect(self) -> ModelResponse:
"""Stream model output, emit WebSocket events, collect full response."""
collected_content: list[ContentBlock] = []
collected_usage: dict[str, int] = {}
stop_reason = "end_turn"
# Track streaming state for WS emissions
stream_text_msg_id: str | None = None
stream_tool_msg_ids: dict[int, str] = {} # block index -> msg_id
block_index_map: dict[int, str] = {} # block index -> msg_id
# Buffers for collecting content
text_buffers: dict[int, str] = {}
json_buffers: dict[int, str] = {}
tool_names: dict[int, str] = {}
tool_ids: dict[int, str] = {}
block_types: dict[int, str] = {}
async for event in self.provider.stream_message(
model=self.model,
system=self.system_prompt,
messages=self.messages,
tools=self.tools,
):
if event.type == "content_block_start":
if event.block_type == "text":
if stream_text_msg_id is None:
stream_text_msg_id = uuid4().hex
await self.ws_emitter("agent:stream_start", {
"message_id": stream_text_msg_id,
"role": "assistant",
})
block_index_map[event.index] = stream_text_msg_id
block_types[event.index] = "text"
text_buffers[event.index] = ""
elif event.block_type == "tool_use":
tool_msg_id = uuid4().hex
stream_tool_msg_ids[event.index] = tool_msg_id
block_index_map[event.index] = tool_msg_id
block_types[event.index] = "tool_use"
tool_names[event.index] = event.tool_name
tool_ids[event.index] = event.tool_id
json_buffers[event.index] = ""
await self.ws_emitter("agent:stream_start", {
"message_id": tool_msg_id,
"role": "tool_call",
"tool_name": event.tool_name,
})
elif event.type == "content_block_delta":
msg_id = block_index_map.get(event.index)
if not msg_id:
continue
if event.delta_type == "text_delta":
text_buffers.setdefault(event.index, "")
text_buffers[event.index] += event.text
await self.ws_emitter("agent:stream_delta", {
"message_id": msg_id,
"delta": event.text,
})
elif event.delta_type == "input_json_delta":
json_buffers.setdefault(event.index, "")
json_buffers[event.index] += event.text
await self.ws_emitter("agent:stream_delta", {
"message_id": msg_id,
"delta": event.text,
})
elif event.type == "content_block_stop":
msg_id = block_index_map.get(event.index)
bt = block_types.get(event.index, "")
if bt == "text":
collected_content.append(
ContentBlock(type="text", text=text_buffers.get(event.index, ""))
)
elif bt == "tool_use":
try:
tool_input = json.loads(json_buffers.get(event.index, "{}"))
except json.JSONDecodeError:
tool_input = {}
collected_content.append(ContentBlock(
type="tool_use",
tool_call=ToolCall(
id=tool_ids.get(event.index, uuid4().hex),
name=tool_names.get(event.index, ""),
input=tool_input,
),
))
# Send stream_end for tool blocks (text block ends at message_stop)
if msg_id and bt == "tool_use":
await self.ws_emitter("agent:stream_end", {
"message_id": msg_id,
})
elif event.type == "usage":
# Accumulate token usage from provider stream
for k, v in event.usage.items():
collected_usage[k] = collected_usage.get(k, 0) + v
elif event.type == "message_stop":
# Check if any tool calls means stop_reason is tool_use
has_tool_use = any(b.type == "tool_use" for b in collected_content)
if has_tool_use:
stop_reason = "tool_use"
# End text stream
if stream_text_msg_id:
await self.ws_emitter("agent:stream_end", {
"message_id": stream_text_msg_id,
})
# Build and emit the collected messages
await self._emit_collected_messages(
collected_content, stream_text_msg_id, stream_tool_msg_ids,
)
return ModelResponse(
content=collected_content,
stop_reason=stop_reason,
usage=collected_usage,
)
async def _emit_collected_messages(
self,
content: list[ContentBlock],
text_msg_id: str | None,
tool_msg_ids: dict[int, str],
) -> None:
"""Emit finalized agent:message events for the collected response."""
from backend.apps.agents.models import Message
# Emit text message
text_parts = [b.text for b in content if b.type == "text" and b.text]
if text_parts:
msg = Message(
id=text_msg_id or uuid4().hex,
role="assistant",
content="\n".join(text_parts),
)
await self.ws_emitter("agent:message", {
"message": msg.model_dump(mode="json"),
})
# Emit tool call messages
tool_blocks = [b for b in content if b.type == "tool_use" and b.tool_call]
tool_id_list = sorted(tool_msg_ids.items(), key=lambda x: x[0])
for i, block in enumerate(tool_blocks):
tc = block.tool_call
msg_id = tool_id_list[i][1] if i < len(tool_id_list) else uuid4().hex
msg = Message(
id=msg_id,
role="tool_call",
content={
"id": tc.id,
"tool": tc.name,
"input": tc.input,
},
)
await self.ws_emitter("agent:message", {
"message": msg.model_dump(mode="json"),
})
async def _execute_tools(self, response: ModelResponse) -> list[dict]:
"""Execute all tool calls from a response, respecting HITL permissions.
Returns a list of tool result dicts formatted for the provider.
"""
from backend.apps.agents.models import Message
results = []
for block in response.content:
if block.type != "tool_use" or not block.tool_call:
continue
tc = block.tool_call
start_time = time.time()
# HITL permission check
approved, updated_input = await self.hitl_handler(tc.name, tc.input)
if not approved:
result_content = [{"type": "text", "text": "Tool use was denied by the user."}]
else:
tool_input = updated_input if updated_input else tc.input
try:
result_content = await self.tool_executor(tc.name, tool_input)
except Exception as e:
logger.warning(f"Tool execution error: {tc.name}: {e}")
result_content = [{"type": "text", "text": f"Error executing {tc.name}: {e}"}]
elapsed_ms = int((time.time() - start_time) * 1000)
# Emit tool result to frontend
result_text = ""
for block_item in result_content:
if isinstance(block_item, dict) and block_item.get("type") == "text":
result_text = block_item.get("text", "")
break
result_msg = Message(
role="tool_result",
content={
"text": result_text[:2000] if result_text else "Done.",
"tool_name": tc.name,
"elapsed_ms": elapsed_ms,
},
)
await self.ws_emitter("agent:message", {
"message": result_msg.model_dump(mode="json"),
})
# Format for provider
results.append(
self.provider.format_tool_result(tc.id, result_content)
)
return results
+213 -370
View File
@@ -23,10 +23,34 @@ from backend.apps.tools_lib.tools_lib import (
refresh_google_token,
)
from backend.config.paths import SESSIONS_DIR
from backend.apps.analytics.collector import record as _analytics
logger = logging.getLogger(__name__)
os.environ.setdefault("CLAUDE_CODE_STREAM_CLOSE_TIMEOUT", "3600000")
_TOOL_CATEGORIES: dict[str, str] = {
"gmail": "email", "calendar": "calendar", "drive": "files",
"sheet": "files", "doc": "files", "slide": "files",
"tweet": "social", "twitter": "social", "reddit": "social",
"Bash": "coding", "Edit": "coding", "Write": "coding", "Read": "coding",
"Glob": "coding", "Grep": "coding",
"BrowserAgent": "browsing", "BrowserAgents": "browsing",
"WebSearch": "research", "WebFetch": "research",
}
def _infer_task_category(tool_names: list[str]) -> str:
"""Infer what the user was doing from the tools used."""
if not tool_names:
return "chat"
counts: dict[str, int] = {}
for name in tool_names:
for keyword, category in _TOOL_CATEGORIES.items():
if keyword.lower() in name.lower():
counts[category] = counts.get(category, 0) + 1
break
if not counts:
return "other"
return max(counts, key=counts.get)
def _save_session(session_id: str, doc_data: dict):
@@ -328,6 +352,7 @@ class AgentManager:
session = AgentSession(
id=session_id,
name=config.name,
provider=config.provider,
model=config.model,
mode=config.mode,
system_prompt=config.system_prompt,
@@ -337,13 +362,20 @@ class AgentManager:
dashboard_id=config.dashboard_id,
)
self.sessions[session_id] = session
_analytics("session.started", {
"model": session.model,
"provider": session.provider,
"mode": session.mode,
"tool_count": len(tools),
}, session_id=session_id, dashboard_id=config.dashboard_id)
await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id,
"status": "running",
"session": session.model_dump(mode="json"),
})
return session
def _resolve_context_paths(self, context_paths: list | None) -> str:
@@ -468,28 +500,20 @@ class AgentManager:
return content
async def _run_agent_loop(self, session_id: str, prompt: str, images: list | None = None, context_paths: list | None = None, forced_tools: list[str] | None = None, attached_skills: list | None = None, fork_session: bool = False, selected_browser_ids: list[str] | None = None):
"""Run the Claude Agent SDK query loop for a session."""
"""Run the owned agent loop for a session (multi-provider)."""
from backend.apps.agents.agent_loop import AgentLoop
from backend.apps.agents.mcp_client import MCPClientManager
from backend.apps.agents.providers.registry import create_provider, calculate_cost
from backend.apps.settings.credentials import validate_credentials
session = self.sessions.get(session_id)
if not session:
return
prompt_content = self._build_prompt_content(prompt, images, context_paths, forced_tools, attached_skills)
try:
from claude_agent_sdk import (
query, ClaudeAgentOptions, AssistantMessage, ResultMessage,
)
from claude_agent_sdk.types import (
HookMatcher, PermissionResultAllow, PermissionResultDeny,
TextBlock, ToolUseBlock, StreamEvent,
)
except ImportError:
logger.warning("claude_agent_sdk not installed, running in mock mode")
await self._run_mock_agent(session_id, prompt)
return
session.status = "running"
_builtin_perms = load_builtin_permissions()
def _get_effective_policy(tool_name: str) -> str:
@@ -539,6 +563,11 @@ class AgentManager:
session_id, request_id, tool_name, safe_input
)
_analytics("tool.approval_resolved", {
"tool_name": tool_name,
"decision": decision.get("behavior", "deny"),
}, session_id=session_id)
session.pending_approvals = [
a for a in session.pending_approvals if a.id != request_id
]
@@ -549,159 +578,7 @@ class AgentManager:
})
return decision
async def can_use_tool(tool_name, input_data, context):
if tool_name != "AskUserQuestion":
policy = _get_effective_policy(tool_name)
if policy == "always_allow":
return PermissionResultAllow(updated_input=input_data)
if policy == "deny":
return PermissionResultDeny(message="Tool denied by permission policy")
decision = await _request_user_approval(tool_name, input_data)
if decision.get("behavior") == "allow":
return PermissionResultAllow(
updated_input=decision.get("updated_input", input_data)
)
return PermissionResultDeny(
message=decision.get("message", "User denied this action")
)
tool_start_times: dict[str, float] = {}
async def pre_tool_hook(input_data, tool_use_id, context):
tool_name = input_data.get("tool_name", "")
hook_event = input_data.get("hook_event_name", "PreToolUse")
if tool_name and tool_name != "AskUserQuestion":
policy = _get_effective_policy(tool_name)
if policy == "deny":
return {
"hookSpecificOutput": {
"hookEventName": hook_event,
"permissionDecision": "deny",
"permissionDecisionReason": "Tool denied by permission policy",
}
}
if policy == "ask":
tool_input = input_data.get("tool_input", {})
decision = await _request_user_approval(tool_name, tool_input)
if decision.get("behavior") == "allow":
if tool_use_id:
tool_start_times[tool_use_id] = time.time()
return {
"hookSpecificOutput": {
"hookEventName": hook_event,
"permissionDecision": "allow",
}
}
return {
"hookSpecificOutput": {
"hookEventName": hook_event,
"permissionDecision": "deny",
"permissionDecisionReason": decision.get("message", "User denied this action"),
}
}
if tool_use_id:
tool_start_times[tool_use_id] = time.time()
return {}
async def post_tool_hook(input_data, tool_use_id, context):
elapsed_ms = None
if tool_use_id and tool_use_id in tool_start_times:
elapsed_ms = int((time.time() - tool_start_times.pop(tool_use_id)) * 1000)
raw_response = input_data.get("tool_response", "")
if isinstance(raw_response, list) and raw_response:
text_parts = [
block.get("text", "")
for block in raw_response
if isinstance(block, dict) and block.get("type") == "text"
]
if text_parts:
raw_response = "\n".join(text_parts) if len(text_parts) > 1 else text_parts[0]
if isinstance(raw_response, str):
content = raw_response
else:
try:
import json as _json
content = _json.dumps(raw_response, indent=2, default=str)
except Exception:
content = str(raw_response)
result_payload = {"text": content}
hook_tool_name = input_data.get("tool_name", "")
if hook_tool_name:
result_payload["tool_name"] = hook_tool_name
if elapsed_ms is not None:
result_payload["elapsed_ms"] = elapsed_ms
if hook_tool_name == "Agent":
tool_input = input_data.get("tool_input", {})
agent_prompt = tool_input.get("prompt", tool_input.get("task", ""))
sub_text = content
sub_cost = 0.0
sub_tokens = {"input": 0, "output": 0}
sub_model = session.model
if isinstance(raw_response, dict):
blocks = raw_response.get("content")
if isinstance(blocks, list):
parts = [
b.get("text", "")
for b in blocks
if isinstance(b, dict) and b.get("type") == "text"
]
if parts:
sub_text = "\n".join(parts) if len(parts) > 1 else parts[0]
elif isinstance(raw_response.get("text"), str):
sub_text = raw_response["text"]
usage = raw_response.get("usage", {})
if isinstance(usage, dict):
sub_tokens["input"] = usage.get("input_tokens", 0) + usage.get("cache_creation_input_tokens", 0) + usage.get("cache_read_input_tokens", 0)
sub_tokens["output"] = usage.get("output_tokens", 0)
if raw_response.get("model"):
sub_model = raw_response["model"]
sub_session_id = uuid4().hex
sub_name = agent_prompt[:50] if agent_prompt else "Sub-agent"
sub_session = AgentSession(
id=sub_session_id,
name=sub_name,
status="completed",
model=sub_model,
mode="sub-agent",
cwd=session.cwd,
created_at=datetime.now(),
cost_usd=sub_cost,
tokens=sub_tokens,
messages=[
Message(role="user", content=agent_prompt, branch_id="main"),
Message(role="assistant", content=sub_text, branch_id="main"),
],
dashboard_id=session.dashboard_id,
parent_session_id=session_id,
)
self.sessions[sub_session_id] = sub_session
await ws_manager.broadcast_global("agent:status", {
"session_id": sub_session_id,
"status": sub_session.status,
"session": sub_session.model_dump(mode="json"),
})
result_payload["sub_session_id"] = sub_session_id
result_msg = Message(role="tool_result", content=result_payload, branch_id=session.active_branch_id)
session.messages.append(result_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
"session_id": session_id,
"message": result_msg.model_dump(mode="json"),
})
return {"continue_": True}
mcp_manager: MCPClientManager | None = None
try:
_, mode_sys_prompt, _ = self._resolve_mode(session.mode)
@@ -716,6 +593,13 @@ class AgentManager:
skill_block = f"<app_builder_reference>\n{VIEW_BUILDER_SKILL}\n</app_builder_reference>"
composed_prompt = f"{composed_prompt}\n\n{skill_block}" if composed_prompt else skill_block
# Validate credentials for the provider
validate_credentials(global_settings, session.provider)
# Create the provider adapter
provider = create_provider(session.provider, global_settings)
# Build MCP servers config
mcp_servers = await self._build_mcp_servers(session.allowed_tools)
_browser_delegation_tools = ["CreateBrowserAgent", "BrowserAgent", "BrowserAgents"]
@@ -765,214 +649,111 @@ class AgentManager:
"type": "stdio",
}
effective_allowed = [
t for t in session.allowed_tools
if t in FULL_TOOLS and _builtin_perms.get(t, "always_allow") == "always_allow"
]
# Connect MCP servers and collect tool schemas
mcp_manager = MCPClientManager()
await mcp_manager.__aenter__()
effective_disallowed = [
t for t in FULL_TOOLS
if _builtin_perms.get(t, "always_allow") == "deny"
]
mcp_tool_schemas = []
for server_name, server_config in mcp_servers.items():
tools = await mcp_manager.connect(server_name, server_config)
mcp_tool_schemas.extend(tools)
if mcp_servers:
all_tools_list = load_all_tools()
for name in mcp_servers:
if name == "openswarm-browser-agent":
for bt in _browser_delegation_tools:
policy = _builtin_perms.get(bt, "always_allow")
if policy == "always_allow":
effective_allowed.append(f"mcp__openswarm-browser-agent__{bt}")
elif policy == "deny":
effective_disallowed.append(f"mcp__openswarm-browser-agent__{bt}")
continue
# Collect builtin + MCP tool schemas
from backend.apps.agents.tools.registry import get_all_tool_schemas
all_tool_schemas = list(get_all_tool_schemas()) + list(mcp_tool_schemas)
if name == "openswarm-invoke-agent":
for it in _invoke_agent_tools:
policy = _builtin_perms.get(it, "always_allow")
if policy == "always_allow":
effective_allowed.append(f"mcp__openswarm-invoke-agent__{it}")
elif policy == "deny":
effective_disallowed.append(f"mcp__openswarm-invoke-agent__{it}")
continue
# HITL handler for the agent loop
async def hitl_handler(tool_name: str, tool_input: dict) -> tuple[bool, dict | None]:
policy = _get_effective_policy(tool_name)
if policy == "always_allow":
return True, None
if policy == "deny":
return False, None
# policy == "ask"
decision = await _request_user_approval(tool_name, tool_input)
if decision.get("behavior") == "allow":
return True, decision.get("updated_input")
return False, None
tool_def = next(
(t for t in all_tools_list
if t.mcp_config and t.enabled and _sanitize_server_name(t.name) == name),
None,
)
if tool_def:
denied = _get_denied_tool_names(tool_def)
known = _get_all_known_tool_names(tool_def)
for tn in known - denied:
policy = tool_def.tool_permissions.get(tn, "ask")
if policy == "always_allow":
effective_allowed.append(f"mcp__{name}__{tn}")
for tn in denied:
effective_disallowed.append(f"mcp__{name}__{tn}")
else:
effective_allowed.append(f"mcp__{name}__*")
# Tool executor — routes to builtins or MCP servers
async def tool_executor(tool_name: str, tool_input: dict) -> list[dict]:
t0 = time.time()
options_kwargs = {
"model": session.model,
"max_buffer_size": 5 * 1024 * 1024,
"permission_mode": "default",
"can_use_tool": can_use_tool,
"hooks": {
"PreToolUse": [HookMatcher(matcher=None, hooks=[pre_tool_hook])],
"PostToolUse": [HookMatcher(matcher=None, hooks=[post_tool_hook])],
},
"allowed_tools": effective_allowed,
"disallowed_tools": effective_disallowed,
"include_partial_messages": True,
}
if not global_settings.anthropic_api_key:
raise ValueError("Anthropic API key not configured. Set it in Settings.")
options_kwargs["env"] = {"ANTHROPIC_API_KEY": global_settings.anthropic_api_key}
if mcp_servers:
options_kwargs["mcp_servers"] = mcp_servers
if composed_prompt:
options_kwargs["system_prompt"] = composed_prompt
if session.max_turns:
options_kwargs["max_turns"] = session.max_turns
# Check builtin tools first
from backend.apps.agents.tools.registry import get_tool
from backend.apps.agents.tools.base import ToolContext
builtin = get_tool(tool_name)
if builtin:
ctx = ToolContext(cwd=session.cwd or os.path.expanduser("~"), session_id=session_id)
result = await builtin.execute(tool_input, ctx)
elapsed_ms = int((time.time() - t0) * 1000)
_analytics("tool.called", {
"tool_name": tool_name,
"tool_type": "builtin",
"elapsed_ms": elapsed_ms,
"input_summary": str(tool_input)[:200],
}, session_id=session_id)
return result
if session.cwd:
options_kwargs["cwd"] = session.cwd
# Check MCP tools
parsed = mcp_manager.parse_mcp_tool_name(tool_name)
if parsed:
server_name, bare_name = parsed
result = await mcp_manager.call_tool(server_name, bare_name, tool_input)
elapsed_ms = int((time.time() - t0) * 1000)
_analytics("tool.called", {
"tool_name": tool_name,
"tool_short_name": bare_name,
"tool_type": "mcp",
"mcp_server": server_name,
"elapsed_ms": elapsed_ms,
"input_summary": str(tool_input)[:200],
}, session_id=session_id)
return result
if session.sdk_session_id:
options_kwargs["resume"] = session.sdk_session_id
if fork_session:
options_kwargs["fork_session"] = True
return [{"type": "text", "text": f"Unknown tool: {tool_name}"}]
options = ClaudeAgentOptions(**options_kwargs)
# WebSocket emitter — captures messages into session
async def ws_emitter(event_type: str, data: dict) -> None:
data["session_id"] = session_id
await ws_manager.send_to_session(session_id, event_type, data)
# Capture finalized messages into session.messages
if event_type == "agent:message" and "message" in data:
msg_data = data["message"]
try:
msg = Message(**msg_data)
msg.branch_id = session.active_branch_id
session.messages.append(msg)
except Exception:
pass
async def prompt_stream():
yield {
"type": "user",
"message": {"role": "user", "content": prompt_content},
}
# Create and run the agent loop
loop = AgentLoop(
session_id=session_id,
provider=provider,
model=session.model,
system_prompt=composed_prompt,
tools=all_tool_schemas,
tool_executor=tool_executor,
hitl_handler=hitl_handler,
ws_emitter=ws_emitter,
max_turns=session.max_turns,
cwd=session.cwd,
)
stream_text_msg_id = None
stream_tool_msg_ids_ordered = []
stream_block_index_map = {}
await loop.run(prompt_content)
async for message in query(
prompt=prompt_stream(),
options=options,
):
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 == "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 == "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 = []
tool_uses = []
for block in message.content:
if isinstance(block, TextBlock):
content_parts.append(block.text)
elif isinstance(block, ToolUseBlock):
tool_uses.append({
"id": block.id,
"tool": block.name,
"input": block.input,
})
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"),
})
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,
})
# Update cost from token usage
session.tokens["input"] += loop.total_input_tokens
session.tokens["output"] += loop.total_output_tokens
session.cost_usd += calculate_cost(
session.provider, session.model,
loop.total_input_tokens, loop.total_output_tokens,
)
await ws_manager.send_to_session(session_id, "agent:cost_update", {
"session_id": session_id,
"cost_usd": session.cost_usd,
})
session.status = "completed"
except asyncio.CancelledError:
@@ -980,6 +761,10 @@ class AgentManager:
except Exception as e:
logger.exception(f"Agent {session_id} error: {e}")
session.status = "error"
_analytics("error.occurred", {
"error_category": type(e).__name__,
"error_message": str(e)[:200],
}, session_id=session_id)
error_msg = Message(role="system", content=f"Error: {str(e)}", branch_id=session.active_branch_id)
session.messages.append(error_msg)
await ws_manager.send_to_session(session_id, "agent:message", {
@@ -987,7 +772,61 @@ class AgentManager:
"message": error_msg.model_dump(mode="json"),
})
finally:
if mcp_manager:
try:
await mcp_manager.__aexit__(None, None, None)
except Exception as e:
logger.warning(f"MCP cleanup error: {e}")
if session_id in self.sessions:
# Track session completion
duration = 0.0
if session.created_at:
duration = (datetime.now() - session.created_at).total_seconds()
msg_count = len([m for m in session.messages if m.role in ("user", "assistant")])
tool_names = [m.content.get("tool", "") for m in session.messages if m.role == "tool_call" and isinstance(m.content, dict)]
task_category = _infer_task_category(tool_names)
# Collect rich session data for analytics — full messages, no truncation
user_messages = [
m.content if isinstance(m.content, str) else str(m.content)
for m in session.messages if m.role == "user"
]
assistant_messages = [
m.content if isinstance(m.content, str) else str(m.content)
for m in session.messages if m.role == "assistant"
]
# Unique tools used with call counts
tool_call_counts: dict[str, int] = {}
for tn in tool_names:
short = tn.split("__")[-1] if "__" in tn else tn
tool_call_counts[short] = tool_call_counts.get(short, 0) + 1
# MCP servers used
mcp_servers_used = list(set(
tn.split("__")[1] for tn in tool_names
if tn.startswith("mcp__") and len(tn.split("__")) >= 3
))
_analytics("session.completed", {
"model": session.model,
"provider": session.provider,
"mode": session.mode,
"cost_usd": session.cost_usd,
"message_count": msg_count,
"duration_seconds": round(duration, 1),
"status": session.status,
"task_category": task_category,
"tool_count": len(tool_names),
# Rich data
"session_title": session.name,
"user_messages": user_messages,
"assistant_messages": assistant_messages,
"first_user_message": user_messages[0] if user_messages else "",
"tools_used": tool_call_counts,
"tools_list": list(tool_call_counts.keys()),
"mcp_servers_used": mcp_servers_used,
"system_prompt_length": len(session.system_prompt or ""),
}, session_id=session_id, dashboard_id=session.dashboard_id)
await ws_manager.send_to_session(session_id, "agent:status", {
"session_id": session_id,
"status": session.status,
@@ -1134,6 +973,7 @@ class AgentManager:
prompt: str,
mode: str | None = None,
model: str | None = None,
provider: str | None = None,
images: list | None = None,
context_paths: list | None = None,
forced_tools: list[str] | None = None,
@@ -1145,12 +985,15 @@ class AgentManager:
session = self.sessions.get(session_id)
if not session:
raise ValueError(f"Session {session_id} not found")
existing = self.tasks.get(session_id)
if existing and not existing.done():
return
session_changed = False
if provider and provider != session.provider:
session.provider = provider
session_changed = True
if model and model != session.model:
session.model = model
session_changed = True
+10
View File
@@ -52,6 +52,7 @@ async def send_message(session_id: str, body: dict):
prompt,
mode=body.get("mode"),
model=body.get("model"),
provider=body.get("provider"),
images=body.get("images"),
context_paths=body.get("context_paths"),
forced_tools=body.get("forced_tools"),
@@ -178,3 +179,12 @@ async def resume_session(session_id: str):
raise HTTPException(status_code=404, detail=str(e))
return {"session": session.model_dump(mode="json")}
@agents.router.get("/models")
async def list_models():
"""Return available models grouped by provider, filtered by configured credentials."""
from backend.apps.agents.providers.registry import get_available_models
from backend.apps.settings.settings import load_settings
settings = load_settings()
return {"models": get_available_models(settings)}
+1 -1
View File
@@ -24,7 +24,7 @@ logger = logging.getLogger(__name__)
MODEL_MAP = {
"sonnet": "claude-sonnet-4-20250514",
"opus": "claude-opus-4-20250514",
"haiku": "claude-haiku-4-20250414",
"haiku": "claude-haiku-4-5-20251001",
}
BROWSER_TOOLS_SCHEMA = [
+360
View File
@@ -0,0 +1,360 @@
"""Standalone MCP client manager for agent sessions.
Replaces claude_agent_sdk's internal MCP server management.
One MCPClientManager instance per agent session — manages connections
to stdio/http/sse MCP servers, discovers tools, and routes tool calls.
"""
from __future__ import annotations
import asyncio
import json
import logging
from contextlib import AsyncExitStack
from dataclasses import dataclass, field
from typing import Any
from backend.apps.agents.providers.base import ToolSchema
logger = logging.getLogger(__name__)
@dataclass
class MCPConnection:
"""A live connection to an MCP server."""
server_name: str
session: Any # mcp.ClientSession
tools: list[ToolSchema] = field(default_factory=list)
class MCPClientManager:
"""Manages connections to MCP servers for a single agent session."""
def __init__(self):
self._connections: dict[str, MCPConnection] = {}
self._exit_stack = AsyncExitStack()
self._started = False
async def __aenter__(self):
await self._exit_stack.__aenter__()
self._started = True
return self
async def __aexit__(self, *exc):
await self.disconnect_all()
try:
await self._exit_stack.__aexit__(*exc)
except (BaseExceptionGroup, ExceptionGroup, Exception) as e:
# MCP subprocess cleanup errors are non-fatal
logger.warning(f"MCP cleanup error (non-fatal): {e}")
self._started = False
async def connect(self, server_name: str, config: dict, timeout: float = 30.0) -> list[ToolSchema]:
"""Connect to an MCP server and return its available tools.
The tools are returned with names prefixed as mcp__<server_name>__<tool_name>.
"""
transport = config.get("type", "stdio")
try:
if transport == "stdio":
coro = self._connect_stdio(server_name, config)
elif transport == "sse":
coro = self._connect_sse(server_name, config)
elif transport == "http":
coro = self._connect_http(server_name, config)
else:
logger.warning(f"Unsupported MCP transport: {transport} for {server_name}")
return []
conn = await asyncio.wait_for(coro, timeout=timeout)
self._connections[server_name] = conn
logger.info(f"MCP connected: {server_name} ({len(conn.tools)} tools)")
return conn.tools
except asyncio.TimeoutError:
logger.warning(f"MCP server {server_name} connection timed out after {timeout}s")
return []
except Exception as e:
logger.warning(f"Failed to connect MCP server {server_name}: {e}")
return []
async def _connect_stdio(self, server_name: str, config: dict) -> MCPConnection:
"""Connect to a stdio MCP server (spawns a subprocess)."""
from mcp import ClientSession
from mcp.client.stdio import stdio_client, StdioServerParameters
command = config.get("command", "")
args = config.get("args", [])
env = config.get("env")
params = StdioServerParameters(
command=command,
args=args,
env=env,
)
transport = await self._exit_stack.enter_async_context(
stdio_client(params)
)
read_stream, write_stream = transport
session = await self._exit_stack.enter_async_context(
ClientSession(read_stream, write_stream)
)
await session.initialize()
result = await session.list_tools()
tools = [
ToolSchema(
name=f"mcp__{server_name}__{t.name}",
description=t.description or "",
input_schema=t.inputSchema if hasattr(t, "inputSchema") else (t.input_schema if hasattr(t, "input_schema") else {}),
)
for t in result.tools
]
return MCPConnection(server_name=server_name, session=session, tools=tools)
async def _connect_sse(self, server_name: str, config: dict) -> MCPConnection:
"""Connect to an SSE MCP server."""
from mcp import ClientSession
from mcp.client.sse import sse_client
url = config.get("url", "")
headers = config.get("headers")
transport = await self._exit_stack.enter_async_context(
sse_client(url=url, headers=headers, timeout=30, sse_read_timeout=300)
)
read_stream, write_stream = transport
session = await self._exit_stack.enter_async_context(
ClientSession(read_stream, write_stream)
)
await session.initialize()
result = await session.list_tools()
tools = [
ToolSchema(
name=f"mcp__{server_name}__{t.name}",
description=t.description or "",
input_schema=t.inputSchema if hasattr(t, "inputSchema") else (t.input_schema if hasattr(t, "input_schema") else {}),
)
for t in result.tools
]
return MCPConnection(server_name=server_name, session=session, tools=tools)
async def _connect_http(self, server_name: str, config: dict) -> MCPConnection:
"""Connect to a Streamable HTTP MCP server.
Falls back to SSE if streamable HTTP fails.
"""
url = config.get("url", "")
headers = config.get("headers")
# Try streamable HTTP first, fall back to SSE
try:
return await self._connect_http_streamable(server_name, url, headers)
except Exception as e:
logger.info(f"Streamable HTTP failed for {server_name}, trying SSE: {e}")
return await self._connect_sse(server_name, config)
async def _connect_http_streamable(
self, server_name: str, url: str, headers: dict | None,
) -> MCPConnection:
"""Connect via Streamable HTTP (JSON-RPC POST)."""
import httpx
from mcp import ClientSession
# Use httpx for streamable HTTP — keep client alive in the exit stack
client = await self._exit_stack.enter_async_context(
httpx.AsyncClient(timeout=30.0)
)
h = {
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
**(headers or {}),
}
# Initialize
init_resp = await client.post(url, headers=h, json={
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {
"protocolVersion": "2025-03-26",
"capabilities": {},
"clientInfo": {"name": "self-swarm", "version": "0.1.0"},
},
})
if init_resp.status_code not in (200, 201):
raise ConnectionError(f"MCP initialize failed: {init_resp.status_code}")
session_id = init_resp.headers.get("mcp-session-id", "")
if session_id:
h["mcp-session-id"] = session_id
# Notify initialized
await client.post(url, headers=h, json={
"jsonrpc": "2.0", "method": "notifications/initialized",
})
# List tools
list_resp = await client.post(url, headers=h, json={
"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {},
})
if list_resp.status_code not in (200, 201):
raise ConnectionError(f"MCP tools/list failed: {list_resp.status_code}")
ct = list_resp.headers.get("content-type", "")
if "text/event-stream" in ct:
data = self._parse_sse_json(list_resp.text)
else:
data = list_resp.json()
if not data:
raise ConnectionError("Empty response from MCP server")
tools_list = data.get("result", {}).get("tools", [])
tools = [
ToolSchema(
name=f"mcp__{server_name}__{t.get('name', '')}",
description=t.get("description", ""),
input_schema=t.get("inputSchema", t.get("input_schema", {})),
)
for t in tools_list
]
# Store the HTTP client info for call_tool
conn = MCPConnection(server_name=server_name, session=None, tools=tools)
conn._http_client = client # type: ignore[attr-defined]
conn._http_url = url # type: ignore[attr-defined]
conn._http_headers = h # type: ignore[attr-defined]
conn._next_id = 3 # type: ignore[attr-defined]
return conn
@staticmethod
def _parse_sse_json(text: str) -> dict | None:
"""Extract JSON from an SSE response body."""
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("data:"):
payload = stripped[len("data:"):].strip()
if payload:
try:
return json.loads(payload)
except json.JSONDecodeError:
continue
try:
return json.loads(text)
except json.JSONDecodeError:
return None
async def call_tool(
self, server_name: str, tool_name: str, arguments: dict,
) -> list[dict]:
"""Call a tool on a specific MCP server.
Args:
server_name: The MCP server name (e.g. "google-workspace")
tool_name: The bare tool name (without mcp__prefix)
arguments: Tool input arguments
Returns:
List of content blocks: [{"type": "text", "text": "..."}]
"""
conn = self._connections.get(server_name)
if not conn:
return [{"type": "text", "text": f"MCP server {server_name} not connected"}]
try:
if conn.session is not None:
# stdio or SSE — use MCP ClientSession
result = await conn.session.call_tool(tool_name, arguments)
return self._format_mcp_result(result)
elif hasattr(conn, "_http_client"):
# Streamable HTTP — use JSON-RPC
return await self._call_tool_http(conn, tool_name, arguments)
else:
return [{"type": "text", "text": f"No session for MCP server {server_name}"}]
except Exception as e:
logger.warning(f"MCP tool call failed: {server_name}/{tool_name}: {e}")
return [{"type": "text", "text": f"Error calling {tool_name}: {e}"}]
async def _call_tool_http(
self, conn: MCPConnection, tool_name: str, arguments: dict,
) -> list[dict]:
"""Call a tool via Streamable HTTP."""
client = conn._http_client # type: ignore[attr-defined]
url = conn._http_url # type: ignore[attr-defined]
headers = conn._http_headers # type: ignore[attr-defined]
req_id = conn._next_id # type: ignore[attr-defined]
conn._next_id = req_id + 1 # type: ignore[attr-defined]
resp = await client.post(url, headers=headers, json={
"jsonrpc": "2.0",
"id": req_id,
"method": "tools/call",
"params": {"name": tool_name, "arguments": arguments},
}, timeout=300.0)
ct = resp.headers.get("content-type", "")
if "text/event-stream" in ct:
data = self._parse_sse_json(resp.text)
else:
data = resp.json()
if not data:
return [{"type": "text", "text": "Empty response from MCP server"}]
if "error" in data:
return [{"type": "text", "text": f"MCP error: {data['error']}"}]
result = data.get("result", {})
content = result.get("content", [])
return content if content else [{"type": "text", "text": json.dumps(result)}]
@staticmethod
def _format_mcp_result(result: Any) -> list[dict]:
"""Convert an MCP CallToolResult to content blocks."""
if hasattr(result, "content"):
blocks = []
for item in result.content:
if hasattr(item, "text"):
blocks.append({"type": "text", "text": item.text})
elif hasattr(item, "data"):
blocks.append({
"type": "image",
"source": {
"type": "base64",
"media_type": getattr(item, "mimeType", "image/png"),
"data": item.data,
},
})
else:
blocks.append({"type": "text", "text": str(item)})
return blocks if blocks else [{"type": "text", "text": "Done."}]
return [{"type": "text", "text": str(result)}]
def get_all_tool_schemas(self) -> list[ToolSchema]:
"""Return tool schemas from all connected MCP servers."""
schemas = []
for conn in self._connections.values():
schemas.extend(conn.tools)
return schemas
def parse_mcp_tool_name(self, full_name: str) -> tuple[str, str] | None:
"""Parse mcp__<server>__<tool> into (server_name, tool_name).
Returns None if the name doesn't match the MCP naming convention.
"""
import re
m = re.match(r"mcp__([^_]+(?:-[^_]+)*)__(.+)", full_name)
if m:
return m.group(1), m.group(2)
return None
async def disconnect_all(self):
"""Disconnect all MCP servers. Called on session end."""
self._connections.clear()
# The AsyncExitStack handles actual cleanup of transports/sessions
+2
View File
@@ -5,6 +5,7 @@ from uuid import uuid4
class AgentConfig(BaseModel):
name: str = Field(default_factory=lambda: f"Agent-{uuid4().hex[:6]}")
provider: str = "anthropic"
model: str = "sonnet"
mode: str = "agent"
system_prompt: Optional[str] = None
@@ -55,6 +56,7 @@ class AgentSession(BaseModel):
id: str = Field(default_factory=lambda: uuid4().hex)
name: str
status: Literal["running", "waiting_approval", "completed", "error", "stopped"] = "running"
provider: str = "anthropic"
model: str = "sonnet"
mode: str = "agent"
sdk_session_id: Optional[str] = None
+260
View File
@@ -0,0 +1,260 @@
"""Anthropic provider adapter using the native Anthropic SDK."""
from __future__ import annotations
import json
import logging
from typing import Any, AsyncIterator
import anthropic
from backend.apps.agents.providers.base import (
BaseProvider, ContentBlock, ModelResponse, ProviderMessage,
StreamEvent, ToolCall, ToolSchema,
)
logger = logging.getLogger(__name__)
MODEL_MAP = {
"sonnet": "claude-sonnet-4-6",
"opus": "claude-opus-4-6",
"haiku": "claude-haiku-4-5",
}
class AnthropicProvider(BaseProvider):
"""Provider adapter for Anthropic's Messages API."""
def __init__(
self,
api_key: str | None = None,
auth_token: str | None = None,
base_url: str | None = None,
):
kwargs: dict[str, Any] = {}
if auth_token:
kwargs["auth_token"] = auth_token
elif api_key:
kwargs["api_key"] = api_key
if base_url:
kwargs["base_url"] = base_url
self.client = anthropic.AsyncAnthropic(**kwargs)
def get_model_id(self, short_name: str) -> str:
return MODEL_MAP.get(short_name, short_name)
def clean_tool_schema(self, schema: ToolSchema) -> dict:
return {
"name": schema.name,
"description": schema.description,
"input_schema": schema.input_schema,
}
def format_tool_result(self, tool_use_id: str, content: list[dict]) -> dict:
return {
"type": "tool_result",
"tool_use_id": tool_use_id,
"content": content,
}
def format_user_message(self, content: Any) -> ProviderMessage:
return ProviderMessage(role="user", content=content)
def format_assistant_message(self, response: ModelResponse) -> ProviderMessage:
blocks = []
for block in response.content:
if block.type == "text":
blocks.append({"type": "text", "text": block.text})
elif block.type == "tool_use" and block.tool_call:
blocks.append({
"type": "tool_use",
"id": block.tool_call.id,
"name": block.tool_call.name,
"input": block.tool_call.input,
})
return ProviderMessage(role="assistant", content=blocks)
def _build_messages(self, messages: list[ProviderMessage]) -> list[dict]:
"""Convert ProviderMessages to Anthropic API format."""
result = []
for msg in messages:
if msg.role == "tool_result":
# Tool results: content is a list of tool_result dicts
if isinstance(msg.content, list):
result.append({"role": "user", "content": msg.content})
else:
result.append({"role": "user", "content": [msg.content]})
elif msg.role == "assistant":
result.append({"role": "assistant", "content": msg.content})
elif msg.role == "user":
result.append({"role": "user", "content": msg.content})
return result
async def create_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> ModelResponse:
kwargs: dict[str, Any] = {
"model": self.get_model_id(model),
"max_tokens": max_tokens,
"messages": self._build_messages(messages),
}
if system:
kwargs["system"] = system
if tools:
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
resp = await self.client.messages.create(**kwargs)
content = []
for block in resp.content:
if block.type == "text":
content.append(ContentBlock(type="text", text=block.text))
elif block.type == "tool_use":
content.append(ContentBlock(
type="tool_use",
tool_call=ToolCall(
id=block.id,
name=block.name,
input=block.input,
),
))
return ModelResponse(
content=content,
stop_reason="tool_use" if resp.stop_reason == "tool_use" else "end_turn",
usage={
"input_tokens": resp.usage.input_tokens,
"output_tokens": resp.usage.output_tokens,
},
)
async def stream_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> AsyncIterator[StreamEvent]:
kwargs: dict[str, Any] = {
"model": self.get_model_id(model),
"max_tokens": max_tokens,
"messages": self._build_messages(messages),
}
if system:
kwargs["system"] = system
if tools:
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
# Use create() with stream=True for raw SSE events
kwargs["stream"] = True
raw_stream = await self.client.messages.create(**kwargs)
current_block_type: dict[int, str] = {}
current_tool_name: dict[int, str] = {}
current_tool_id: dict[int, str] = {}
current_text: dict[int, str] = {}
current_json: dict[int, str] = {}
async for event in raw_stream:
event_type = getattr(event, "type", "")
if event_type == "content_block_start":
index = event.index
block = event.content_block
block_type = block.type
current_block_type[index] = block_type
if block_type == "text":
current_text[index] = ""
yield StreamEvent(
type="content_block_start",
index=index,
block_type="text",
)
elif block_type == "tool_use":
current_tool_name[index] = block.name
current_tool_id[index] = block.id
current_json[index] = ""
yield StreamEvent(
type="content_block_start",
index=index,
block_type="tool_use",
tool_name=block.name,
tool_id=block.id,
)
elif event_type == "content_block_delta":
index = event.index
delta = event.delta
delta_type = delta.type
if delta_type == "text_delta":
current_text.setdefault(index, "")
current_text[index] += delta.text
yield StreamEvent(
type="content_block_delta",
index=index,
delta_type="text_delta",
text=delta.text,
)
elif delta_type == "input_json_delta":
current_json.setdefault(index, "")
current_json[index] += delta.partial_json
yield StreamEvent(
type="content_block_delta",
index=index,
delta_type="input_json_delta",
text=delta.partial_json,
)
elif event_type == "content_block_stop":
yield StreamEvent(type="content_block_stop", index=event.index)
elif event_type == "message_delta":
# Extract output token usage from the final delta
usage_data = {}
delta_usage = getattr(event, "usage", None)
if delta_usage:
output_tokens = getattr(delta_usage, "output_tokens", 0)
if output_tokens:
usage_data["output_tokens"] = output_tokens
if usage_data:
yield StreamEvent(type="usage", usage=usage_data)
elif event_type == "message_start":
# Extract input token usage from the message start
msg = getattr(event, "message", None)
if msg:
msg_usage = getattr(msg, "usage", None)
if msg_usage:
usage_data = {}
input_tokens = getattr(msg_usage, "input_tokens", 0)
output_tokens = getattr(msg_usage, "output_tokens", 0)
if input_tokens:
usage_data["input_tokens"] = input_tokens
if output_tokens:
usage_data["output_tokens"] = output_tokens
if usage_data:
yield StreamEvent(type="usage", usage=usage_data)
yield StreamEvent(type="message_stop")
async def stream_and_collect(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> tuple[AsyncIterator[StreamEvent], ModelResponse]:
"""Helper: stream events and also return the full collected response.
Not used directly — the AgentLoop handles collection.
"""
raise NotImplementedError("Use stream_message() directly; AgentLoop collects.")
+135
View File
@@ -0,0 +1,135 @@
"""Provider-agnostic base classes for multi-model support.
All provider adapters (Anthropic, OpenAI, Gemini, OpenAI-compatible)
implement BaseProvider, translating their native APIs into these
common data structures.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, AsyncIterator
@dataclass
class ToolSchema:
"""Provider-agnostic tool definition."""
name: str
description: str
input_schema: dict[str, Any]
@dataclass
class ToolCall:
"""A tool invocation requested by the model."""
id: str
name: str
input: dict[str, Any]
@dataclass
class ContentBlock:
"""A block of content from the model response."""
type: str # "text" | "tool_use"
text: str = ""
tool_call: ToolCall | None = None
@dataclass
class ModelResponse:
"""Complete (non-streaming) response from a provider."""
content: list[ContentBlock]
stop_reason: str # "end_turn" | "tool_use" | "max_tokens"
usage: dict[str, int] = field(default_factory=dict)
@dataclass
class StreamEvent:
"""A single streaming event, normalized across providers.
The event types match what the frontend already expects via WebSocket:
content_block_start, content_block_delta, content_block_stop, message_stop.
"""
type: str
index: int = 0
block_type: str = "" # "text" | "tool_use"
delta_type: str = "" # "text_delta" | "input_json_delta"
text: str = ""
tool_name: str = ""
tool_id: str = ""
usage: dict[str, int] = field(default_factory=dict)
@dataclass
class ProviderMessage:
"""Provider-agnostic message for conversation history.
Each provider adapter converts these to/from its native format.
"""
role: str # "user" | "assistant" | "tool_result"
content: Any # str, list[dict], or provider-specific content
class BaseProvider(ABC):
"""Abstract base for LLM provider adapters."""
@abstractmethod
async def stream_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> AsyncIterator[StreamEvent]:
"""Stream a model response, yielding normalized StreamEvents."""
...
@abstractmethod
async def create_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> ModelResponse:
"""Non-streaming message creation."""
...
@abstractmethod
def format_tool_result(
self,
tool_use_id: str,
content: list[dict],
) -> dict:
"""Format a tool result in this provider's expected message format."""
...
@abstractmethod
def format_user_message(self, content: Any) -> ProviderMessage:
"""Wrap user content (str or multimodal blocks) into a ProviderMessage."""
...
@abstractmethod
def format_assistant_message(self, response: ModelResponse) -> ProviderMessage:
"""Convert a ModelResponse into a ProviderMessage for conversation history."""
...
@abstractmethod
def get_model_id(self, short_name: str) -> str:
"""Resolve a short model name to the full API model ID."""
...
def clean_tool_schema(self, schema: ToolSchema) -> dict:
"""Convert a ToolSchema to the provider's native tool format.
Default: Anthropic-style format. Override for providers that need
different formats or schema cleaning (e.g. Gemini).
"""
return {
"name": schema.name,
"description": schema.description,
"input_schema": schema.input_schema,
}
+600
View File
@@ -0,0 +1,600 @@
"""Gemini provider adapter using the new google-genai SDK."""
from __future__ import annotations
import base64
import json
import logging
from copy import deepcopy
from typing import Any, AsyncIterator
from uuid import uuid4
from google import genai
from google.genai import types
from backend.apps.agents.providers.base import (
BaseProvider,
ContentBlock,
ModelResponse,
ProviderMessage,
StreamEvent,
ToolCall,
ToolSchema,
)
logger = logging.getLogger(__name__)
MODEL_MAP = {
"flash": "gemini-2.5-flash",
"pro": "gemini-2.5-pro",
}
# JSON Schema keywords that Gemini does not support
_UNSUPPORTED_VALIDATION_KEYS = frozenset({
"minLength",
"maxLength",
"minimum",
"maximum",
"exclusiveMinimum",
"exclusiveMaximum",
"pattern",
"minItems",
"maxItems",
"uniqueItems",
"minProperties",
"maxProperties",
"multipleOf",
"format",
"const",
})
_UNSUPPORTED_STRUCTURAL_KEYS = frozenset({
"$ref",
"$defs",
"patternProperties",
})
# ---------------------------------------------------------------------------
# Schema cleaning — port of OpenClaw's clean-for-gemini.ts logic
# ---------------------------------------------------------------------------
def _resolve_ref(ref: str, root_defs: dict[str, Any]) -> dict[str, Any]:
"""Attempt to resolve a $ref pointer like '#/$defs/Foo'."""
if ref.startswith("#/$defs/"):
name = ref[len("#/$defs/"):]
if name in root_defs:
return deepcopy(root_defs[name])
# Cannot resolve — return empty object
return {"type": "object"}
def _clean_schema_node(node: dict[str, Any], root_defs: dict[str, Any]) -> dict[str, Any]:
"""Recursively clean a single JSON Schema node for Gemini compatibility."""
if not isinstance(node, dict):
return node
# If this node is just a $ref, resolve it first then clean the result
if "$ref" in node and len(node) <= 2: # $ref possibly with description
resolved = _resolve_ref(node["$ref"], root_defs)
# Carry over description if the ref node had one
if "description" in node:
resolved["description"] = node["description"]
return _clean_schema_node(resolved, root_defs)
result: dict[str, Any] = {}
for key, value in node.items():
# Drop unsupported keys
if key in _UNSUPPORTED_VALIDATION_KEYS:
continue
if key in _UNSUPPORTED_STRUCTURAL_KEYS:
continue
# Handle additionalProperties: drop if boolean, recurse if schema
if key == "additionalProperties":
if isinstance(value, bool):
continue
# It's a schema dict — clean and keep it
result[key] = _clean_schema_node(value, root_defs)
continue
# Handle anyOf / oneOf: flatten into something Gemini can use
if key in ("anyOf", "oneOf"):
if isinstance(value, list):
flattened = _flatten_union(value, root_defs)
if flattened is not None:
result.update(flattened)
continue
# Handle allOf: merge all members
if key == "allOf":
if isinstance(value, list):
merged = _merge_all_of(value, root_defs)
result.update(merged)
continue
# Recurse into properties
if key == "properties" and isinstance(value, dict):
result[key] = {
prop_name: _clean_schema_node(prop_schema, root_defs)
for prop_name, prop_schema in value.items()
}
continue
# Recurse into items
if key == "items":
if isinstance(value, dict):
result[key] = _clean_schema_node(value, root_defs)
elif isinstance(value, list):
result[key] = [_clean_schema_node(item, root_defs) for item in value]
else:
result[key] = value
continue
# Keep everything else (type, description, title, default, enum, required, etc.)
result[key] = value
return result
def _flatten_union(
variants: list[dict[str, Any]],
root_defs: dict[str, Any],
) -> dict[str, Any] | None:
"""Collapse anyOf/oneOf into a Gemini-compatible schema.
Strategies (applied in order):
1. If all variants are literal types (with const or single-value enums),
collapse into a single enum.
2. If there's a null variant mixed with non-null variants, strip the null
variant and return the remaining schema (nullable).
3. If only one non-null variant remains after stripping, unwrap it.
4. Otherwise return the first variant (best-effort).
"""
if not variants:
return None
# Clean each variant first (resolve refs, etc.)
cleaned = [_clean_schema_node(v, root_defs) for v in variants]
# Strategy 1: all literal types → collapse to enum
enum_values: list[Any] = []
all_literals = True
for v in cleaned:
if "const" in v:
enum_values.append(v["const"])
elif "enum" in v and isinstance(v["enum"], list) and len(v["enum"]) == 1:
enum_values.append(v["enum"][0])
else:
all_literals = False
break
if all_literals and enum_values:
return {"type": "string", "enum": enum_values}
# Strategy 2 & 3: strip null variants
non_null = [v for v in cleaned if v.get("type") != "null"]
if len(non_null) == 0:
# All variants are null
return {"type": "string"}
if len(non_null) == 1:
# Single non-null variant — unwrap it, mark as nullable
result = non_null[0].copy()
result["nullable"] = True
return result
# Strategy 4: multiple non-null variants, just use first (best-effort)
return non_null[0]
def _merge_all_of(
members: list[dict[str, Any]],
root_defs: dict[str, Any],
) -> dict[str, Any]:
"""Merge allOf members into a single cleaned schema."""
merged: dict[str, Any] = {}
for member in members:
cleaned = _clean_schema_node(member, root_defs)
for key, value in cleaned.items():
if key == "properties" and key in merged:
merged[key].update(value)
elif key == "required" and key in merged:
existing = set(merged[key])
existing.update(value)
merged[key] = sorted(existing)
else:
merged[key] = value
return merged
def clean_schema_for_gemini(schema: dict[str, Any]) -> dict[str, Any]:
"""Top-level entry point: clean a full JSON Schema for Gemini compatibility."""
root_defs = schema.get("$defs", schema.get("definitions", {}))
return _clean_schema_node(schema, root_defs)
# ---------------------------------------------------------------------------
# Provider implementation
# ---------------------------------------------------------------------------
class GeminiProvider(BaseProvider):
"""Provider adapter for Google Gemini via the google-genai SDK."""
def __init__(self, api_key: str):
self.client = genai.Client(api_key=api_key)
def get_model_id(self, short_name: str) -> str:
return MODEL_MAP.get(short_name, short_name)
def clean_tool_schema(self, schema: ToolSchema) -> dict:
"""Convert a ToolSchema to Gemini function declaration format.
Cleans the JSON Schema of unsupported features before sending.
"""
cleaned_params = clean_schema_for_gemini(schema.input_schema)
return {
"name": schema.name,
"description": schema.description,
"parameters": cleaned_params,
}
def format_tool_result(self, tool_use_id: str, content: list[dict]) -> dict:
"""Format a tool result for Gemini conversation history.
Gemini uses FunctionResponse parts; we store enough info to reconstruct them.
"""
# Extract text content for the function response
text_parts = []
for block in content:
if block.get("type") == "text":
text_parts.append(block.get("text", ""))
elif block.get("type") == "image":
text_parts.append("[image]")
else:
text_parts.append(json.dumps(block))
return {
"tool_use_id": tool_use_id,
"output": "\n".join(text_parts) if text_parts else "Done.",
}
def format_user_message(self, content: Any) -> ProviderMessage:
if isinstance(content, str):
return ProviderMessage(role="user", content=content)
return ProviderMessage(role="user", content=content)
def format_assistant_message(self, response: ModelResponse) -> ProviderMessage:
"""Convert a ModelResponse into a ProviderMessage for conversation history."""
blocks = []
for block in response.content:
if block.type == "text":
blocks.append({"type": "text", "text": block.text})
elif block.type == "tool_use" and block.tool_call:
blocks.append({
"type": "tool_use",
"id": block.tool_call.id,
"name": block.tool_call.name,
"input": block.tool_call.input,
})
return ProviderMessage(role="assistant", content=blocks)
# ------------------------------------------------------------------
# Internal helpers for building Gemini API messages
# ------------------------------------------------------------------
def _build_tools(self, tools: list[ToolSchema]) -> list[types.Tool] | None:
"""Convert ToolSchemas to Gemini Tool objects."""
if not tools:
return None
declarations = []
for t in tools:
cleaned = self.clean_tool_schema(t)
declarations.append(types.FunctionDeclaration(
name=cleaned["name"],
description=cleaned["description"],
parameters=cleaned["parameters"],
))
return [types.Tool(function_declarations=declarations)]
def _build_contents(
self, messages: list[ProviderMessage],
) -> list[types.Content]:
"""Convert ProviderMessages into a list of Gemini Content objects.
Gemini requires:
- Conversation starts with a user message
- Strict alternating user/model turns
We merge consecutive same-role messages to satisfy this.
"""
raw_contents: list[types.Content] = []
for msg in messages:
if msg.role == "user":
parts = self._user_content_to_parts(msg.content)
raw_contents.append(types.Content(role="user", parts=parts))
elif msg.role == "assistant":
parts = self._assistant_content_to_parts(msg.content)
raw_contents.append(types.Content(role="model", parts=parts))
elif msg.role == "tool_result":
# Tool results become user turns with FunctionResponse parts
parts = self._tool_result_to_parts(msg.content)
raw_contents.append(types.Content(role="user", parts=parts))
# Ensure conversation starts with user
if raw_contents and raw_contents[0].role != "user":
raw_contents.insert(
0,
types.Content(
role="user",
parts=[types.Part.from_text("Hello.")],
),
)
# Merge consecutive same-role turns
merged: list[types.Content] = []
for content in raw_contents:
if merged and merged[-1].role == content.role:
merged[-1].parts.extend(content.parts)
else:
merged.append(content)
return merged
def _user_content_to_parts(self, content: Any) -> list[types.Part]:
"""Convert user message content to Gemini Part objects."""
if isinstance(content, str):
return [types.Part.from_text(content)]
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, str):
parts.append(types.Part.from_text(block))
elif isinstance(block, dict):
if block.get("type") == "text":
parts.append(types.Part.from_text(block.get("text", "")))
elif block.get("type") == "image":
source = block.get("source", {})
media_type = source.get("media_type", "image/png")
data = source.get("data", "")
parts.append(types.Part.from_bytes(
data=base64.b64decode(data),
mime_type=media_type,
))
else:
parts.append(types.Part.from_text(json.dumps(block)))
return parts if parts else [types.Part.from_text("")]
return [types.Part.from_text(str(content))]
def _assistant_content_to_parts(self, content: Any) -> list[types.Part]:
"""Convert assistant message content to Gemini Part objects."""
if isinstance(content, str):
return [types.Part.from_text(content)]
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict):
if block.get("type") == "text":
text = block.get("text", "")
if text:
parts.append(types.Part.from_text(text))
elif block.get("type") == "tool_use":
parts.append(types.Part(
function_call=types.FunctionCall(
name=block.get("name", ""),
args=block.get("input", {}),
)
))
return parts if parts else [types.Part.from_text("")]
return [types.Part.from_text(str(content))]
def _tool_result_to_parts(self, content: Any) -> list[types.Part]:
"""Convert tool result content to Gemini FunctionResponse parts."""
parts = []
if isinstance(content, list):
for item in content:
if isinstance(item, dict) and "tool_use_id" in item:
# The tool_use_id is the function name in our format_tool_result
func_name = item.get("tool_use_id", "unknown")
output = item.get("output", "Done.")
parts.append(types.Part(
function_response=types.FunctionResponse(
name=func_name,
response={"result": output},
)
))
elif isinstance(content, dict) and "tool_use_id" in content:
func_name = content.get("tool_use_id", "unknown")
output = content.get("output", "Done.")
parts.append(types.Part(
function_response=types.FunctionResponse(
name=func_name,
response={"result": output},
)
))
return parts if parts else [types.Part.from_text("Tool result unavailable.")]
# ------------------------------------------------------------------
# Non-streaming message creation
# ------------------------------------------------------------------
async def create_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> ModelResponse:
contents = self._build_contents(messages)
gemini_tools = self._build_tools(tools)
config = types.GenerateContentConfig(
max_output_tokens=max_tokens,
)
if system:
config.system_instruction = system
if gemini_tools:
config.tools = gemini_tools
resp = await self.client.aio.models.generate_content(
model=self.get_model_id(model),
contents=contents,
config=config,
)
return self._parse_response(resp)
def _parse_response(self, resp: Any) -> ModelResponse:
"""Parse a Gemini GenerateContentResponse into a ModelResponse."""
content: list[ContentBlock] = []
has_tool_calls = False
if resp.candidates:
candidate = resp.candidates[0]
if candidate.content and candidate.content.parts:
for part in candidate.content.parts:
if part.text is not None:
content.append(ContentBlock(type="text", text=part.text))
elif part.function_call is not None:
has_tool_calls = True
fc = part.function_call
content.append(ContentBlock(
type="tool_use",
tool_call=ToolCall(
id=uuid4().hex,
name=fc.name,
input=dict(fc.args) if fc.args else {},
),
))
stop_reason = "tool_use" if has_tool_calls else "end_turn"
usage = {}
if resp.usage_metadata:
usage = {
"input_tokens": getattr(resp.usage_metadata, "prompt_token_count", 0) or 0,
"output_tokens": getattr(resp.usage_metadata, "candidates_token_count", 0) or 0,
}
return ModelResponse(content=content, stop_reason=stop_reason, usage=usage)
# ------------------------------------------------------------------
# Streaming message creation
# ------------------------------------------------------------------
async def stream_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> AsyncIterator[StreamEvent]:
contents = self._build_contents(messages)
gemini_tools = self._build_tools(tools)
config = types.GenerateContentConfig(
max_output_tokens=max_tokens,
)
if system:
config.system_instruction = system
if gemini_tools:
config.tools = gemini_tools
stream = self.client.aio.models.generate_content_stream(
model=self.get_model_id(model),
contents=contents,
config=config,
)
# Track streaming state
block_index = 0
text_block_open = False
tool_blocks: dict[str, int] = {} # tool_name -> block_index (for dedup)
async for chunk in stream:
if not chunk.candidates:
continue
candidate = chunk.candidates[0]
if not candidate.content or not candidate.content.parts:
continue
for part in candidate.content.parts:
if part.text is not None:
text = part.text
if not text_block_open:
text_block_open = True
yield StreamEvent(
type="content_block_start",
index=block_index,
block_type="text",
)
yield StreamEvent(
type="content_block_delta",
index=block_index,
delta_type="text_delta",
text=text,
)
elif part.function_call is not None:
fc = part.function_call
# Close text block if open
if text_block_open:
yield StreamEvent(
type="content_block_stop",
index=block_index,
)
block_index += 1
text_block_open = False
tool_id = uuid4().hex
tool_block_idx = block_index
block_index += 1
args = dict(fc.args) if fc.args else {}
args_json = json.dumps(args)
yield StreamEvent(
type="content_block_start",
index=tool_block_idx,
block_type="tool_use",
tool_name=fc.name,
tool_id=tool_id,
)
yield StreamEvent(
type="content_block_delta",
index=tool_block_idx,
delta_type="input_json_delta",
text=args_json,
)
yield StreamEvent(
type="content_block_stop",
index=tool_block_idx,
)
# Close any remaining open text block
if text_block_open:
yield StreamEvent(
type="content_block_stop",
index=block_index,
)
# Emit usage from the last chunk if available
if chunk and hasattr(chunk, 'usage_metadata') and chunk.usage_metadata:
um = chunk.usage_metadata
usage_data = {
"input_tokens": getattr(um, "prompt_token_count", 0) or 0,
"output_tokens": getattr(um, "candidates_token_count", 0) or 0,
}
if any(v > 0 for v in usage_data.values()):
yield StreamEvent(type="usage", usage=usage_data)
yield StreamEvent(type="message_stop")
@@ -0,0 +1,330 @@
"""OpenAI-compatible provider adapter.
Works with ANY endpoint that speaks the OpenAI Chat Completions API:
OpenAI, OpenRouter, Together, Groq, Fireworks, Mistral, Ollama, vLLM, etc.
"""
from __future__ import annotations
import json
import logging
from typing import Any, AsyncIterator
from uuid import uuid4
from openai import AsyncOpenAI
from backend.apps.agents.providers.base import (
BaseProvider, ContentBlock, ModelResponse, ProviderMessage,
StreamEvent, ToolCall, ToolSchema,
)
logger = logging.getLogger(__name__)
class OpenAICompatProvider(BaseProvider):
"""Provider adapter for any OpenAI-compatible API endpoint."""
def __init__(
self,
api_key: str = "",
base_url: str | None = None,
):
kwargs: dict[str, Any] = {}
if api_key:
kwargs["api_key"] = api_key
if base_url:
kwargs["base_url"] = base_url
self.client = AsyncOpenAI(**kwargs)
def get_model_id(self, short_name: str) -> str:
# Pass through — user selects exact model ID
return short_name
def clean_tool_schema(self, schema: ToolSchema) -> dict:
"""Convert to OpenAI function calling format."""
return {
"type": "function",
"function": {
"name": schema.name,
"description": schema.description,
"parameters": schema.input_schema,
},
}
def format_tool_result(self, tool_use_id: str, content: list[dict]) -> dict:
"""Format tool result as OpenAI expects."""
# OpenAI wants a single string for tool results
text_parts = []
for block in content:
if block.get("type") == "text":
text_parts.append(block.get("text", ""))
elif block.get("type") == "image":
text_parts.append("[image]")
else:
text_parts.append(json.dumps(block))
return {
"role": "tool",
"tool_call_id": tool_use_id,
"content": "\n".join(text_parts) if text_parts else "Done.",
}
def format_user_message(self, content: Any) -> ProviderMessage:
"""Convert user content to OpenAI format."""
if isinstance(content, str):
return ProviderMessage(role="user", content=content)
# Multimodal content (text + images)
if isinstance(content, list):
parts = []
for block in content:
if isinstance(block, dict):
if block.get("type") == "text":
parts.append({"type": "text", "text": block["text"]})
elif block.get("type") == "image":
source = block.get("source", {})
media_type = source.get("media_type", "image/png")
data = source.get("data", "")
parts.append({
"type": "image_url",
"image_url": {"url": f"data:{media_type};base64,{data}"},
})
elif isinstance(block, str):
parts.append({"type": "text", "text": block})
return ProviderMessage(role="user", content=parts)
return ProviderMessage(role="user", content=str(content))
def format_assistant_message(self, response: ModelResponse) -> ProviderMessage:
"""Convert ModelResponse to OpenAI assistant message format."""
text_parts = []
tool_calls = []
for block in response.content:
if block.type == "text":
text_parts.append(block.text)
elif block.type == "tool_use" and block.tool_call:
tool_calls.append({
"id": block.tool_call.id,
"type": "function",
"function": {
"name": block.tool_call.name,
"arguments": json.dumps(block.tool_call.input),
},
})
msg: dict[str, Any] = {"role": "assistant"}
if text_parts:
msg["content"] = "\n".join(text_parts)
else:
msg["content"] = None
if tool_calls:
msg["tool_calls"] = tool_calls
return ProviderMessage(role="assistant", content=msg)
def _build_messages(
self,
system: str | None,
messages: list[ProviderMessage],
) -> list[dict]:
"""Convert ProviderMessages to OpenAI API format."""
result = []
if system:
result.append({"role": "system", "content": system})
for msg in messages:
if msg.role == "assistant":
# Assistant messages are already in OpenAI format from format_assistant_message
if isinstance(msg.content, dict) and "role" in msg.content:
result.append(msg.content)
else:
# Raw content blocks from provider-agnostic format
text_parts = []
tool_calls = []
if isinstance(msg.content, list):
for block in msg.content:
if isinstance(block, dict):
if block.get("type") == "text":
text_parts.append(block["text"])
elif block.get("type") == "tool_use":
tool_calls.append({
"id": block.get("id", uuid4().hex),
"type": "function",
"function": {
"name": block.get("name", ""),
"arguments": json.dumps(block.get("input", {})),
},
})
api_msg: dict[str, Any] = {
"role": "assistant",
"content": "\n".join(text_parts) if text_parts else None,
}
if tool_calls:
api_msg["tool_calls"] = tool_calls
result.append(api_msg)
elif msg.role == "tool_result":
# Tool results: content is a list of tool result dicts
if isinstance(msg.content, list):
for tr in msg.content:
if isinstance(tr, dict) and "tool_call_id" in tr:
result.append(tr)
elif isinstance(msg.content, dict) and "tool_call_id" in msg.content:
result.append(msg.content)
elif msg.role == "user":
result.append({"role": "user", "content": msg.content})
return result
async def create_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> ModelResponse:
kwargs: dict[str, Any] = {
"model": self.get_model_id(model),
"max_tokens": max_tokens,
"messages": self._build_messages(system, messages),
}
if tools:
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
resp = await self.client.chat.completions.create(**kwargs)
choice = resp.choices[0]
message = choice.message
content: list[ContentBlock] = []
if message.content:
content.append(ContentBlock(type="text", text=message.content))
if message.tool_calls:
for tc in message.tool_calls:
try:
args = json.loads(tc.function.arguments)
except json.JSONDecodeError:
args = {}
content.append(ContentBlock(
type="tool_use",
tool_call=ToolCall(
id=tc.id,
name=tc.function.name,
input=args,
),
))
stop = "end_turn"
if choice.finish_reason == "tool_calls":
stop = "tool_use"
elif message.tool_calls:
stop = "tool_use"
usage_dict = {}
if resp.usage:
usage_dict = {
"input_tokens": resp.usage.prompt_tokens,
"output_tokens": resp.usage.completion_tokens,
}
return ModelResponse(content=content, stop_reason=stop, usage=usage_dict)
async def stream_message(
self,
model: str,
system: str | None,
messages: list[ProviderMessage],
tools: list[ToolSchema],
max_tokens: int = 8192,
) -> AsyncIterator[StreamEvent]:
kwargs: dict[str, Any] = {
"model": self.get_model_id(model),
"max_tokens": max_tokens,
"messages": self._build_messages(system, messages),
"stream": True,
"stream_options": {"include_usage": True},
}
if tools:
kwargs["tools"] = [self.clean_tool_schema(t) for t in tools]
stream = await self.client.chat.completions.create(**kwargs)
# Track streaming state to emit normalized events
text_started = False
text_index = 0
tool_indices: dict[int, dict] = {} # openai tool_call index -> {name, id, json_buf}
next_block_index = 0
async for chunk in stream:
if not chunk.choices:
# Usage-only chunk at the end
if chunk.usage:
yield StreamEvent(type="usage", usage={
"input_tokens": chunk.usage.prompt_tokens or 0,
"output_tokens": chunk.usage.completion_tokens or 0,
})
continue
delta = chunk.choices[0].delta
finish_reason = chunk.choices[0].finish_reason
# Text content
if delta.content is not None:
if not text_started:
text_started = True
text_index = next_block_index
next_block_index += 1
yield StreamEvent(
type="content_block_start",
index=text_index,
block_type="text",
)
yield StreamEvent(
type="content_block_delta",
index=text_index,
delta_type="text_delta",
text=delta.content,
)
# Tool calls
if delta.tool_calls:
for tc_delta in delta.tool_calls:
tc_idx = tc_delta.index
if tc_idx not in tool_indices:
# New tool call starting
if text_started:
yield StreamEvent(type="content_block_stop", index=text_index)
text_started = False
block_idx = next_block_index
next_block_index += 1
tool_indices[tc_idx] = {
"block_index": block_idx,
"id": tc_delta.id or uuid4().hex,
"name": tc_delta.function.name if tc_delta.function else "",
"json_buf": "",
}
yield StreamEvent(
type="content_block_start",
index=block_idx,
block_type="tool_use",
tool_name=tool_indices[tc_idx]["name"],
tool_id=tool_indices[tc_idx]["id"],
)
info = tool_indices[tc_idx]
if tc_delta.function and tc_delta.function.name:
info["name"] = tc_delta.function.name
if tc_delta.function and tc_delta.function.arguments:
info["json_buf"] += tc_delta.function.arguments
yield StreamEvent(
type="content_block_delta",
index=info["block_index"],
delta_type="input_json_delta",
text=tc_delta.function.arguments,
)
# Finish
if finish_reason is not None:
if text_started:
yield StreamEvent(type="content_block_stop", index=text_index)
for info in tool_indices.values():
yield StreamEvent(type="content_block_stop", index=info["block_index"])
yield StreamEvent(type="message_stop")
+286
View File
@@ -0,0 +1,286 @@
"""Provider factory and model registry.
Two-tier system:
1. Built-in providers (Anthropic, OpenAI, Gemini) with curated model lists
2. User-configured custom providers (any OpenAI-compatible endpoint)
- Includes built-in OpenRouter integration for 300+ models
"""
from __future__ import annotations
import logging
from typing import Any, TYPE_CHECKING
from backend.apps.agents.providers.base import BaseProvider
if TYPE_CHECKING:
from backend.apps.settings.models import AppSettings
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Tier 1: Built-in models (curated, we know their quirks)
# ---------------------------------------------------------------------------
BUILTIN_MODELS: dict[str, list[dict[str, Any]]] = {
# ── Native API providers (use direct SDK) ──
"Anthropic": [
{"value": "sonnet", "label": "Claude Sonnet 4.6", "context_window": 1_000_000, "model_id": "claude-sonnet-4-6", "api": "anthropic"},
{"value": "opus", "label": "Claude Opus 4.6", "context_window": 1_000_000, "model_id": "claude-opus-4-6", "api": "anthropic"},
{"value": "haiku", "label": "Claude Haiku 4.5", "context_window": 200_000, "model_id": "claude-haiku-4-5", "api": "anthropic"},
],
"OpenAI": [
{"value": "gpt-5.4", "label": "GPT-5.4", "context_window": 1_000_000, "api": "openai"},
{"value": "gpt-5.4-mini", "label": "GPT-5.4 Mini", "context_window": 400_000, "api": "openai"},
{"value": "o3", "label": "o3", "context_window": 200_000, "api": "openai"},
{"value": "o4-mini", "label": "o4-mini", "context_window": 200_000, "api": "openai"},
],
"Google": [
{"value": "gemini-2.5-pro", "label": "Gemini 2.5 Pro", "context_window": 1_048_576, "api": "gemini"},
{"value": "gemini-2.5-flash", "label": "Gemini 2.5 Flash", "context_window": 1_048_576, "api": "gemini"},
],
# ── Via OpenRouter (need OpenRouter API key) ──
"xAI": [
{"value": "x-ai/grok-4-0214", "label": "Grok 4", "context_window": 2_000_000, "api": "openrouter"},
],
"Meta": [
{"value": "meta-llama/llama-4-maverick", "label": "Llama 4 Maverick", "context_window": 1_000_000, "api": "openrouter"},
{"value": "meta-llama/llama-4-scout", "label": "Llama 4 Scout", "context_window": 10_000_000, "api": "openrouter"},
],
"DeepSeek": [
{"value": "deepseek/deepseek-chat-v3-0324", "label": "DeepSeek V3", "context_window": 163_840, "api": "openrouter"},
{"value": "deepseek/deepseek-r1", "label": "DeepSeek R1", "context_window": 163_840, "api": "openrouter"},
],
"Mistral": [
{"value": "mistralai/mistral-large-2501", "label": "Mistral Large", "context_window": 256_000, "api": "openrouter"},
{"value": "mistralai/mistral-small-3.1-24b-instruct", "label": "Mistral Small 3.1", "context_window": 128_000, "api": "openrouter"},
],
"Qwen": [
{"value": "qwen/qwen3-coder", "label": "Qwen3 Coder 480B", "context_window": 262_144, "api": "openrouter"},
{"value": "qwen/qwen3-235b-a22b", "label": "Qwen3 235B", "context_window": 131_072, "api": "openrouter"},
],
"Cohere": [
{"value": "cohere/command-a-03-2025", "label": "Command A", "context_window": 256_000, "api": "openrouter"},
],
}
# ---------------------------------------------------------------------------
# OpenRouter: built-in integration for 300+ models
# ---------------------------------------------------------------------------
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
# ---------------------------------------------------------------------------
# Provider factory
# ---------------------------------------------------------------------------
def create_provider(
provider_name: str,
settings: AppSettings,
provider_config: dict | None = None,
) -> BaseProvider:
"""Create a provider adapter.
Routes based on the 'api' field in BUILTIN_MODELS:
- "anthropic" → native Anthropic SDK
- "openai" → native OpenAI SDK (direct API)
- "gemini" → native Google GenAI SDK
- "openrouter" → OpenAI-compat via openrouter.ai (Meta, Mistral, DeepSeek, Qwen, xAI, etc.)
Custom providers use OpenAI-compat with user's base_url.
"""
api_type = _get_api_type(provider_name)
if api_type == "anthropic":
from backend.apps.agents.providers.anthropic import AnthropicProvider
if getattr(settings, "connection_mode", "own_key") == "managed":
return AnthropicProvider(
auth_token=getattr(settings, "openswarm_auth_token", None),
base_url=getattr(settings, "openswarm_proxy_url", None) or "https://api.openswarm.ai",
)
return AnthropicProvider(api_key=settings.anthropic_api_key)
if api_type == "openai":
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(
api_key=settings.openai_api_key or "",
base_url="https://api.openai.com/v1",
)
if api_type == "gemini":
from backend.apps.agents.providers.gemini import GeminiProvider
return GeminiProvider(api_key=settings.google_api_key or "")
if api_type == "openrouter":
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(
api_key=getattr(settings, "openrouter_api_key", "") or "",
base_url=OPENROUTER_BASE_URL,
)
# Custom provider — look up in settings.custom_providers
if provider_config:
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(
api_key=provider_config.get("api_key", ""),
base_url=provider_config.get("base_url", ""),
)
for cp in getattr(settings, "custom_providers", []):
if cp.name == provider_name:
from backend.apps.agents.providers.openai_compat import OpenAICompatProvider
return OpenAICompatProvider(
api_key=cp.api_key,
base_url=cp.base_url,
)
raise ValueError(f"Unknown provider: {provider_name}")
def _get_api_type(provider_name: str) -> str:
"""Get the API type for a provider from BUILTIN_MODELS.
Accepts both display names ('Anthropic') and lowercase API names ('anthropic').
"""
# Direct lookup first (display name like 'Anthropic', 'OpenAI', etc.)
models = BUILTIN_MODELS.get(provider_name, [])
if models:
return models[0].get("api", "openrouter")
# Lowercase API name mapping
_API_NAME_MAP = {
"anthropic": "anthropic",
"openai": "openai",
"gemini": "gemini",
"google": "gemini",
"openrouter": "openrouter",
}
if provider_name.lower() in _API_NAME_MAP:
return _API_NAME_MAP[provider_name.lower()]
# Case-insensitive lookup into BUILTIN_MODELS
lower = provider_name.lower()
for key, models in BUILTIN_MODELS.items():
if key.lower() == lower:
return models[0].get("api", "openrouter")
return "openrouter"
def _has_credentials(provider_name: str, settings: AppSettings) -> bool:
"""Check if a provider has credentials configured."""
api_type = _get_api_type(provider_name)
if api_type == "anthropic":
if getattr(settings, "connection_mode", "own_key") == "managed":
return bool(getattr(settings, "openswarm_auth_token", None))
return bool(settings.anthropic_api_key)
if api_type == "openai":
return bool(settings.openai_api_key)
if api_type == "gemini":
return bool(getattr(settings, "google_api_key", None))
if api_type == "openrouter":
return bool(getattr(settings, "openrouter_api_key", None))
return False
def get_available_models(settings: AppSettings) -> dict[str, list[dict]]:
"""Return all models — always show everything, mark which have keys configured.
Like Cursor: show all models upfront, prompt for key when user tries to use one.
Returns: {"provider_name": [{"value": ..., "label": ..., "context_window": ..., "configured": bool}, ...]}
"""
result: dict[str, list[dict]] = {}
# Built-in providers — always show all
for provider_name, models in BUILTIN_MODELS.items():
configured = _has_credentials(provider_name, settings)
result[provider_name] = [
{**m, "configured": configured}
for m in models
]
# Custom providers
for cp in getattr(settings, "custom_providers", []):
if cp.models:
result[cp.name] = [
{
"value": m.get("value", m.get("id", "")),
"label": m.get("label", m.get("value", m.get("id", ""))),
"context_window": m.get("context_window", 128_000),
"configured": True,
}
for m in cp.models
]
return result
def get_context_window(provider: str, model: str, settings: AppSettings | None = None) -> int:
"""Look up context window for any model."""
# Check built-in models first
for models in BUILTIN_MODELS.values():
for m in models:
if m["value"] == model:
return m.get("context_window", 128_000)
# Check custom providers
if settings:
for cp in getattr(settings, "custom_providers", []):
for m in cp.models:
if m.get("value") == model or m.get("id") == model:
return m.get("context_window", 128_000)
return 128_000 # safe default
# ---------------------------------------------------------------------------
# Cost tracking
# ---------------------------------------------------------------------------
COST_PER_1M_TOKENS: dict[tuple[str, str], tuple[float, float]] = {
# (provider, model): (input_cost_per_1M, output_cost_per_1M)
# Anthropic
("Anthropic", "sonnet"): (3.0, 15.0),
("Anthropic", "opus"): (5.0, 25.0),
("Anthropic", "haiku"): (1.0, 5.0),
# OpenAI
("OpenAI", "gpt-5.4"): (2.50, 15.0),
("OpenAI", "gpt-5.4-mini"): (0.75, 3.0),
("OpenAI", "o3"): (2.0, 8.0),
("OpenAI", "o4-mini"): (1.10, 4.40),
# Google
("Google", "gemini-2.5-flash"): (0.15, 0.60),
("Google", "gemini-2.5-pro"): (1.25, 10.0),
# OpenRouter-backed (approximate)
("xAI", "x-ai/grok-4-0214"): (3.0, 15.0),
("Meta", "meta-llama/llama-4-maverick"): (0.50, 0.70),
("Meta", "meta-llama/llama-4-scout"): (0.15, 0.40),
("DeepSeek", "deepseek/deepseek-chat-v3-0324"): (0.30, 0.90),
("DeepSeek", "deepseek/deepseek-r1"): (0.80, 2.40),
("Mistral", "mistralai/mistral-large-2501"): (2.0, 6.0),
("Mistral", "mistralai/mistral-small-3.1-24b-instruct"): (0.10, 0.30),
("Qwen", "qwen/qwen3-coder"): (0.0, 0.0),
("Qwen", "qwen/qwen3-235b-a22b"): (0.20, 0.70),
("Cohere", "cohere/command-a-03-2025"): (2.50, 10.0),
}
def calculate_cost(
provider: str, model: str,
input_tokens: int, output_tokens: int,
) -> float:
"""Calculate cost in USD from token counts."""
# Direct lookup first
rates = COST_PER_1M_TOKENS.get((provider, model))
if not rates:
# Case-insensitive provider lookup
lower = provider.lower()
for (p, m), r in COST_PER_1M_TOKENS.items():
if p.lower() == lower and m == model:
rates = r
break
if not rates:
return 0.0
input_rate, output_rate = rates
return (input_tokens * input_rate + output_tokens * output_rate) / 1_000_000
+47
View File
@@ -0,0 +1,47 @@
"""Base classes for builtin tool implementations."""
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any
@dataclass
class ToolContext:
"""Runtime context passed to every tool execution."""
cwd: str
session_id: str
class BaseTool(ABC):
"""Abstract base for all builtin tools.
Subclasses must set ``name`` and ``description`` as class attributes and
implement ``get_schema`` (JSON Schema for tool input) and ``execute``.
"""
name: str
description: str
@abstractmethod
def get_schema(self) -> dict:
"""Return JSON Schema for this tool's input parameters."""
...
@abstractmethod
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
"""Execute the tool.
Returns a list of content blocks, e.g.
``[{"type": "text", "text": "..."}]``.
"""
...
def to_tool_schema(self):
"""Convert to the provider-agnostic ``ToolSchema`` used everywhere."""
from backend.apps.agents.providers.base import ToolSchema
return ToolSchema(
name=self.name,
description=self.description,
input_schema=self.get_schema(),
)
+476
View File
@@ -0,0 +1,476 @@
"""Filesystem tools: Read, Write, Edit, Glob, Grep."""
from __future__ import annotations
import asyncio
import base64
import mimetypes
import os
import re
from pathlib import Path
from typing import Any
from backend.apps.agents.tools.base import BaseTool, ToolContext
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"}
_MAX_OUTPUT_BYTES = 50 * 1024 # ~50 KB cap for grep output
def _resolve(file_path: str, cwd: str) -> Path:
"""Resolve *file_path* against *cwd* when it is relative."""
p = Path(file_path)
if not p.is_absolute():
p = Path(cwd) / p
return p.resolve()
def _text_block(text: str) -> list[dict]:
return [{"type": "text", "text": text}]
# ───────────────────────────────────────────────────────────────────────────
# ReadTool
# ───────────────────────────────────────────────────────────────────────────
class ReadTool(BaseTool):
name = "Read"
description = (
"Read a file from the filesystem. Returns lines with line numbers "
"(cat -n style). For image files returns base64 content. Supports "
"offset and limit parameters for reading portions of large files."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute or relative path to the file to read.",
},
"offset": {
"type": "integer",
"description": "1-based line number to start reading from.",
},
"limit": {
"type": "integer",
"description": "Maximum number of lines to return (default 2000).",
},
},
"required": ["file_path"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
file_path = _resolve(input_data["file_path"], context.cwd)
if not file_path.exists():
return _text_block(f"Error: file not found: {file_path}")
if not file_path.is_file():
return _text_block(f"Error: not a regular file: {file_path}")
# Binary / image files → base64
ext = file_path.suffix.lower()
if ext in _IMAGE_EXTENSIONS:
try:
raw = file_path.read_bytes()
b64 = base64.b64encode(raw).decode("ascii")
media = mimetypes.guess_type(str(file_path))[0] or "application/octet-stream"
return [
{
"type": "image",
"source": {
"type": "base64",
"media_type": media,
"data": b64,
},
}
]
except Exception as exc:
return _text_block(f"Error reading image {file_path}: {exc}")
# Text files
offset = max(input_data.get("offset", 1), 1)
limit = input_data.get("limit", 2000)
if limit <= 0:
limit = 2000
try:
with open(file_path, "r", errors="replace") as fh:
lines: list[str] = []
for lineno, line in enumerate(fh, start=1):
if lineno < offset:
continue
if len(lines) >= limit:
break
# cat -n style: right-justified line number + tab + content
lines.append(f"{lineno:>6}\t{line.rstrip()}")
if not lines:
return _text_block(f"(file is empty or offset beyond end of file: {file_path})")
return _text_block("\n".join(lines))
except Exception as exc:
return _text_block(f"Error reading {file_path}: {exc}")
# ───────────────────────────────────────────────────────────────────────────
# WriteTool
# ───────────────────────────────────────────────────────────────────────────
class WriteTool(BaseTool):
name = "Write"
description = (
"Write content to a file. Creates parent directories if they do not "
"exist. Overwrites the file if it already exists."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute or relative path to the file to write.",
},
"content": {
"type": "string",
"description": "The full content to write to the file.",
},
},
"required": ["file_path", "content"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
file_path = _resolve(input_data["file_path"], context.cwd)
content: str = input_data["content"]
try:
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content, encoding="utf-8")
return _text_block(f"Successfully wrote {len(content)} bytes to {file_path}")
except Exception as exc:
return _text_block(f"Error writing {file_path}: {exc}")
# ───────────────────────────────────────────────────────────────────────────
# EditTool
# ───────────────────────────────────────────────────────────────────────────
class EditTool(BaseTool):
name = "Edit"
description = (
"Perform exact string replacements in a file. By default the "
"old_string must appear exactly once (not unique → error). Pass "
"replace_all=true to replace every occurrence."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute or relative path to the file to edit.",
},
"old_string": {
"type": "string",
"description": "The exact text to find in the file.",
},
"new_string": {
"type": "string",
"description": "The text to replace old_string with.",
},
"replace_all": {
"type": "boolean",
"description": "If true, replace all occurrences. Default false.",
"default": False,
},
},
"required": ["file_path", "old_string", "new_string"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
file_path = _resolve(input_data["file_path"], context.cwd)
old_string: str = input_data["old_string"]
new_string: str = input_data["new_string"]
replace_all: bool = input_data.get("replace_all", False)
if not file_path.exists():
return _text_block(f"Error: file not found: {file_path}")
if not file_path.is_file():
return _text_block(f"Error: not a regular file: {file_path}")
try:
content = file_path.read_text(encoding="utf-8")
except Exception as exc:
return _text_block(f"Error reading {file_path}: {exc}")
count = content.count(old_string)
if count == 0:
return _text_block(
f"Error: old_string not found in {file_path}. "
"Make sure the string matches exactly, including whitespace and indentation."
)
if not replace_all and count > 1:
return _text_block(
f"Error: old_string appears {count} times in {file_path}. "
"Provide more surrounding context to make the match unique, "
"or set replace_all=true to replace every occurrence."
)
if replace_all:
new_content = content.replace(old_string, new_string)
else:
# Replace only the first (and only) occurrence
new_content = content.replace(old_string, new_string, 1)
try:
file_path.write_text(new_content, encoding="utf-8")
except Exception as exc:
return _text_block(f"Error writing {file_path}: {exc}")
replacements = count if replace_all else 1
return _text_block(
f"Successfully edited {file_path} ({replacements} replacement{'s' if replacements != 1 else ''})."
)
# ───────────────────────────────────────────────────────────────────────────
# GlobTool
# ───────────────────────────────────────────────────────────────────────────
class GlobTool(BaseTool):
name = "Glob"
description = (
"Fast file pattern matching. Supports glob patterns like '**/*.py'. "
"Returns matching file paths sorted by modification time (newest first)."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern to match files (e.g. '**/*.py', 'src/**/*.ts').",
},
"path": {
"type": "string",
"description": "Directory to search in. Defaults to the working directory.",
},
},
"required": ["pattern"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
pattern: str = input_data["pattern"]
base = Path(input_data.get("path") or context.cwd)
if not base.is_dir():
return _text_block(f"Error: directory not found: {base}")
try:
matches: list[Path] = []
for p in base.glob(pattern):
if p.is_file():
matches.append(p)
if len(matches) >= 500:
break
# Sort by modification time, newest first
matches.sort(key=lambda p: p.stat().st_mtime, reverse=True)
if not matches:
return _text_block(f"No files matched pattern '{pattern}' in {base}")
result = "\n".join(str(p) for p in matches)
return _text_block(result)
except Exception as exc:
return _text_block(f"Error during glob '{pattern}' in {base}: {exc}")
# ───────────────────────────────────────────────────────────────────────────
# GrepTool
# ───────────────────────────────────────────────────────────────────────────
class GrepTool(BaseTool):
name = "Grep"
description = (
"Search file contents using regular expressions. Uses ripgrep (rg) "
"when available, otherwise falls back to Python's re module. "
"Supports output modes: files_with_matches, content, count."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Regular expression pattern to search for.",
},
"path": {
"type": "string",
"description": "File or directory to search in. Defaults to the working directory.",
},
"glob": {
"type": "string",
"description": "Glob pattern to filter files (e.g. '*.py', '*.{ts,tsx}').",
},
"output_mode": {
"type": "string",
"enum": ["files_with_matches", "content", "count"],
"description": "Output mode. Default: files_with_matches.",
"default": "files_with_matches",
},
},
"required": ["pattern"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
pattern: str = input_data["pattern"]
search_path: str = input_data.get("path") or context.cwd
file_glob: str | None = input_data.get("glob")
output_mode: str = input_data.get("output_mode", "files_with_matches")
# Try ripgrep first
try:
result = await self._run_rg(pattern, search_path, file_glob, output_mode)
if result is not None:
return result
except FileNotFoundError:
pass # rg not installed, fall through to Python fallback
# Python fallback
return await self._python_grep(pattern, search_path, file_glob, output_mode)
async def _run_rg(
self,
pattern: str,
search_path: str,
file_glob: str | None,
output_mode: str,
) -> list[dict] | None:
"""Run ripgrep and return results, or None if rg is not available."""
cmd = ["rg", "--no-heading", "--color=never"]
if output_mode == "files_with_matches":
cmd.append("--files-with-matches")
elif output_mode == "count":
cmd.append("--count")
else:
cmd.extend(["--line-number"])
if file_glob:
cmd.extend(["--glob", file_glob])
cmd.append(pattern)
cmd.append(search_path)
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
except FileNotFoundError:
raise # re-raise so caller knows rg is missing
except asyncio.TimeoutError:
return _text_block("Error: grep timed out after 30 seconds.")
except Exception as exc:
return _text_block(f"Error running ripgrep: {exc}")
output = stdout.decode("utf-8", errors="replace")
if proc.returncode not in (0, 1):
err = stderr.decode("utf-8", errors="replace").strip()
if err:
return _text_block(f"Grep error: {err}")
if not output.strip():
return _text_block(f"No matches found for pattern '{pattern}'.")
# Truncate if too large
if len(output) > _MAX_OUTPUT_BYTES:
output = output[:_MAX_OUTPUT_BYTES] + "\n... (output truncated)"
return _text_block(output.rstrip())
async def _python_grep(
self,
pattern: str,
search_path: str,
file_glob: str | None,
output_mode: str,
) -> list[dict]:
"""Pure-Python grep fallback using the re module."""
try:
regex = re.compile(pattern)
except re.error as exc:
return _text_block(f"Invalid regex pattern: {exc}")
base = Path(search_path)
if base.is_file():
files = [base]
elif base.is_dir():
glob_pat = file_glob or "**/*"
files = [p for p in base.glob(glob_pat) if p.is_file()]
else:
return _text_block(f"Error: path not found: {search_path}")
lines_out: list[str] = []
total_bytes = 0
truncated = False
for fp in sorted(files):
try:
text = fp.read_text(encoding="utf-8", errors="replace")
except Exception:
continue
file_matches: list[tuple[int, str]] = []
for lineno, line in enumerate(text.splitlines(), start=1):
if regex.search(line):
file_matches.append((lineno, line))
if not file_matches:
continue
if output_mode == "files_with_matches":
entry = str(fp)
elif output_mode == "count":
entry = f"{fp}:{len(file_matches)}"
else:
parts = [f"{fp}:{ln}:{txt}" for ln, txt in file_matches]
entry = "\n".join(parts)
total_bytes += len(entry)
if total_bytes > _MAX_OUTPUT_BYTES:
truncated = True
break
lines_out.append(entry)
if not lines_out:
return _text_block(f"No matches found for pattern '{pattern}'.")
result = "\n".join(lines_out)
if truncated:
result += "\n... (output truncated)"
return _text_block(result)
+61
View File
@@ -0,0 +1,61 @@
"""Central tool registry.
Importing this module automatically registers all builtin tools.
"""
from __future__ import annotations
from backend.apps.agents.tools.base import BaseTool
from backend.apps.agents.providers.base import ToolSchema
_TOOLS: dict[str, BaseTool] = {}
def register_tool(tool: BaseTool) -> None:
"""Register a tool instance by its name."""
_TOOLS[tool.name] = tool
def get_tool(name: str) -> BaseTool | None:
"""Look up a registered tool by name. Returns None if not found."""
return _TOOLS.get(name)
def get_all_tools() -> list[BaseTool]:
"""Return all registered tool instances."""
return list(_TOOLS.values())
def get_all_tool_schemas() -> list[ToolSchema]:
"""Return provider-agnostic ToolSchema for every registered tool."""
return [t.to_tool_schema() for t in _TOOLS.values()]
def init_tools() -> None:
"""Import and register all builtin tools."""
from backend.apps.agents.tools.filesystem import (
ReadTool,
WriteTool,
EditTool,
GlobTool,
GrepTool,
)
from backend.apps.agents.tools.system import BashTool, AskUserQuestionTool
from backend.apps.agents.tools.web import WebSearchTool, WebFetchTool
for tool_cls in [
ReadTool,
WriteTool,
EditTool,
GlobTool,
GrepTool,
BashTool,
AskUserQuestionTool,
WebSearchTool,
WebFetchTool,
]:
register_tool(tool_cls())
# Auto-register on import
init_tools()
+125
View File
@@ -0,0 +1,125 @@
"""System tools: Bash and AskUserQuestion."""
from __future__ import annotations
import asyncio
from backend.apps.agents.tools.base import BaseTool, ToolContext
_MAX_OUTPUT_BYTES = 100 * 1024 # ~100 KB cap
class BashTool(BaseTool):
name = "Bash"
description = (
"Execute a shell command and return its output. The command runs in "
"the session's working directory. Supports an optional timeout "
"(default 120 000 ms). Stdout and stderr are captured and returned."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute.",
},
"timeout": {
"type": "integer",
"description": "Timeout in milliseconds (default 120000, max 600000).",
"default": 120000,
},
"description": {
"type": "string",
"description": "Optional human-readable description of what this command does.",
},
},
"required": ["command"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
command: str = input_data["command"]
timeout_ms: int = min(input_data.get("timeout", 120000), 600000)
timeout_s: float = timeout_ms / 1000.0
try:
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=context.cwd,
)
except Exception as exc:
return [{"type": "text", "text": f"Error starting command: {exc}"}]
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_s)
except asyncio.TimeoutError:
# Attempt to kill the process
try:
proc.kill()
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=5)
except Exception:
stdout, stderr = b"", b""
partial = self._decode(stdout, stderr)
msg = (
f"Command timed out after {timeout_ms}ms.\n"
f"Partial output:\n{partial}"
)
return [{"type": "text", "text": self._truncate(msg)}]
except Exception as exc:
return [{"type": "text", "text": f"Error executing command: {exc}"}]
output = self._decode(stdout, stderr)
if proc.returncode != 0:
output = f"Exit code: {proc.returncode}\n{output}"
if not output.strip():
output = f"(command completed with exit code {proc.returncode})"
return [{"type": "text", "text": self._truncate(output)}]
@staticmethod
def _decode(stdout: bytes, stderr: bytes) -> str:
parts: list[str] = []
if stdout:
parts.append(stdout.decode("utf-8", errors="replace"))
if stderr:
parts.append(stderr.decode("utf-8", errors="replace"))
return "\n".join(parts)
@staticmethod
def _truncate(text: str) -> str:
if len(text) > _MAX_OUTPUT_BYTES:
return text[:_MAX_OUTPUT_BYTES] + "\n... (output truncated)"
return text
class AskUserQuestionTool(BaseTool):
name = "AskUserQuestion"
description = (
"Ask the user a clarifying question. The actual blocking/HITL "
"interaction is handled by the agent loop's hitl_handler; this tool "
"simply surfaces the question text."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "The question to ask the user.",
},
},
"required": ["question"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
question: str = input_data.get("question", "")
return [{"type": "text", "text": question}]
+206
View File
@@ -0,0 +1,206 @@
"""Web tools: WebSearch and WebFetch."""
from __future__ import annotations
import html
import re
from typing import Any
import httpx
from backend.apps.agents.tools.base import BaseTool, ToolContext
_MAX_OUTPUT_BYTES = 100 * 1024 # ~100 KB
_HTTP_TIMEOUT = 30 # seconds
_USER_AGENT = (
"Mozilla/5.0 (compatible; SelfSwarmBot/1.0; +https://github.com/openswarm-ai/self-swarm)"
)
def _truncate(text: str, limit: int = _MAX_OUTPUT_BYTES) -> str:
if len(text) > limit:
return text[:limit] + "\n... (output truncated)"
return text
def _strip_html(raw_html: str) -> str:
"""Naive but effective HTML → plain-text conversion."""
# Remove script/style blocks
text = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", raw_html, flags=re.DOTALL | re.IGNORECASE)
# Remove HTML tags
text = re.sub(r"<[^>]+>", " ", text)
# Decode HTML entities
text = html.unescape(text)
# Collapse whitespace
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
# ───────────────────────────────────────────────────────────────────────────
# WebSearchTool
# ───────────────────────────────────────────────────────────────────────────
class WebSearchTool(BaseTool):
name = "WebSearch"
description = (
"Search the web using DuckDuckGo and return titles, URLs, and "
"snippets for the top results."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query.",
},
"num_results": {
"type": "integer",
"description": "Maximum number of results to return (default 5).",
"default": 5,
},
},
"required": ["query"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
query: str = input_data["query"]
num_results: int = input_data.get("num_results", 5)
try:
results = await self._search_ddg(query, num_results)
if not results:
return [{"type": "text", "text": f"No search results found for: {query}"}]
return [{"type": "text", "text": results}]
except Exception as exc:
return [{"type": "text", "text": f"Web search error: {exc}"}]
@staticmethod
async def _search_ddg(query: str, num_results: int) -> str:
"""Query DuckDuckGo HTML endpoint and parse results."""
async with httpx.AsyncClient(
timeout=_HTTP_TIMEOUT,
follow_redirects=True,
headers={"User-Agent": _USER_AGENT},
) as client:
resp = await client.post(
"https://html.duckduckgo.com/html/",
data={"q": query},
)
resp.raise_for_status()
body = resp.text
# Parse result blocks DuckDuckGo wraps each result in
# <div class="result ..."> ... </div>
result_blocks = re.findall(
r'<div[^>]*class="[^"]*result[^"]*"[^>]*>(.*?)</div>\s*(?=<div[^>]*class="[^"]*result|$)',
body,
flags=re.DOTALL,
)
entries: list[str] = []
for block in result_blocks:
if len(entries) >= num_results:
break
# Title + URL
link_match = re.search(
r'<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]*)"[^>]*>(.*?)</a>',
block,
flags=re.DOTALL,
)
if not link_match:
continue
raw_url = html.unescape(link_match.group(1))
title = _strip_html(link_match.group(2)).strip()
# Snippet
snippet_match = re.search(
r'<a[^>]*class="[^"]*result__snippet[^"]*"[^>]*>(.*?)</a>',
block,
flags=re.DOTALL,
)
snippet = _strip_html(snippet_match.group(1)).strip() if snippet_match else ""
# DuckDuckGo wraps URLs through a redirect; try to extract the real URL
real_url_match = re.search(r"uddg=([^&]+)", raw_url)
if real_url_match:
from urllib.parse import unquote
url = unquote(real_url_match.group(1))
else:
url = raw_url
entry = f"[{len(entries) + 1}] {title}\n {url}"
if snippet:
entry += f"\n {snippet}"
entries.append(entry)
return "\n\n".join(entries)
# ───────────────────────────────────────────────────────────────────────────
# WebFetchTool
# ───────────────────────────────────────────────────────────────────────────
class WebFetchTool(BaseTool):
name = "WebFetch"
description = (
"Fetch the contents of a URL and return the extracted text. "
"HTML is stripped to plain text. Output is truncated to ~100 KB."
)
def get_schema(self) -> dict:
return {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to fetch.",
},
"prompt": {
"type": "string",
"description": "Optional prompt/context describing what information to look for.",
},
},
"required": ["url"],
"additionalProperties": False,
}
async def execute(self, input_data: dict, context: ToolContext) -> list[dict]:
url: str = input_data["url"]
prompt: str | None = input_data.get("prompt")
try:
async with httpx.AsyncClient(
timeout=_HTTP_TIMEOUT,
follow_redirects=True,
headers={"User-Agent": _USER_AGENT},
) as client:
resp = await client.get(url)
resp.raise_for_status()
except httpx.HTTPStatusError as exc:
return [{"type": "text", "text": f"HTTP error {exc.response.status_code} fetching {url}"}]
except Exception as exc:
return [{"type": "text", "text": f"Error fetching {url}: {exc}"}]
content_type = resp.headers.get("content-type", "")
if "html" in content_type or resp.text.strip().startswith("<!"):
text = _strip_html(resp.text)
else:
text = resp.text
text = _truncate(text)
header = f"Contents of {url}:"
if prompt:
header += f"\n(Looking for: {prompt})"
return [{"type": "text", "text": f"{header}\n\n{text}"}]
View File
+154
View File
@@ -0,0 +1,154 @@
"""Analytics SubApp: PostHog for product analytics + local usage summary from session data."""
import json
import logging
import os
import platform
from collections import Counter
from contextlib import asynccontextmanager
from backend.config.Apps import SubApp
from backend.config.paths import SESSIONS_DIR
from backend.apps.analytics.collector import init as init_collector, shutdown as shutdown_collector, record, identify
logger = logging.getLogger(__name__)
@asynccontextmanager
async def analytics_lifespan():
init_collector()
logger.info("PostHog analytics initialised")
try:
from backend.apps.settings.settings import load_settings
settings = load_settings()
providers = []
if getattr(settings, "anthropic_api_key", None):
providers.append("anthropic")
if getattr(settings, "openai_api_key", None):
providers.append("openai")
if getattr(settings, "google_api_key", None):
providers.append("gemini")
if getattr(settings, "openrouter_api_key", None):
providers.append("openrouter")
for cp in getattr(settings, "custom_providers", []):
providers.append(cp.name)
record("app.opened", {
"os": platform.system(),
"platform": platform.platform(),
"provider_count": len(providers),
"providers": providers,
"connection_mode": getattr(settings, "connection_mode", "own_key"),
})
identify({
"providers_configured": providers,
"provider_count": len(providers),
"connection_mode": getattr(settings, "connection_mode", "own_key"),
})
except Exception as e:
logger.debug(f"Analytics startup event failed (non-critical): {e}")
yield
shutdown_collector()
logger.info("PostHog analytics shut down")
analytics = SubApp("analytics", analytics_lifespan)
def _load_all_sessions() -> list[dict]:
"""Load all persisted session JSON files."""
results = []
if not os.path.exists(SESSIONS_DIR):
return results
for fname in os.listdir(SESSIONS_DIR):
if fname.endswith(".json"):
try:
with open(os.path.join(SESSIONS_DIR, fname)) as f:
results.append(json.load(f))
except Exception:
pass
return results
@analytics.router.get("/usage-summary")
async def usage_summary():
"""Compute usage stats from persisted sessions for the Settings page."""
from backend.apps.agents.agent_manager import agent_manager
# Combine persisted + active sessions
sessions = _load_all_sessions()
for s in agent_manager.get_all_sessions():
sessions.append(s.model_dump(mode="json"))
total_sessions = len(sessions)
total_cost = sum(s.get("cost_usd", 0) for s in sessions)
total_messages = 0
total_tool_calls = 0
total_duration = 0.0
model_counts: Counter = Counter()
provider_counts: Counter = Counter()
tool_counts: Counter = Counter()
status_counts: Counter = Counter()
for s in sessions:
messages = s.get("messages", [])
user_msgs = [m for m in messages if m.get("role") in ("user", "assistant")]
tool_msgs = [m for m in messages if m.get("role") == "tool_call"]
total_messages += len(user_msgs)
total_tool_calls += len(tool_msgs)
model_counts[s.get("model", "unknown")] += 1
provider_counts[s.get("provider", "anthropic")] += 1
status_counts[s.get("status", "unknown")] += 1
# Duration
created = s.get("created_at")
closed = s.get("closed_at")
if created and closed:
try:
from datetime import datetime
fmt = "%Y-%m-%dT%H:%M:%S"
c_str = created[:19]
cl_str = closed[:19]
dur = (datetime.fromisoformat(cl_str) - datetime.fromisoformat(c_str)).total_seconds()
if dur > 0:
total_duration += dur
except Exception:
pass
# Count individual tools
for m in tool_msgs:
content = m.get("content", {})
if isinstance(content, dict):
tool_name = content.get("tool", "")
if tool_name:
tool_counts[tool_name] += 1
avg_duration = total_duration / total_sessions if total_sessions > 0 else 0
avg_cost = total_cost / total_sessions if total_sessions > 0 else 0
completed = status_counts.get("completed", 0)
completion_rate = completed / total_sessions if total_sessions > 0 else 0
return {
"total_sessions": total_sessions,
"total_cost_usd": round(total_cost, 4),
"total_messages": total_messages,
"total_tool_calls": total_tool_calls,
"avg_duration_seconds": round(avg_duration, 1),
"avg_cost_per_session": round(avg_cost, 4),
"completion_rate": round(completion_rate, 3),
"models_used": dict(model_counts.most_common(10)),
"providers_used": dict(provider_counts.most_common(10)),
"top_tools": dict(tool_counts.most_common(15)),
"status_breakdown": dict(status_counts),
}
@analytics.router.get("/status")
async def analytics_status():
return {"status": "posthog", "enabled": True}
+123
View File
@@ -0,0 +1,123 @@
"""PostHog-only analytics collector.
All events go directly to PostHog. No local SQLite storage.
Usage from any module:
from backend.apps.analytics.collector import record
record("session.started", {"model": "opus"}, session_id="abc123")
"""
import logging
import platform
from uuid import uuid4
from posthog import Posthog
logger = logging.getLogger(__name__)
POSTHOG_API_KEY = "phc_KdVLvAdjCuHeacFoDm1CM1Gb23XikewRqlX67Mj6TNB"
POSTHOG_HOST = "https://us.i.posthog.com"
_posthog: Posthog | None = None
_installation_id: str | None = None
def init():
"""Initialise PostHog. Called once at app startup."""
global _posthog
if _posthog is None:
_posthog = Posthog(
project_api_key=POSTHOG_API_KEY,
host=POSTHOG_HOST,
)
return _posthog
def shutdown():
"""Flush and close. Called at app shutdown."""
global _posthog
if _posthog:
try:
_posthog.shutdown()
except Exception:
pass
_posthog = None
def _get_installation_id() -> str:
"""Get or create a stable anonymous installation ID."""
global _installation_id
if _installation_id:
return _installation_id
try:
from backend.apps.settings.settings import load_settings, _save_settings
settings = load_settings()
iid = getattr(settings, "installation_id", None)
if not iid:
iid = uuid4().hex
settings.installation_id = iid
_save_settings(settings)
_installation_id = iid
except Exception:
_installation_id = uuid4().hex
return _installation_id
def _is_opted_in() -> bool:
"""Check if user has opted in to analytics."""
try:
from backend.apps.settings.settings import load_settings
return getattr(load_settings(), "analytics_opt_in", True)
except Exception:
return True
def record(
event_type: str,
properties: dict | None = None,
session_id: str | None = None,
dashboard_id: str | None = None,
):
"""Record an analytics event to PostHog."""
if not _posthog or not _is_opted_in():
return
props = {**(properties or {})}
if session_id:
props["session_id"] = session_id
if dashboard_id:
props["dashboard_id"] = dashboard_id
props["os"] = platform.system()
props["platform"] = platform.platform()
try:
_posthog.capture(
event_type,
distinct_id=_get_installation_id(),
properties=props,
)
except Exception as e:
logger.debug(f"PostHog capture failed (non-critical): {e}")
def identify(extra_properties: dict | None = None):
"""Identify the current installation with properties."""
if not _posthog or not _is_opted_in():
return
try:
_posthog.identify(
_get_installation_id(),
properties={
"os": platform.system(),
"platform": platform.platform(),
**(extra_properties or {}),
},
)
except Exception as e:
logger.debug(f"PostHog identify failed (non-critical): {e}")
def get_collector():
"""Backward compat — returns None since we no longer have a local collector."""
return None
+37
View File
@@ -0,0 +1,37 @@
from pydantic import BaseModel
from typing import Optional
class AnalyticsEvent(BaseModel):
id: Optional[int] = None
timestamp: str
event_type: str
properties: dict
session_id: Optional[str] = None
dashboard_id: Optional[str] = None
class UsageSummary(BaseModel):
total_sessions: int = 0
total_cost_usd: float = 0.0
total_messages: int = 0
total_tool_calls: int = 0
avg_session_duration_seconds: float = 0.0
session_completion_rate: float = 0.0
approval_rate: float = 0.0
models_used: dict[str, int] = {}
modes_used: dict[str, int] = {}
top_tools: list[list] = []
class TimeSeriesPoint(BaseModel):
date: str
value: float
class ExportPayload(BaseModel):
export_version: str = "1.0"
exported_at: str = ""
app_version: str = "unknown"
period: dict = {}
summary: dict = {}
View File
+168
View File
@@ -0,0 +1,168 @@
"""Auth SubApp — handles managed-mode authentication with Open Swarm service.
All endpoints are currently stubbed with mock responses so the full UI flow
works end-to-end without a real proxy server.
"""
from contextlib import asynccontextmanager
from uuid import uuid4
from pydantic import BaseModel
from typing import Optional
from backend.config.Apps import SubApp
from backend.apps.settings.settings import load_settings, update_settings
# ── Models ──────────────────────────────────────────────────────────────
class LoginRequest(BaseModel):
email: str
password: str
class GoogleCallbackRequest(BaseModel):
code: str
redirect_uri: str = ""
class LoginResponse(BaseModel):
ok: bool
token: Optional[str] = None
email: Optional[str] = None
proxy_url: Optional[str] = None
error: Optional[str] = None
class ValidateResponse(BaseModel):
valid: bool
email: Optional[str] = None
class UsageResponse(BaseModel):
used_usd: float
quota_usd: float
reset_date: str
# ── SubApp setup ────────────────────────────────────────────────────────
@asynccontextmanager
async def _lifespan():
yield
auth = SubApp("auth", _lifespan)
router = auth.router
# ── Endpoints ───────────────────────────────────────────────────────────
@router.post("/login")
async def login(req: LoginRequest) -> LoginResponse:
"""Authenticate with email + password.
TODO: replace with real API call to Open Swarm auth server.
"""
if not req.email or not req.password:
return LoginResponse(ok=False, error="Email and password are required")
# Stub: generate a mock token for any valid-looking input
mock_token = f"osw_{uuid4().hex}"
proxy_url = "https://api.openswarm.ai"
# Persist credentials to settings
settings = load_settings()
settings.connection_mode = "managed"
settings.openswarm_auth_token = mock_token
settings.openswarm_proxy_url = proxy_url
settings.openswarm_user_email = req.email
await _save_settings(settings)
return LoginResponse(ok=True, token=mock_token, email=req.email, proxy_url=proxy_url)
@router.post("/google-url")
async def google_auth_url() -> dict:
"""Return the Google OAuth authorize URL.
TODO: replace with real Google OAuth URL construction.
"""
# Stub: return a placeholder URL
return {
"url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=PLACEHOLDER&response_type=code&scope=email+profile&redirect_uri=http://localhost:8324/api/auth/google-callback"
}
@router.post("/google-callback")
async def google_callback(req: GoogleCallbackRequest) -> LoginResponse:
"""Exchange Google OAuth code for a session token.
TODO: replace with real OAuth code exchange + Open Swarm auth server call.
"""
if not req.code:
return LoginResponse(ok=False, error="Authorization code is required")
# Stub: generate a mock token
mock_token = f"osw_{uuid4().hex}"
proxy_url = "https://api.openswarm.ai"
mock_email = "user@gmail.com"
settings = load_settings()
settings.connection_mode = "managed"
settings.openswarm_auth_token = mock_token
settings.openswarm_proxy_url = proxy_url
settings.openswarm_user_email = mock_email
await _save_settings(settings)
return LoginResponse(ok=True, token=mock_token, email=mock_email, proxy_url=proxy_url)
@router.post("/validate")
async def validate_token() -> ValidateResponse:
"""Check if the stored auth token is still valid.
TODO: replace with real validation call to Open Swarm auth server.
"""
settings = load_settings()
if not settings.openswarm_auth_token:
return ValidateResponse(valid=False)
# Stub: always return valid
return ValidateResponse(valid=True, email=settings.openswarm_user_email)
@router.post("/logout")
async def logout() -> dict:
"""Clear managed-mode credentials from settings."""
settings = load_settings()
settings.connection_mode = "own_key"
settings.openswarm_auth_token = None
settings.openswarm_proxy_url = None
settings.openswarm_user_email = None
await _save_settings(settings)
return {"ok": True}
@router.get("/usage")
async def get_usage() -> UsageResponse:
"""Fetch usage and quota information for the current managed-mode user.
TODO: replace with real API call to Open Swarm proxy server.
"""
settings = load_settings()
if not settings.openswarm_auth_token:
return UsageResponse(used_usd=0, quota_usd=0, reset_date="")
# Stub: return mock usage data
return UsageResponse(used_usd=0, quota_usd=50, reset_date="2026-04-01")
# ── Helpers ─────────────────────────────────────────────────────────────
async def _save_settings(settings):
"""Persist settings to disk (reuses the settings module's update logic)."""
import json
from backend.apps.settings.settings import SETTINGS_FILE
with open(SETTINGS_FILE, "w") as f:
json.dump(settings.model_dump(), f, indent=2)
+4 -5
View File
@@ -123,8 +123,10 @@ async def list_dashboards():
@dashboards.router.post("/create")
async def create_dashboard(body: DashboardCreate):
from backend.apps.analytics.collector import record as _analytics
dashboard = Dashboard(name=body.name)
_save(dashboard)
_analytics("dashboard.created", {}, dashboard_id=dashboard.id)
return dashboard.model_dump(mode="json")
@@ -151,13 +153,10 @@ async def generate_name(dashboard_id: str):
fallback = prompts[0][:40]
try:
import anthropic
from backend.apps.settings.settings import load_settings
from backend.apps.settings.credentials import get_anthropic_client
global_settings = load_settings()
if not global_settings.anthropic_api_key:
raise ValueError("API key not configured")
client = anthropic.AsyncAnthropic(api_key=global_settings.anthropic_api_key)
client = get_anthropic_client(global_settings)
if len(prompts) == 1:
system = (
+3 -5
View File
@@ -32,13 +32,11 @@ def _resolve_model(short_name: str) -> str:
def _get_anthropic_client():
"""Create an AsyncAnthropic client using the API key from app settings."""
import anthropic
"""Create an AsyncAnthropic client using credentials from app settings."""
from backend.apps.settings.credentials import get_anthropic_client
settings = load_settings()
if not settings.anthropic_api_key:
raise ValueError("Anthropic API key not configured. Set it in Settings.")
return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
return get_anthropic_client(settings)
def _validate_against_schema(data: dict, schema: dict) -> str | None:
+111
View File
@@ -0,0 +1,111 @@
"""Centralized credential resolution for LLM API calls.
Supports multiple providers: Anthropic (native), OpenAI, Gemini,
OpenRouter, and user-configured custom providers.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import anthropic
from backend.apps.settings.models import AppSettings
OPENSWARM_DEFAULT_PROXY_URL = "https://api.openswarm.ai"
def validate_credentials(settings: AppSettings, provider: str = "anthropic") -> None:
"""Raise ValueError if credentials are missing for the given provider."""
if provider == "anthropic":
if getattr(settings, "connection_mode", "own_key") == "managed":
if not getattr(settings, "openswarm_auth_token", None):
raise ValueError(
"Open Swarm account not connected. Sign in via Settings → API."
)
else:
if not settings.anthropic_api_key:
raise ValueError(
"Anthropic API key not configured. Set it in Settings."
)
elif provider == "openai":
if not settings.openai_api_key:
raise ValueError("OpenAI API key not configured. Set it in Settings.")
elif provider == "gemini":
if not getattr(settings, "google_api_key", None):
raise ValueError("Google API key not configured. Set it in Settings.")
elif provider == "openrouter":
if not getattr(settings, "openrouter_api_key", None):
raise ValueError("OpenRouter API key not configured. Set it in Settings.")
else:
# Custom provider — check if it exists in custom_providers
for cp in getattr(settings, "custom_providers", []):
if cp.name == provider:
return # Custom providers may have empty api_key (e.g. local Ollama)
raise ValueError(f"Provider '{provider}' not found in settings.")
def get_provider_credentials(settings: AppSettings, provider: str) -> dict[str, str]:
"""Return credential dict for a specific provider."""
validate_credentials(settings, provider)
if provider == "anthropic":
if getattr(settings, "connection_mode", "own_key") == "managed":
return {
"auth_token": getattr(settings, "openswarm_auth_token", "") or "",
"base_url": getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL,
}
return {"api_key": settings.anthropic_api_key or ""}
if provider == "openai":
return {"api_key": settings.openai_api_key or ""}
if provider == "gemini":
return {"api_key": getattr(settings, "google_api_key", "") or ""}
if provider == "openrouter":
return {"api_key": getattr(settings, "openrouter_api_key", "") or ""}
# Custom provider
for cp in getattr(settings, "custom_providers", []):
if cp.name == provider:
return {"api_key": cp.api_key, "base_url": cp.base_url}
raise ValueError(f"No credentials for provider: {provider}")
# ---------------------------------------------------------------------------
# Legacy helpers (kept for backward compat during migration)
# ---------------------------------------------------------------------------
def get_agent_sdk_env(settings: AppSettings) -> dict[str, str]:
"""Return the env dict for ClaudeAgentOptions based on connection mode.
DEPRECATED: Use create_provider() from providers.registry instead.
"""
validate_credentials(settings, "anthropic")
if getattr(settings, "connection_mode", "own_key") == "managed":
proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL
return {
"ANTHROPIC_AUTH_TOKEN": getattr(settings, "openswarm_auth_token", ""),
"ANTHROPIC_BASE_URL": proxy_url,
}
return {"ANTHROPIC_API_KEY": settings.anthropic_api_key}
def get_anthropic_client(settings: AppSettings) -> anthropic.AsyncAnthropic:
"""Return a configured AsyncAnthropic client based on connection mode."""
import anthropic
validate_credentials(settings, "anthropic")
if getattr(settings, "connection_mode", "own_key") == "managed":
proxy_url = getattr(settings, "openswarm_proxy_url", None) or OPENSWARM_DEFAULT_PROXY_URL
return anthropic.AsyncAnthropic(
auth_token=getattr(settings, "openswarm_auth_token", None),
base_url=proxy_url,
)
return anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
+15 -2
View File
@@ -1,5 +1,5 @@
from pydantic import BaseModel
from typing import Optional
from pydantic import BaseModel, Field
from typing import Optional, Any
DEFAULT_SYSTEM_PROMPT = (
'"Ask the user as many follow ups as needed in order to eliminate any possible ambiguity. '
@@ -28,9 +28,22 @@ class AppSettings(BaseModel):
elevenlabs_api_key: Optional[str] = None
deepgram_api_key: Optional[str] = None
openai_api_key: Optional[str] = None
google_api_key: Optional[str] = None
openrouter_api_key: Optional[str] = None
custom_providers: list["CustomProvider"] = Field(default_factory=list)
webhook_base_url: Optional[str] = None
# Dashboard / UI preferences
auto_select_mode_on_new_agent: bool = False
expand_new_chats_in_dashboard: bool = False
auto_reveal_sub_agents: bool = True
dev_mode: bool = False
# Analytics: opted in by default, user can toggle off
analytics_opt_in: bool = True
installation_id: Optional[str] = None
class CustomProvider(BaseModel):
name: str
base_url: str
api_key: str = ""
models: list[dict[str, Any]] = Field(default_factory=list)
+7
View File
@@ -38,6 +38,13 @@ def load_settings() -> AppSettings:
return AppSettings()
def _save_settings(settings: AppSettings):
"""Persist settings to JSON file."""
os.makedirs(DATA_DIR, exist_ok=True)
with open(SETTINGS_FILE, "w") as f:
json.dump(settings.model_dump(), f, indent=2)
@settings.router.get("")
async def get_settings():
return load_settings().model_dump()
+1
View File
@@ -37,5 +37,6 @@ DASHBOARD_LAYOUT_DIR = os.path.join(DATA_ROOT, "dashboard_layout")
BUILTIN_PERMISSIONS_PATH = os.path.join(DATA_ROOT, "builtin_permissions.json")
CHANNELS_DIR = os.path.join(DATA_ROOT, "channels")
CHANNELS_SESSIONS_DIR = os.path.join(DATA_ROOT, "channels", "sessions")
ANALYTICS_DIR = os.path.join(DATA_ROOT, "analytics")
BACKEND_DIR = _BACKEND_DIR
+4 -1
View File
@@ -20,11 +20,13 @@ from backend.apps.skill_registry.skill_registry import skill_registry
from backend.apps.outputs.outputs import outputs
from backend.apps.dashboards.dashboards import dashboards
from backend.apps.channels.channels import channels
from backend.apps.analytics.analytics import analytics
from backend.apps.auth.auth import auth
from fastapi.middleware.cors import CORSMiddleware
from fastapi import WebSocket, WebSocketDisconnect
import json
main_app = MainApp([health, agents, templates, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, channels])
main_app = MainApp([health, agents, templates, skills, tools_lib, modes, settings, mcp_registry, skill_registry, outputs, dashboards, channels, analytics, auth])
app = main_app.app
app.add_middleware(
@@ -52,6 +54,7 @@ async def websocket_session(websocket: WebSocket, session_id: str):
payload.get("prompt", ""),
mode=payload.get("mode"),
model=payload.get("model"),
provider=payload.get("provider"),
images=payload.get("images"),
)
elif event == "agent:approval_response":
+4 -1
View File
@@ -1,5 +1,8 @@
anthropic
claude-agent-sdk
openai
google-genai
mcp
posthog
jsonschema
fastapi[standard]
pydantic==2.10.5
+6
View File
@@ -5,6 +5,7 @@ import { ThemeProvider as MuiThemeProvider, createTheme, CssBaseline } from '@mu
import { store } from '../shared/state/store';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchSettings } from '@/shared/state/settingsSlice';
import { fetchModels } from '@/shared/state/modelsSlice';
import {
setAppVersion,
setUpdateAvailable,
@@ -23,6 +24,8 @@ import Modes from './pages/Modes/Modes';
import Views from './pages/Views/Views';
import Customization from './pages/Customization/Customization';
import Channels from './pages/Channels/Channels';
import Analytics from './pages/Analytics/Analytics';
import AnalyticsOptIn from './components/AnalyticsOptIn';
import { useKeyboardShortcuts } from '@/shared/hooks/useKeyboardShortcuts';
import KeyboardShortcutsHelp from './components/KeyboardShortcutsHelp';
import { ThemeProvider, useThemeMode, useClaudeTokens } from '@/shared/styles/ThemeContext';
@@ -161,6 +164,7 @@ const SettingsLoader: React.FC<{ children: React.ReactNode }> = ({ children }) =
const loaded = useAppSelector((s) => s.settings.loaded);
useEffect(() => {
dispatch(fetchSettings());
dispatch(fetchModels());
}, [dispatch]);
useEffect(() => {
if (loaded) setThemeMode(theme as 'light' | 'dark');
@@ -230,8 +234,10 @@ const ThemedApp: React.FC = () => {
<Route path="/apps" element={<Views />} />
<Route path="/apps/:id" element={<Views />} />
<Route path="/channels" element={<Channels />} />
<Route path="/analytics" element={<Analytics />} />
</Route>
</Routes>
<AnalyticsOptIn />
</UpdateListener>
</SettingsLoader>
</ShortcutsProvider>
@@ -0,0 +1,84 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Button from '@mui/material/Button';
import Paper from '@mui/material/Paper';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { updateSettings } from '@/shared/state/settingsSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const AnalyticsOptIn: React.FC = () => {
const c = useClaudeTokens();
const dispatch = useAppDispatch();
const settings = useAppSelector((s) => s.settings.data);
const loaded = useAppSelector((s) => s.settings.loaded);
if (!loaded || settings.analytics_opt_in !== null) return null;
const handleChoice = (optIn: boolean) => {
dispatch(updateSettings({ ...settings, analytics_opt_in: optIn }));
};
return (
<Box
sx={{
position: 'fixed',
bottom: 24,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1400,
maxWidth: 480,
width: '90%',
}}
>
<Paper
elevation={0}
sx={{
p: 2.5,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.medium}`,
borderRadius: 3,
boxShadow: c.shadow.lg,
}}
>
<Typography sx={{ color: c.text.primary, fontSize: '0.9rem', fontWeight: 600, mb: 0.5 }}>
Help improve OpenSwarm
</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.8rem', lineHeight: 1.5, mb: 2 }}>
Share anonymous usage statistics like session counts, feature usage, and model preferences.
No conversations, file paths, or personal information ever.
You can change this anytime in Settings.
</Typography>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button
onClick={() => handleChoice(false)}
sx={{
color: c.text.muted,
textTransform: 'none',
fontSize: '0.82rem',
'&:hover': { bgcolor: `${c.text.tertiary}0A` },
}}
>
No thanks
</Button>
<Button
variant="contained"
onClick={() => handleChoice(true)}
sx={{
bgcolor: c.accent.primary,
'&:hover': { bgcolor: c.accent.pressed },
textTransform: 'none',
fontSize: '0.82rem',
borderRadius: 1.5,
px: 2,
}}
>
Share anonymous data
</Button>
</Box>
</Paper>
</Box>
);
};
export default AnalyticsOptIn;
@@ -24,6 +24,7 @@ import AddIcon from '@mui/icons-material/Add';
import SettingsIcon from '@mui/icons-material/Settings';
import ExtensionIcon from '@mui/icons-material/Extension';
import PhoneIcon from '@mui/icons-material/Phone';
import BarChartIcon from '@mui/icons-material/BarChart';
import ViewSidebarOutlinedIcon from '@mui/icons-material/ViewSidebarOutlined';
import ArrowBackOutlinedIcon from '@mui/icons-material/ArrowBackOutlined';
import ArrowForwardOutlinedIcon from '@mui/icons-material/ArrowForwardOutlined';
@@ -33,6 +34,7 @@ import CloseIcon from '@mui/icons-material/Close';
import LinearProgress from '@mui/material/LinearProgress';
import Settings from '@/app/pages/Settings/Settings';
import GlobalApprovalOverlay from '@/app/components/GlobalApprovalOverlay';
import TalkModeOverlay from '@/app/components/TalkModeOverlay';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice';
import { addBrowserCard, addBrowserTab } from '@/shared/state/dashboardLayoutSlice';
@@ -69,6 +71,7 @@ const AppShell: React.FC = () => {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [renamingDashboardId, setRenamingDashboardId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState('');
const [talkModeOpen, setTalkModeOpen] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(() => {
try {
const stored = localStorage.getItem(SIDEBAR_WIDTH_KEY);
@@ -231,6 +234,7 @@ const AppShell: React.FC = () => {
const isAppsRoute = location.pathname === '/apps' || location.pathname.startsWith('/apps/');
const isCustomizationRoute = location.pathname === '/customization' || CUSTOMIZATION_PATHS.has(location.pathname);
const isChannelsRoute = location.pathname === '/channels';
const isAnalyticsRoute = location.pathname === '/analytics';
const activeDashboardId = location.pathname.startsWith('/dashboard/')
? location.pathname.split('/dashboard/')[1]
: null;
@@ -959,6 +963,10 @@ const AppShell: React.FC = () => {
<Settings />
<GlobalApprovalOverlay />
<TalkModeOverlay
open={talkModeOpen}
onClose={() => setTalkModeOpen(false)}
/>
<Snackbar
open={showUpdateSnackbar}
@@ -0,0 +1,526 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import MicIcon from '@mui/icons-material/Mic';
import MicOffIcon from '@mui/icons-material/MicOff';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import CloseIcon from '@mui/icons-material/Close';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
import { WS_BASE } from '@/shared/config';
type FaceState = 'idle' | 'happy' | 'thinking' | 'talking' | 'surprised' | 'sleeping' | 'angry' | 'love';
type TalkStatus = 'idle' | 'listening' | 'processing' | 'speaking';
interface Props {
open: boolean;
onClose: () => void;
sessionId?: string;
}
// ─── Pixel Face Canvas Renderer ──────────────────────────────────
// Ported from face.html — all the draw logic in one hook.
function usePixelFace(
canvasRef: React.RefObject<HTMLCanvasElement | null>,
faceState: FaceState,
size: number,
) {
const stateRef = useRef<FaceState>('idle');
const animRef = useRef<number>(0);
useEffect(() => {
stateRef.current = faceState;
}, [faceState]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const PX = Math.max(4, Math.floor(size / 30));
const COLS = Math.ceil(size / PX);
const ROWS = Math.ceil(size / PX);
canvas.width = COLS * PX;
canvas.height = ROWS * PX;
const BG = '#E8927A';
const EYE = '#1E1E1E';
const MOUTH = '#1E1E1E';
let breath = 0, talk = 0, think = 0, sleepZ = 0, heartP = 0;
let eyeH = 3, eyeHTarget = 3;
let mouthW = 2, mouthWTarget = 2;
let mouthH = 2, mouthHTarget = 2;
let eyeOffX = 0, eyeOffXTarget = 0;
let eyeOffY = 0, eyeOffYTarget = 0;
let blinkOpen = true, blinkCD = 120 + Math.random() * 200;
let doubleBlink = false;
let idleSinceInput = 0, sleepTransitioned = false;
let idleAction = 'none', idleActionTimer = 0;
let idleGlanceX = 0, idleGlanceY = 0;
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const px = (col: number, row: number, color: string) => {
ctx.fillStyle = color;
ctx.fillRect(col * PX, row * PX, PX, PX);
};
const pxRect = (x: number, y: number, w: number, h: number, color: string) => {
for (let r = 0; r < Math.round(h); r++)
for (let c = 0; c < Math.round(w); c++)
px(Math.round(x) + c, Math.round(y) + r, color);
};
function draw() {
const state = stateRef.current;
breath += 0.025;
talk += 0.3;
think += 0.025;
sleepZ += 0.012;
heartP += 0.05;
idleSinceInput++;
if (idleSinceInput > 2700 && state === 'idle' && !sleepTransitioned) {
stateRef.current = 'sleeping';
sleepTransitioned = true;
}
if (state === 'idle') {
idleActionTimer--;
blinkCD--;
if (blinkCD <= 0 && blinkOpen) { blinkOpen = false; blinkCD = 6; doubleBlink = Math.random() < 0.3; }
else if (!blinkOpen && blinkCD <= 0) { blinkOpen = true; blinkCD = doubleBlink ? 8 : 100 + Math.random() * 280; doubleBlink = false; }
if (idleActionTimer <= 0) {
const roll = Math.random();
if (roll < 0.3) { idleAction = 'glance'; idleGlanceX = Math.floor(Math.random() * 5) - 2; idleGlanceY = (Math.random() - 0.5) * 1.2; idleActionTimer = 50 + Math.random() * 120; }
else if (roll < 0.45) { idleAction = 'scan'; idleActionTimer = 180; }
else if (roll < 0.55) { idleAction = 'squint'; idleActionTimer = 35 + Math.random() * 40; }
else if (roll < 0.65) { idleAction = 'lookup'; idleActionTimer = 50 + Math.random() * 70; }
else { idleAction = 'none'; idleGlanceX = 0; idleGlanceY = 0; idleActionTimer = 60 + Math.random() * 200; }
}
}
if (state === 'talking' || state === 'thinking') {
blinkCD--;
if (blinkCD <= 0 && blinkOpen) { blinkOpen = false; blinkCD = 6; }
else if (!blinkOpen && blinkCD <= 0) { blinkOpen = true; blinkCD = 120 + Math.random() * 250; }
}
if (state !== 'idle' && state !== 'talking' && state !== 'thinking') blinkOpen = true;
const b = Math.sin(breath) * 0.3;
switch (state) {
case 'idle': {
eyeHTarget = blinkOpen ? 3 : 0; mouthWTarget = 2; mouthHTarget = 2;
let gx = 0, gy = b;
if (idleAction === 'glance') { gx = idleGlanceX; gy = idleGlanceY + b; }
else if (idleAction === 'scan') { const t = 1 - (idleActionTimer / 180); gx = Math.sin(t * Math.PI * 2) * 2.5; }
else if (idleAction === 'squint') { eyeHTarget = blinkOpen ? 2 : 0; gy = b + 0.3; }
else if (idleAction === 'lookup') { gy = -1.2 + b; }
eyeOffXTarget = gx; eyeOffYTarget = gy; break;
}
case 'happy': eyeHTarget = 1; mouthWTarget = 6; mouthHTarget = 1; eyeOffXTarget = 0; eyeOffYTarget = b; break;
case 'thinking': eyeHTarget = blinkOpen ? 3 : 0; mouthWTarget = 2; mouthHTarget = 2; eyeOffXTarget = 2; eyeOffYTarget = b; break;
case 'talking': { eyeHTarget = blinkOpen ? 3 : 0; const open = Math.round(Math.abs(Math.sin(talk)) * 2 + 1); mouthWTarget = 4; mouthHTarget = open; eyeOffXTarget = 0; eyeOffYTarget = b; break; }
case 'surprised': eyeHTarget = 4; mouthWTarget = 3; mouthHTarget = 3; eyeOffXTarget = 0; eyeOffYTarget = b; break;
case 'sleeping': eyeHTarget = 1; mouthWTarget = 2; mouthHTarget = 1; eyeOffXTarget = 0; eyeOffYTarget = b * 2; break;
case 'angry': eyeHTarget = 2; mouthWTarget = 6; mouthHTarget = 1; eyeOffXTarget = 0; eyeOffYTarget = b * 0.3; break;
case 'love': eyeHTarget = 3; mouthWTarget = 2; mouthHTarget = 2; eyeOffXTarget = 0; eyeOffYTarget = b; break;
}
eyeH = lerp(eyeH, eyeHTarget, 0.18);
mouthW = lerp(mouthW, mouthWTarget, 0.15);
mouthH = lerp(mouthH, mouthHTarget, 0.2);
eyeOffX = lerp(eyeOffX, eyeOffXTarget, 0.1);
eyeOffY = lerp(eyeOffY, eyeOffYTarget, 0.15);
// Draw
ctx.fillStyle = BG;
ctx.fillRect(0, 0, canvas.width, canvas.height);
const cx = Math.floor(COLS / 2);
const cy = Math.floor(ROWS / 2);
const eyeSpread = 5, eyeW = 3;
const eh = Math.max(1, Math.round(eyeH));
const eOffX = Math.round(eyeOffX), eOffY = Math.round(eyeOffY);
const eyeBaseY = cy - 2;
const blinkOff = Math.round((3 - eh) / 2);
if (state === 'love') {
const pulse = Math.sin(heartP) > 0 ? '#CC2244' : '#BB1E3E';
const heart = (hx: number, hy: number) => {
px(hx - 1, hy, pulse); px(hx + 1, hy, pulse);
px(hx - 2, hy + 1, pulse); px(hx - 1, hy + 1, pulse); px(hx, hy + 1, pulse); px(hx + 1, hy + 1, pulse); px(hx + 2, hy + 1, pulse);
px(hx - 1, hy + 2, pulse); px(hx, hy + 2, pulse); px(hx + 1, hy + 2, pulse);
px(hx, hy + 3, pulse);
};
heart(cx - eyeSpread + eOffX, eyeBaseY + eOffY);
heart(cx + eyeSpread + eOffX, eyeBaseY + eOffY);
} else {
pxRect(cx - eyeSpread - 1 + eOffX, eyeBaseY + blinkOff + eOffY, eyeW, eh, EYE);
pxRect(cx + eyeSpread - 1 + eOffX, eyeBaseY + blinkOff + eOffY, eyeW, eh, EYE);
}
if (state === 'angry') {
const lx = cx - eyeSpread - 1 + eOffX, ly = eyeBaseY + blinkOff + eOffY - 2;
px(lx, ly + 1, EYE); px(lx + 1, ly, EYE); px(lx + 2, ly, EYE);
const rx = cx + eyeSpread - 1 + eOffX;
px(rx + 2, ly + 1, EYE); px(rx + 1, ly, EYE); px(rx, ly, EYE);
}
const mw = Math.max(1, Math.round(mouthW)), mh = Math.max(1, Math.round(mouthH));
const mouthY = cy + 4 + Math.round(eyeOffY);
if (state === 'happy') {
pxRect(cx - Math.floor(mw / 2), mouthY, mw, 1, MOUTH);
px(cx - Math.floor(mw / 2), mouthY - 1, MOUTH);
px(cx - Math.floor(mw / 2) + mw - 1, mouthY - 1, MOUTH);
} else if (state === 'angry') {
pxRect(cx - Math.floor(mw / 2), mouthY, mw, 1, MOUTH);
px(cx - Math.floor(mw / 2), mouthY + 1, MOUTH);
px(cx - Math.floor(mw / 2) + mw - 1, mouthY + 1, MOUTH);
} else {
pxRect(cx - Math.floor(mw / 2), mouthY, mw, mh, MOUTH);
}
if (state === 'sleeping') {
const zFrame = Math.floor(sleepZ * 60) % 90;
const zy = Math.round(cy - 5 - (zFrame / 90) * 4);
const zx = cx + eyeSpread + 3;
if (zy >= 1 && zFrame < 70) {
px(zx, zy, '#5577CC'); px(zx + 1, zy, '#5577CC');
px(zx + 1, zy + 1, '#5577CC');
px(zx, zy + 2, '#5577CC'); px(zx + 1, zy + 2, '#5577CC');
}
}
if (state === 'thinking') {
const phase = Math.floor(think * 10) % 4;
const dx = cx + eyeSpread + 3, dy = cy - 5;
if (phase >= 1) px(dx, dy, '#7799DD');
if (phase >= 2) px(dx + 1, dy - 1, '#7799DD');
if (phase >= 3) px(dx + 2, dy - 2, '#7799DD');
}
animRef.current = requestAnimationFrame(draw);
}
animRef.current = requestAnimationFrame(draw);
return () => cancelAnimationFrame(animRef.current);
}, [canvasRef, size]);
}
// ─── Status Labels ───────────────────────────────────────────────
const STATUS_LABELS: Record<TalkStatus, string> = {
idle: 'Tap to speak',
listening: 'Listening...',
processing: 'Thinking...',
speaking: 'Speaking...',
};
// ─── Main Component ─────────────────────────────────────────────
const TalkModeOverlay: React.FC<Props> = ({ open, onClose, sessionId }) => {
const c = useClaudeTokens();
const canvasRef = useRef<HTMLCanvasElement>(null);
const [faceState, setFaceState] = useState<FaceState>('idle');
const [talkStatus, setTalkStatus] = useState<TalkStatus>('idle');
const [transcript, setTranscript] = useState('');
const [agentResponse, setAgentResponse] = useState('');
const wsRef = useRef<WebSocket | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const silenceTimerRef = useRef<number>(0);
const audioContextRef = useRef<AudioContext | null>(null);
usePixelFace(canvasRef, faceState, 240);
// Map talk status to face state
useEffect(() => {
switch (talkStatus) {
case 'idle': setFaceState('idle'); break;
case 'listening': setFaceState('idle'); break;
case 'processing': setFaceState('thinking'); break;
case 'speaking': setFaceState('talking'); break;
}
}, [talkStatus]);
// WebSocket connection for talk mode
useEffect(() => {
if (!open || !sessionId) return;
const ws = new WebSocket(`${WS_BASE}/ws/talk/${sessionId}`);
wsRef.current = ws;
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'config', stt: {}, tts: {} }));
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'status':
if (msg.status === 'listening') setTalkStatus('idle');
else if (msg.status === 'processing') setTalkStatus('processing');
else if (msg.status === 'speaking') setTalkStatus('speaking');
break;
case 'transcript':
setTranscript(msg.text);
break;
case 'agent_response':
setAgentResponse(msg.text);
setFaceState('happy');
setTimeout(() => setFaceState('idle'), 2000);
break;
case 'audio': {
const audioData = atob(msg.data);
const audioArray = new Uint8Array(audioData.length);
for (let i = 0; i < audioData.length; i++) audioArray[i] = audioData.charCodeAt(i);
if (!audioContextRef.current) audioContextRef.current = new AudioContext();
const audioCtx = audioContextRef.current;
audioCtx.decodeAudioData(audioArray.buffer.slice(0), (buffer) => {
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
source.onended = () => setTalkStatus('idle');
source.start(0);
setTalkStatus('speaking');
});
break;
}
}
};
ws.onerror = () => setFaceState('angry');
ws.onclose = () => {};
return () => {
ws.close();
wsRef.current = null;
};
}, [open, sessionId]);
// Keyboard: Esc to close
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [open, onClose]);
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
mediaRecorderRef.current = recorder;
const chunks: Blob[] = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
recorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop());
const blob = new Blob(chunks, { type: 'audio/webm' });
const reader = new FileReader();
reader.onload = () => {
const base64 = (reader.result as string).split(',')[1];
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'audio', data: base64, format: 'webm' }));
wsRef.current.send(JSON.stringify({ type: 'end_utterance', format: 'webm' }));
}
};
reader.readAsDataURL(blob);
setTalkStatus('processing');
};
recorder.start();
setTalkStatus('listening');
setTranscript('');
setAgentResponse('');
// Auto-stop after silence (simple timeout approach)
silenceTimerRef.current = window.setTimeout(() => {
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop();
}
}, 5000);
} catch {
setFaceState('angry');
}
}, []);
const stopRecording = useCallback(() => {
window.clearTimeout(silenceTimerRef.current);
if (mediaRecorderRef.current?.state === 'recording') {
mediaRecorderRef.current.stop();
}
}, []);
const handleMicClick = useCallback(() => {
if (talkStatus === 'listening') {
stopRecording();
} else if (talkStatus === 'idle') {
startRecording();
}
}, [talkStatus, startRecording, stopRecording]);
if (!open) return null;
const micBg =
talkStatus === 'listening' ? c.accent.primary :
talkStatus === 'speaking' ? '#4caf50' :
c.bg.elevated;
return (
<Box
sx={{
position: 'fixed',
inset: 0,
zIndex: 9999,
bgcolor: 'rgba(0, 0, 0, 0.75)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(8px)',
animation: 'fadeIn 300ms cubic-bezier(0.165, 0.85, 0.45, 1)',
'@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } },
'@keyframes pulse': {
'0%': { boxShadow: `0 0 0 0 ${c.accent.primary}60` },
'70%': { boxShadow: `0 0 0 16px ${c.accent.primary}00` },
'100%': { boxShadow: `0 0 0 0 ${c.accent.primary}00` },
},
}}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
{/* Close button */}
<IconButton
onClick={onClose}
sx={{
position: 'absolute',
top: 24,
right: 24,
color: 'rgba(255,255,255,0.5)',
'&:hover': { color: 'rgba(255,255,255,0.9)' },
}}
>
<CloseIcon />
</IconButton>
{/* Face canvas */}
<Box
sx={{
borderRadius: 4,
overflow: 'hidden',
boxShadow: '0 8px 40px rgba(0,0,0,0.4)',
mb: 3,
}}
>
<canvas
ref={canvasRef}
style={{
display: 'block',
imageRendering: 'pixelated',
width: 240,
height: 240,
borderRadius: 16,
}}
/>
</Box>
{/* Status label */}
<Typography
sx={{
color: 'rgba(255,255,255,0.5)',
fontSize: '0.85rem',
fontWeight: 500,
mb: 3,
fontFamily: c.font.sans,
letterSpacing: 0.3,
}}
>
{STATUS_LABELS[talkStatus]}
</Typography>
{/* Transcript area */}
<Box sx={{ maxWidth: 440, width: '100%', px: 3, mb: 2, minHeight: 80 }}>
{transcript && (
<Typography
sx={{
color: 'rgba(255,255,255,0.4)',
fontSize: '0.9rem',
fontStyle: 'italic',
textAlign: 'center',
mb: 1.5,
fontFamily: c.font.sans,
animation: 'fadeIn 200ms ease',
}}
>
"{transcript}"
</Typography>
)}
{agentResponse && (
<Typography
sx={{
color: 'rgba(255,255,255,0.85)',
fontSize: '0.95rem',
textAlign: 'center',
fontFamily: c.font.sans,
lineHeight: 1.5,
animation: 'fadeIn 300ms ease',
}}
>
{agentResponse}
</Typography>
)}
</Box>
{/* Mic button */}
<IconButton
onClick={handleMicClick}
sx={{
width: 56,
height: 56,
bgcolor: micBg,
color: talkStatus === 'listening' ? '#fff' : c.text.primary,
'&:hover': { bgcolor: micBg, opacity: 0.9 },
transition: 'all 200ms ease',
animation: talkStatus === 'listening' ? 'pulse 1.5s infinite' : 'none',
mt: 2,
}}
>
{talkStatus === 'listening' ? <MicIcon sx={{ fontSize: 28 }} /> :
talkStatus === 'speaking' ? <VolumeUpIcon sx={{ fontSize: 28 }} /> :
<MicIcon sx={{ fontSize: 28 }} />}
</IconButton>
{/* Hint */}
<Typography
sx={{
color: 'rgba(255,255,255,0.2)',
fontSize: '0.7rem',
mt: 3,
fontFamily: c.font.sans,
}}
>
esc to close
</Typography>
</Box>
);
};
export default TalkModeOverlay;
+30 -10
View File
@@ -27,6 +27,7 @@ import {
switchBranch,
duplicateSession,
setActiveSession,
updateSessionProvider,
updateSessionModel,
updateSessionMode,
fetchSession,
@@ -45,9 +46,9 @@ import DiffViewer from './DiffViewer';
import { setGlowingBrowserCards, clearGlowingBrowserCards } from '@/shared/state/dashboardLayoutSlice';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const CONTEXT_WINDOWS: Record<string, number> = {
sonnet: 200_000,
opus: 200_000,
const CONTEXT_WINDOWS_DEFAULT: Record<string, number> = {
sonnet: 1_000_000,
opus: 1_000_000,
haiku: 200_000,
};
@@ -135,6 +136,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const dispatch = useAppDispatch();
const session = useAppSelector((state) => (id ? state.agents.sessions[id] : undefined));
const modesMap = useAppSelector((state) => state.modes.items);
const modelsByProvider = useAppSelector((state) => state.models.byProvider);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const chatInputRef = useRef<ChatInputHandle>(null);
const isAtBottomRef = useRef(true);
@@ -143,6 +145,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
const [awaitingResponse, setAwaitingResponse] = useState(false);
const [mode, setMode] = useState('agent');
const [model, setModel] = useState('sonnet');
const [provider, setProvider] = useState('anthropic');
const wsRef = useRef<ReturnType<typeof createSessionWs> | null>(null);
const initialContextApplied = useRef(false);
@@ -185,6 +188,10 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
if (session) setModel(session.model);
}, [session?.model]);
useEffect(() => {
if (session?.provider) setProvider(session.provider);
}, [session?.provider]);
useEffect(() => {
if (Object.keys(modesMap).length === 0) dispatch(fetchModes());
}, [dispatch, modesMap]);
@@ -194,11 +201,11 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
setShowResumeBubble(false);
setAwaitingResponse(true);
if (isDraft) {
const config: Record<string, any> = { model, mode };
const config: Record<string, any> = { provider, model, mode };
if (session?.system_prompt) config.system_prompt = session.system_prompt;
if (session?.target_directory) config.target_directory = session.target_directory;
dispatch(
launchAndSendFirstMessage({ draftId: id, config, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds })
launchAndSendFirstMessage({ draftId: id, config, prompt: msg.prompt, mode, model, provider, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds })
).then((action) => {
if (launchAndSendFirstMessage.fulfilled.match(action)) {
const realId = action.payload.session.id;
@@ -212,14 +219,14 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
if (msg.selectedBrowserIds?.length) {
dispatch(setGlowingBrowserCards({ browserIds: msg.selectedBrowserIds, sessionId: id }));
}
dispatch(sendMessageThunk({ sessionId: id, prompt: msg.prompt, mode, model, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds }))
dispatch(sendMessageThunk({ sessionId: id, prompt: msg.prompt, mode, model, provider, images: msg.images, contextPaths: msg.contextPaths, forcedTools: msg.forcedTools, attachedSkills: msg.attachedSkills, selectedBrowserIds: msg.selectedBrowserIds }))
.then((action) => {
if (sendMessageThunk.rejected.match(action)) {
setAwaitingResponse(false);
}
});
}
}, [id, isDraft, mode, model, session?.system_prompt, session?.target_directory, dispatch]);
}, [id, isDraft, mode, model, provider, session?.system_prompt, session?.target_directory, dispatch]);
const agentBusy = awaitingResponse || (!isDraft && (session?.status === 'running' || session?.status === 'waiting_approval'));
@@ -304,6 +311,11 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
if (id && !isDraft) dispatch(updateSessionMode({ sessionId: id, mode: newMode }));
}, [id, isDraft, dispatch]);
const handleProviderChange = useCallback((newProvider: string) => {
setProvider(newProvider);
if (id && !isDraft) dispatch(updateSessionProvider({ sessionId: id, provider: newProvider }));
}, [id, isDraft, dispatch]);
const handleModelChange = useCallback((newModel: string) => {
setModel(newModel);
if (id && !isDraft) dispatch(updateSessionModel({ sessionId: id, model: newModel }));
@@ -330,9 +342,10 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
prompt: "Continue where you left off. Start you're response EXACTLY with 'Sorry, let me pick up where I left off",
mode,
model,
provider,
hidden: true,
}));
}, [id, mode, model, dispatch]);
}, [id, mode, model, provider, dispatch]);
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
@@ -427,7 +440,12 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
}, [id, dispatch, onBranch, session?.dashboard_id]);
const contextEstimate = useMemo(() => {
const limit = CONTEXT_WINDOWS[model] || 200_000;
// Look up context window from dynamic models first, then fall back to defaults
let limit = CONTEXT_WINDOWS_DEFAULT[model] || 200_000;
for (const models of Object.values(modelsByProvider)) {
const found = models.find((m: any) => m.value === model);
if (found?.context_window) { limit = found.context_window; break; }
}
let totalChars = 0;
if (session?.system_prompt) totalChars += session.system_prompt.length;
for (const msg of activeBranchMessages) {
@@ -438,7 +456,7 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
}
const used = Math.round(totalChars / 4);
return { used, limit };
}, [activeBranchMessages, session?.system_prompt, session?.streamingMessage?.content, model]);
}, [activeBranchMessages, session?.system_prompt, session?.streamingMessage?.content, model, modelsByProvider]);
const sessionRunning = session?.status === 'running' || session?.status === 'waiting_approval';
@@ -1116,6 +1134,8 @@ const AgentChat: React.FC<AgentChatProps> = ({ sessionId: sessionIdProp, onClose
onModeChange={handleModeChange}
model={model}
onModelChange={handleModelChange}
provider={provider}
onProviderChange={handleProviderChange}
isRunning={agentBusy}
onStop={handleStop}
queueLength={queueLength}
+67 -22
View File
@@ -67,6 +67,8 @@ interface Props {
onModeChange: (mode: string) => void;
model: string;
onModelChange: (model: string) => void;
provider?: string;
onProviderChange?: (provider: string) => void;
isRunning?: boolean;
onStop?: () => void;
autoRunMode?: boolean;
@@ -92,10 +94,10 @@ const ICON_MAP: Record<string, React.ReactNode> = {
const FALLBACK_MODE_BASE = { label: 'Agent', icon: ICON_MAP.smart_toy };
const MODEL_OPTIONS = [
{ value: 'sonnet', label: 'Sonnet', version: '4.6' },
{ value: 'opus', label: 'Opus', version: '4.6' },
{ value: 'haiku', label: 'Haiku', version: '3.5' },
const FALLBACK_MODELS = [
{ value: 'sonnet', label: 'Claude Sonnet 4.6', context_window: 1_000_000 },
{ value: 'opus', label: 'Claude Opus 4.6', context_window: 1_000_000 },
{ value: 'haiku', label: 'Claude Haiku 4.5', context_window: 200_000 },
];
function formatTokenCount(n: number): string {
@@ -132,7 +134,7 @@ const ContextRing: React.FC<{ used: number; limit: number; accentColor: string;
);
};
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0 }, ref) => {
const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode, onModeChange, model, onModelChange, provider, onProviderChange, isRunning, onStop, autoRunMode, contextEstimate, embedded, autoFocus, sessionId, queueLength = 0 }, ref) => {
const c = useClaudeTokens();
const editorRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -158,6 +160,24 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
const skills = useAppSelector((state) => state.skills.items);
const modesMap = useAppSelector((state) => state.modes.items);
const modesArr = useMemo(() => Object.values(modesMap), [modesMap]);
const modelsByProvider = useAppSelector((state) => state.models.byProvider);
const modelsLoaded = useAppSelector((state) => state.models.loaded);
// Build flat model list with provider grouping
const allModelOptions = useMemo(() => {
if (!modelsLoaded || Object.keys(modelsByProvider).length === 0) {
return { flat: FALLBACK_MODELS.map(m => ({ ...m, provider: 'Anthropic' })), grouped: { Anthropic: FALLBACK_MODELS } };
}
const flat: Array<{ value: string; label: string; context_window: number; provider: string }> = [];
const grouped: Record<string, Array<{ value: string; label: string; context_window: number }>> = {};
for (const [prov, models] of Object.entries(modelsByProvider)) {
grouped[prov] = models.map(m => ({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000 }));
for (const m of models) {
flat.push({ value: m.value, label: m.label, context_window: m.context_window ?? 200_000, provider: prov });
}
}
return { flat, grouped };
}, [modelsByProvider, modelsLoaded]);
useEffect(() => {
if (modesArr.length === 0) dispatch(fetchModes());
@@ -566,7 +586,8 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
borderRadius: '10px',
minWidth: 140,
minWidth: 180,
maxHeight: 400,
boxShadow: c.shadow.lg,
'& .MuiMenuItem-root': {
fontSize: '0.8rem',
@@ -979,7 +1000,7 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
}}
>
<Typography sx={{ fontSize: '0.75rem', fontWeight: 500, color: 'inherit', lineHeight: 1 }}>
{(() => { const m = MODEL_OPTIONS.find((m) => m.value === model); return m ? `${m.label} ${m.version}` : model; })()}
{(() => { const m = allModelOptions.flat.find((m) => m.value === model); return m ? m.label : model; })()}
</Typography>
<KeyboardArrowDownIcon sx={{ fontSize: 14, color: 'inherit', opacity: 0.7 }} />
</Box>
@@ -992,21 +1013,45 @@ const ChatInput = forwardRef<ChatInputHandle, Props>(({ onSend, disabled, mode,
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
slotProps={{ paper: menuPaperProps }}
>
{MODEL_OPTIONS.map((opt) => (
<MenuItem
key={opt.value}
selected={model === opt.value}
onClick={() => {
onModelChange(opt.value);
setModelAnchor(null);
}}
>
<ListItemText
primary={`${opt.label} ${opt.version}`}
slotProps={{ primary: { sx: { fontSize: '0.8rem', color: model === opt.value ? c.text.primary : c.text.muted } } }}
/>
</MenuItem>
))}
{Object.entries(allModelOptions.grouped).map(([prov, models]) => [
<MenuItem key={`header-${prov}`} disabled sx={{ opacity: '0.7 !important', py: 0.5, px: 1.5, minHeight: 'auto' }}>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 700, letterSpacing: '0.06em', textTransform: 'uppercase', color: c.text.tertiary }}>
{prov}
</Typography>
</MenuItem>,
...models.map((opt) => (
<MenuItem
key={opt.value}
selected={model === opt.value}
onClick={() => {
onModelChange(opt.value);
if (onProviderChange) {
// Derive API-level provider key from the display group name
const provLower = prov.toLowerCase();
const providerMap: Record<string, string> = {
anthropic: 'anthropic',
openai: 'openai',
google: 'gemini',
// OpenRouter-backed providers
xai: 'openrouter',
meta: 'openrouter',
deepseek: 'openrouter',
mistral: 'openrouter',
qwen: 'openrouter',
cohere: 'openrouter',
};
onProviderChange(providerMap[provLower] || provLower);
}
setModelAnchor(null);
}}
>
<ListItemText
primary={opt.label}
slotProps={{ primary: { sx: { fontSize: '0.8rem', color: model === opt.value ? c.text.primary : c.text.muted } } }}
/>
</MenuItem>
)),
]).flat()}
</Menu>
<Box sx={{ flex: 1 }} />
@@ -0,0 +1,63 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Paper from '@mui/material/Paper';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const Analytics: React.FC = () => {
const c = useClaudeTokens();
return (
<Box sx={{ height: '100%', overflow: 'auto', p: 3 }}>
<Box sx={{ maxWidth: 800, mx: 'auto' }}>
<Typography variant="h5" sx={{ color: c.text.primary, fontWeight: 600, mb: 3 }}>
Analytics
</Typography>
<Paper sx={{
p: 4,
bgcolor: c.bg.surface,
border: `1px solid ${c.border.subtle}`,
textAlign: 'center',
}}>
<Box sx={{ mb: 2 }}>
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke={c.accent.primary} strokeWidth="1.5">
<path d="M3 3v18h18" />
<path d="M7 16l4-4 4 4 5-5" />
<circle cx="20" cy="7" r="1.5" fill={c.accent.primary} />
</svg>
</Box>
<Typography sx={{ color: c.text.primary, fontSize: '1.1rem', fontWeight: 600, mb: 1 }}>
Analytics powered by PostHog
</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.85rem', lineHeight: 1.6, mb: 3, maxWidth: 500, mx: 'auto' }}>
Usage data is automatically collected sessions, costs, tool usage, model distribution, and task categories.
All data is anonymous and can be disabled in Settings.
</Typography>
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 2, mt: 3, textAlign: 'left' }}>
{[
{ label: 'Sessions & Usage', desc: 'How often agents are launched, session duration, completion rates' },
{ label: 'Cost Tracking', desc: 'Spend by model, provider, and time period' },
{ label: 'Task Categories', desc: 'What users do — coding, email, research, social, browsing' },
{ label: 'Model Distribution', desc: 'Which models and providers are most popular' },
{ label: 'Tool Usage', desc: 'Most used MCP tools, execution times, approval rates' },
{ label: 'Retention & Funnels', desc: 'User engagement, feature adoption, onboarding flow' },
].map((item) => (
<Box key={item.label} sx={{ p: 2, borderRadius: `${c.radius.md}px`, bgcolor: c.bg.elevated }}>
<Typography sx={{ color: c.text.primary, fontSize: '0.82rem', fontWeight: 600, mb: 0.5 }}>
{item.label}
</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.72rem', lineHeight: 1.4 }}>
{item.desc}
</Typography>
</Box>
))}
</Box>
</Paper>
</Box>
</Box>
);
};
export default Analytics;
@@ -0,0 +1,406 @@
import React, { useRef, useEffect, useCallback } from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import { useClaudeTokens } from '@/shared/styles/ThemeContext';
const PALETTES = {
salmon: ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E'],
blue: ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD'],
coral: ['#993344', '#AA3D4E', '#BB4455', '#CC5566', '#DD6677'],
green: ['#447755', '#558866', '#669977', '#77AA88', '#88BB99'],
purple: ['#665588', '#7766AA', '#8877BB', '#9988CC', '#AA99DD'],
} as const;
type PaletteKey = keyof typeof PALETTES;
interface PixelChartProps {
data: { label: string; value: number }[];
palette?: PaletteKey;
height?: number;
pixelSize?: number;
formatValue?: (v: number) => string;
glow?: boolean;
showXLabels?: boolean;
showYScale?: boolean;
mode?: 'bar' | 'area'; // 'area' draws a filled line chart instead of bars
}
const PixelChart: React.FC<PixelChartProps> = ({
data,
palette = 'salmon',
height = 140,
pixelSize = 6,
formatValue,
glow = true,
showXLabels = true,
showYScale = true,
mode = 'bar',
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const animRef = useRef(0);
const progressRef = useRef(0);
const hoverIdxRef = useRef(-1);
const tooltipRef = useRef<HTMLDivElement>(null);
const c = useClaudeTokens();
const colors = PALETTES[palette];
const maxVal = Math.max(...data.map((d) => d.value), 0.001);
// Compute nice Y-axis ticks
const yTicks = (() => {
if (maxVal <= 0) return [0];
const rawStep = maxVal / 3;
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
const normalised = rawStep / magnitude;
let niceStep: number;
if (normalised <= 1) niceStep = magnitude;
else if (normalised <= 2) niceStep = 2 * magnitude;
else if (normalised <= 5) niceStep = 5 * magnitude;
else niceStep = 10 * magnitude;
const ticks: number[] = [];
for (let v = 0; v <= maxVal * 1.1; v += niceStep) {
ticks.push(v);
}
if (ticks.length < 2) ticks.push(niceStep);
return ticks;
})();
// X-axis labels: show first, last, and up to 3 evenly spaced
const xLabels = (() => {
if (data.length <= 1) return data.map((d, i) => ({ idx: i, label: d.label }));
if (data.length <= 5) return data.map((d, i) => ({ idx: i, label: d.label }));
const result: { idx: number; label: string }[] = [];
result.push({ idx: 0, label: data[0].label });
const step = Math.floor(data.length / 4);
for (let i = 1; i <= 3; i++) {
const idx = Math.min(i * step, data.length - 2);
if (idx > 0 && idx < data.length - 1) {
result.push({ idx, label: data[idx].label });
}
}
result.push({ idx: data.length - 1, label: data[data.length - 1].label });
return result;
})();
const Y_LABEL_WIDTH = showYScale ? 80 : 0;
const draw = useCallback(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
if (!canvas || !container || data.length === 0) return;
const dpr = window.devicePixelRatio || 1;
const totalW = container.clientWidth;
const chartW = totalW - Y_LABEL_WIDTH;
const h = height;
canvas.width = totalW * dpr;
canvas.height = h * dpr;
canvas.style.width = `${totalW}px`;
canvas.style.height = `${h}px`;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
const px = pixelSize;
const gridCols = Math.floor(chartW / px);
const gridRows = Math.floor(h / px);
const effectiveMax = yTicks[yTicks.length - 1] || maxVal;
ctx.clearRect(0, 0, totalW, h);
// Y-axis labels and horizontal grid lines
if (showYScale) {
ctx.font = '10px monospace';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (const tick of yTicks) {
const yNorm = effectiveMax > 0 ? tick / effectiveMax : 0;
const yPx = h - yNorm * (h - px);
// Grid line
ctx.strokeStyle = c.border.subtle;
ctx.lineWidth = 0.5;
ctx.setLineDash([2, 4]);
ctx.beginPath();
ctx.moveTo(Y_LABEL_WIDTH, yPx);
ctx.lineTo(totalW, yPx);
ctx.stroke();
ctx.setLineDash([]);
// Label
const label = formatValue ? formatValue(tick) : (tick % 1 === 0 ? String(tick) : tick.toFixed(1));
ctx.fillStyle = c.text.ghost;
ctx.fillText(label, Y_LABEL_WIDTH - 8, yPx);
}
}
// Subtle grid dots in chart area
ctx.fillStyle = c.border.subtle;
for (let gy = 0; gy < gridRows; gy += 5) {
for (let gx = 0; gx < gridCols; gx += 5) {
ctx.fillRect(Y_LABEL_WIDTH + gx * px, gy * px, 1, 1);
}
}
const progress = Math.min(progressRef.current, 1);
const hoverIdx = hoverIdxRef.current;
if (mode === 'area') {
// ── Area / line chart mode ──
// Draw a smooth filled area under a line
const usableH = h - px * 2;
const points: { x: number; y: number }[] = [];
for (let i = 0; i < data.length; i++) {
const val = data[i].value;
const norm = effectiveMax > 0 ? val / effectiveMax : 0;
const x = Y_LABEL_WIDTH + (i / Math.max(data.length - 1, 1)) * chartW;
const y = h - px - norm * usableH * progress;
points.push({ x, y });
}
if (points.length > 0) {
// Filled area with gradient
const gradient = ctx.createLinearGradient(0, 0, 0, h);
gradient.addColorStop(0, colors[colors.length - 1] + '60');
gradient.addColorStop(0.5, colors[Math.floor(colors.length / 2)] + '30');
gradient.addColorStop(1, colors[0] + '08');
ctx.beginPath();
ctx.moveTo(points[0].x, h);
// Smooth curve through points
for (let i = 0; i < points.length; i++) {
if (i === 0) {
ctx.lineTo(points[i].x, points[i].y);
} else {
// Cubic bezier for smoothing
const prev = points[i - 1];
const curr = points[i];
const cpx = (prev.x + curr.x) / 2;
ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y);
}
}
ctx.lineTo(points[points.length - 1].x, h);
ctx.closePath();
ctx.fillStyle = gradient;
ctx.fill();
// Line on top
ctx.beginPath();
for (let i = 0; i < points.length; i++) {
if (i === 0) {
ctx.moveTo(points[i].x, points[i].y);
} else {
const prev = points[i - 1];
const curr = points[i];
const cpx = (prev.x + curr.x) / 2;
ctx.bezierCurveTo(cpx, prev.y, cpx, curr.y, curr.x, curr.y);
}
}
ctx.strokeStyle = colors[colors.length - 1];
ctx.lineWidth = 2;
ctx.stroke();
// Glow on line
if (glow) {
ctx.shadowColor = colors[colors.length - 1];
ctx.shadowBlur = 8;
ctx.stroke();
ctx.shadowBlur = 0;
}
// Data point dots
for (let i = 0; i < points.length; i++) {
if (data[i].value > 0) {
const isHov = i === hoverIdx;
ctx.beginPath();
ctx.arc(points[i].x, points[i].y, isHov ? 4 : 2.5, 0, Math.PI * 2);
ctx.fillStyle = isHov ? colors[colors.length - 1] : colors[Math.floor(colors.length / 2)];
ctx.fill();
if (isHov) {
ctx.strokeStyle = colors[colors.length - 1];
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
}
// Pixel scatter in the filled area for the pixel art feel
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i];
const p2 = points[i + 1];
const steps = Math.ceil((p2.x - p1.x) / px);
for (let s = 0; s < steps; s++) {
const t = s / steps;
const x = p1.x + t * (p2.x - p1.x);
const lineY = p1.y + t * (p2.y - p1.y);
// Scatter pixels below the line
for (let py = lineY + px * 2; py < h - px; py += px * 2) {
if (Math.random() > 0.65) {
const depth = (py - lineY) / (h - lineY);
const ci = Math.max(0, Math.floor((1 - depth) * (colors.length - 1)));
ctx.globalAlpha = 0.15 + (1 - depth) * 0.2;
ctx.fillStyle = colors[ci];
ctx.fillRect(Math.floor(x / px) * px, Math.floor(py / px) * px, px - 1, px - 1);
}
}
}
}
ctx.globalAlpha = 1;
}
} else {
// ── Bar chart mode (original) ──
const barSlots = data.length;
const totalBarPx = Math.max(1, Math.floor(gridCols / barSlots));
const barW = Math.max(1, totalBarPx - 1);
for (let i = 0; i < data.length; i++) {
const val = data[i].value;
const normalised = effectiveMax > 0 ? val / effectiveMax : 0;
const usableRows = gridRows - 2;
const targetH = Math.max(normalised > 0 ? 1 : 0, Math.round(normalised * usableRows));
const barH = Math.round(targetH * progress);
const barX = i * totalBarPx;
const isHovered = i === hoverIdx;
for (let row = 0; row < barH; row++) {
const y = gridRows - 1 - row;
const colorIdx = Math.min(colors.length - 1, Math.floor((row / Math.max(barH - 1, 1)) * (colors.length - 1)));
const baseColor = isHovered ? colors[Math.min(colorIdx + 1, colors.length - 1)] : colors[colorIdx];
for (let col = 0; col < barW; col++) {
ctx.fillStyle = baseColor;
ctx.fillRect(Y_LABEL_WIDTH + (barX + col) * px, y * px, px - 1, px - 1);
}
}
if (glow && barH > 0) {
const topY = (gridRows - 1 - barH + 1) * px;
ctx.shadowColor = colors[colors.length - 1];
ctx.shadowBlur = 6;
ctx.fillStyle = colors[colors.length - 1];
for (let col = 0; col < barW; col++) {
ctx.fillRect(Y_LABEL_WIDTH + (barX + col) * px, topY, px - 1, px - 1);
}
ctx.shadowBlur = 0;
}
}
}
}, [data, height, pixelSize, c, colors, glow, maxVal, yTicks, showYScale, Y_LABEL_WIDTH, formatValue, mode]);
useEffect(() => {
progressRef.current = 0;
let start: number | null = null;
const animate = (ts: number) => {
if (!start) start = ts;
progressRef.current = Math.min(1, (ts - start) / 600);
draw();
if (progressRef.current < 1) animRef.current = requestAnimationFrame(animate);
};
animRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animRef.current);
}, [data, draw]);
useEffect(() => {
const handleResize = () => draw();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [draw]);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
const canvas = canvasRef.current;
const tooltip = tooltipRef.current;
if (!canvas || !tooltip || data.length === 0) return;
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left - Y_LABEL_WIDTH;
if (mx < 0) { hoverIdxRef.current = -1; tooltip.style.opacity = '0'; draw(); return; }
const chartW = rect.width - Y_LABEL_WIDTH;
const gridCols = Math.floor(chartW / pixelSize);
const totalBarPx = Math.max(1, Math.floor(gridCols / data.length));
const idx = Math.floor(mx / (totalBarPx * pixelSize));
if (idx >= 0 && idx < data.length) {
hoverIdxRef.current = idx;
const d = data[idx];
const valStr = formatValue ? formatValue(d.value) : d.value.toFixed(2);
tooltip.textContent = `${d.label}: ${valStr}`;
tooltip.style.opacity = '1';
tooltip.style.left = `${e.clientX - rect.left}px`;
tooltip.style.top = `${e.clientY - rect.top - 28}px`;
} else {
hoverIdxRef.current = -1;
tooltip.style.opacity = '0';
}
draw();
},
[data, pixelSize, draw, formatValue, Y_LABEL_WIDTH],
);
const handleMouseLeave = useCallback(() => {
hoverIdxRef.current = -1;
if (tooltipRef.current) tooltipRef.current.style.opacity = '0';
draw();
}, [draw]);
return (
<Box ref={containerRef} sx={{ position: 'relative', width: '100%' }}>
<canvas
ref={canvasRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
style={{ display: 'block', width: '100%', imageRendering: 'pixelated', cursor: 'crosshair' }}
/>
{/* X-axis labels */}
{showXLabels && data.length > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 0.5, pl: `${Y_LABEL_WIDTH}px` }}>
{xLabels.map((xl) => (
<Typography
key={xl.idx}
sx={{
color: c.text.ghost,
fontSize: '0.58rem',
fontFamily: c.font.mono,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: 60,
}}
>
{xl.label}
</Typography>
))}
</Box>
)}
{/* Tooltip */}
<Box
ref={tooltipRef}
sx={{
position: 'absolute',
pointerEvents: 'none',
opacity: 0,
transition: 'opacity 0.12s',
bgcolor: c.bg.inverse,
color: c.text.inverse,
fontSize: '0.7rem',
fontFamily: c.font.mono,
fontWeight: 500,
px: 1,
py: 0.35,
borderRadius: 0.75,
whiteSpace: 'nowrap',
transform: 'translateX(-50%)',
zIndex: 10,
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
}}
/>
</Box>
);
};
export default PixelChart;
@@ -95,6 +95,7 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
const defaultModel = useAppSelector((s) => s.settings.data.default_model);
const [mode, setMode] = useState(defaultMode || 'agent');
const [model, setModel] = useState(defaultModel || 'sonnet');
const [provider, setProvider] = useState('anthropic');
const settingsApplied = useRef(false);
useEffect(() => {
if (!settingsApplied.current) {
@@ -365,6 +366,8 @@ const DashboardToolbar = React.forwardRef<HTMLDivElement, Props>(
onModeChange={setMode}
model={model}
onModelChange={setModel}
provider={provider}
onProviderChange={setProvider}
embedded
autoFocus
sessionId={TOOLBAR_OWNER_ID}
+347 -120
View File
@@ -40,11 +40,203 @@ import Collapse from '@mui/material/Collapse';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { updateSettings, closeSettingsModal, resetSystemPrompt, AppSettings, DEFAULT_SYSTEM_PROMPT } from '@/shared/state/settingsSlice';
import { fetchModels } from '@/shared/state/modelsSlice';
import { setChecking, setUpdateError } from '@/shared/state/updateSlice';
import { fetchModes } from '@/shared/state/modesSlice';
import { useClaudeTokens, useThemeMode } from '@/shared/styles/ThemeContext';
import DirectoryBrowser from '@/app/components/DirectoryBrowser';
import { CommandsContent } from '@/app/pages/Commands/Commands';
import { API_BASE } from '@/shared/config';
// ── Pixel Bar ──
const PIXEL_SALMON = ['#C46B57', '#D4795F', '#E8927A', '#F0A088', '#F5B49E'];
const PIXEL_BLUE = ['#445588', '#5577AA', '#6688BB', '#7799CC', '#88AADD'];
const PixelBarOuter: React.FC<{ value: number; max: number; width?: number; palette?: string[]; tokens: any }> = ({ value, max, width = 16, palette = PIXEL_SALMON, tokens: c }) => {
const filled = max > 0 ? Math.max(value > 0 ? 1 : 0, Math.round((value / max) * width)) : 0;
return (
<Box sx={{ display: 'flex', gap: '1px', mt: 0.25 }}>
{Array.from({ length: width }, (_, i) => (
<Box
key={i}
sx={{
width: 5,
height: 5,
bgcolor: i < filled
? palette[Math.min(palette.length - 1, Math.floor((i / Math.max(filled - 1, 1)) * (palette.length - 1)))]
: c.border.subtle,
opacity: i < filled ? 1 : 0.3,
}}
/>
))}
</Box>
);
};
// ── Usage Stats Component ──
const UsageStats: React.FC = () => {
const c = useClaudeTokens();
const [stats, setStats] = useState<any>(null);
useEffect(() => {
fetch(`${API_BASE}/analytics/usage-summary`)
.then(r => r.json())
.then(setStats)
.catch(() => {});
}, []);
if (!stats) return null;
const formatCost = (v: number) => {
if (v === 0) return '$0.00';
if (v < 0.001) return `$${v.toFixed(6)}`;
if (v < 0.01) return `$${v.toFixed(5)}`;
if (v < 1) return `$${v.toFixed(4)}`;
return `$${v.toFixed(2)}`;
};
const formatDuration = (s: number) => {
if (s === 0) return '0s';
if (s < 60) return `${s.toFixed(1)}s`;
if (s < 3600) return `${Math.floor(s / 60)}m ${Math.round(s % 60)}s`;
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
};
const formatTotalTime = (s: number) => {
if (s < 60) return `${s.toFixed(1)}s`;
if (s < 3600) return `${(s / 60).toFixed(1)} min`;
return `${(s / 3600).toFixed(1)} hrs`;
};
const cardSx = {
p: 1.5,
borderRadius: `${c.radius.md}px`,
bgcolor: c.bg.elevated,
border: `1px solid ${c.border.subtle}`,
};
const labelSx = { fontSize: '0.58rem', fontWeight: 700, color: c.text.ghost, textTransform: 'uppercase' as const, letterSpacing: '0.06em', mb: 0.25 };
const valueSx = { fontSize: '1.05rem', fontWeight: 700, color: c.text.primary, lineHeight: 1.2 };
const subSx = { fontSize: '0.62rem', color: c.text.tertiary, mt: 0.25 };
const modelEntries = Object.entries(stats.models_used || {}).sort((a: any, b: any) => b[1] - a[1]) as [string, number][];
const providerEntries = Object.entries(stats.providers_used || {}).sort((a: any, b: any) => b[1] - a[1]) as [string, number][];
const toolEntries = Object.entries(stats.top_tools || {}).slice(0, 10) as [string, number][];
const maxToolCount = toolEntries.length > 0 ? Math.max(...toolEntries.map(([, c]) => c)) : 1;
const statusEntries = Object.entries(stats.status_breakdown || {}) as [string, string][];
// Pixel bar helper that passes tokens
const PixelBar: React.FC<{ value: number; max: number; width?: number; palette?: string[] }> = (props) => (
<PixelBarOuter {...props} tokens={c} />
);
const totalTime = stats.avg_duration_seconds * stats.total_sessions;
const msgsPerSession = stats.total_sessions > 0 ? (stats.total_messages / stats.total_sessions).toFixed(1) : '0';
const toolsPerSession = stats.total_sessions > 0 ? (stats.total_tool_calls / stats.total_sessions).toFixed(1) : '0';
return (
<Box sx={{ mb: 2.5 }}>
{/* Row 1: Core metrics */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1 }}>
<Box sx={cardSx}>
<Typography sx={labelSx}>Total Sessions</Typography>
<Typography sx={valueSx}>{stats.total_sessions.toLocaleString()}</Typography>
<Typography sx={subSx}>
{statusEntries.map(([s, n]) => `${n} ${s}`).join(', ') || 'no sessions'}
</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>Total Cost</Typography>
<Typography sx={valueSx}>{formatCost(stats.total_cost_usd)}</Typography>
<Typography sx={subSx}>
{formatCost(stats.avg_cost_per_session)} avg per session
</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>Total Messages</Typography>
<Typography sx={valueSx}>{stats.total_messages.toLocaleString()}</Typography>
<Typography sx={subSx}>
{msgsPerSession} avg per session
</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>Total Tool Calls</Typography>
<Typography sx={valueSx}>{stats.total_tool_calls.toLocaleString()}</Typography>
<Typography sx={subSx}>
{toolsPerSession} avg per session
</Typography>
</Box>
</Box>
{/* Row 2: Time + efficiency */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 1, mb: 1.5 }}>
<Box sx={cardSx}>
<Typography sx={labelSx}>Total Run Time</Typography>
<Typography sx={valueSx}>{formatTotalTime(totalTime)}</Typography>
<Typography sx={subSx}>across all sessions</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>Avg Session</Typography>
<Typography sx={valueSx}>{formatDuration(stats.avg_duration_seconds)}</Typography>
<Typography sx={subSx}>per session duration</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>Completion Rate</Typography>
<Typography sx={valueSx}>{(stats.completion_rate * 100).toFixed(1)}%</Typography>
<Typography sx={subSx}>
sessions finished successfully
</Typography>
</Box>
<Box sx={cardSx}>
<Typography sx={labelSx}>Providers</Typography>
<Typography sx={valueSx}>{Object.keys(stats.providers_used || {}).length}</Typography>
<Typography sx={subSx}>
{providerEntries.map(([p]) => p).join(', ') || 'none configured'}
</Typography>
</Box>
</Box>
{/* Model + Provider + Tool breakdown */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 1.5 }}>
{/* Models & Providers */}
<Box sx={{ ...cardSx, p: 2 }}>
<Typography sx={{ ...labelSx, mb: 1.5 }}>Models Used</Typography>
{modelEntries.length > 0 ? modelEntries.map(([model, count]) => {
const pct = stats.total_sessions > 0 ? ((count / stats.total_sessions) * 100).toFixed(0) : '0';
return (
<Box key={model} sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0 }}>
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, fontWeight: 500 }}>{model}</Typography>
<Typography sx={{ fontSize: '0.68rem', color: c.text.tertiary, fontFamily: c.font.mono }}>
{count} ({pct}%)
</Typography>
</Box>
<PixelBar value={count} max={stats.total_sessions} palette={PIXEL_BLUE} />
</Box>
);
}) : <Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>No sessions yet</Typography>}
</Box>
{/* Tools */}
<Box sx={{ ...cardSx, p: 2 }}>
<Typography sx={{ ...labelSx, mb: 1.5 }}>Top Tools</Typography>
{toolEntries.length > 0 ? toolEntries.map(([tool, count]) => {
const shortName = tool.includes('__') ? tool.split('__').pop() : tool;
const pct = stats.total_tool_calls > 0 ? ((count / stats.total_tool_calls) * 100).toFixed(0) : '0';
return (
<Box key={tool} sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', mb: 0 }}>
<Typography sx={{ fontSize: '0.72rem', color: c.text.muted, fontWeight: 500 }}>{shortName}</Typography>
<Typography sx={{ fontSize: '0.62rem', color: c.text.tertiary, fontFamily: c.font.mono }}>
{count} call{count !== 1 ? 's' : ''} ({pct}%)
</Typography>
</Box>
<PixelBar value={count} max={maxToolCount} />
</Box>
);
}) : <Typography sx={{ fontSize: '0.75rem', color: c.text.ghost }}>No tool calls yet</Typography>}
</Box>
</Box>
</Box>
);
};
const API_KEY_STEPS = [
{
@@ -87,7 +279,7 @@ const Settings: React.FC = () => {
const downloadPercent = useAppSelector((s) => s.update.downloadPercent);
const updateError = useAppSelector((s) => s.update.error);
const [activeTab, setActiveTab] = useState<'general' | 'commands'>('general');
const [activeTab, setActiveTab] = useState<'general' | 'models' | 'usage' | 'commands'>('general');
const [form, setForm] = useState<AppSettings>({ ...settings });
const [showApiKey, setShowApiKey] = useState(false);
const [browseOpen, setBrowseOpen] = useState(false);
@@ -143,6 +335,7 @@ const Settings: React.FC = () => {
if (form.theme !== settings.theme) {
setThemeMode(form.theme);
}
dispatch(fetchModels());
setSaved(true);
};
@@ -165,6 +358,7 @@ const Settings: React.FC = () => {
if (form.theme !== settings.theme) {
setThemeMode(form.theme);
}
dispatch(fetchModels());
setSaved(true);
setConfirmDiscard(false);
dispatch(closeSettingsModal());
@@ -272,6 +466,8 @@ const Settings: React.FC = () => {
}}
>
<Tab label="General" value="general" disableRipple />
<Tab label="Models" value="models" disableRipple />
<Tab label="Usage" value="usage" disableRipple />
<Tab label="Commands" value="commands" disableRipple />
</Tabs>
</DialogTitle>
@@ -626,125 +822,6 @@ const Settings: React.FC = () => {
</Box>
</Box>
{/* ── API ── */}
<Typography sx={{ ...sectionSx, mt: 3 }}>API</Typography>
<Box sx={rowLastSx}>
<Typography sx={labelSx}>Anthropic API key</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography sx={descSx}>
Stored securely in the local database.
</Typography>
<Typography
component="span"
onClick={() => setShowApiHelp((v) => !v)}
sx={{
color: c.accent.primary,
fontSize: '0.75rem',
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 0.4,
whiteSpace: 'nowrap',
userSelect: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
{showApiHelp ? 'Hide guide' : 'How do I get a key?'}
</Typography>
</Box>
<Collapse in={showApiHelp} timeout={250}>
<Box sx={{
mb: 1.5,
p: 2,
borderRadius: `${c.radius.md}px`,
bgcolor: `${c.accent.primary}08`,
border: `1px solid ${c.accent.primary}20`,
}}>
{API_KEY_STEPS.map((step, i) => (
<Box key={i} sx={{ display: 'flex', gap: 1.5, mb: i < API_KEY_STEPS.length - 1 ? 1.5 : 0 }}>
<Box sx={{
width: 22,
height: 22,
borderRadius: '50%',
bgcolor: `${c.accent.primary}15`,
color: c.accent.primary,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.7rem',
fontWeight: 700,
flexShrink: 0,
mt: 0.1,
}}>
{i + 1}
</Box>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ color: c.text.primary, fontSize: '0.8rem', fontWeight: 500, lineHeight: 1.4 }}>
{step.title}
{step.link && (
<Typography
component="a"
href={step.link}
sx={{
color: c.accent.primary,
fontSize: '0.75rem',
ml: 0.75,
cursor: 'pointer',
display: 'inline-flex',
alignItems: 'center',
gap: 0.3,
verticalAlign: 'middle',
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
}}
>
Open
<OpenInNewIcon sx={{ fontSize: 12 }} />
</Typography>
)}
</Typography>
<Typography sx={{ color: c.text.muted, fontSize: '0.75rem', lineHeight: 1.4 }}>
{step.detail}
</Typography>
</Box>
</Box>
))}
</Box>
</Collapse>
<TextField
type={showApiKey ? 'text' : 'password'}
value={form.anthropic_api_key ?? ''}
onChange={(e) => setForm({ ...form, anthropic_api_key: e.target.value || null })}
size="small"
fullWidth
placeholder="sk-ant-..."
sx={{
...fieldSx,
'& .MuiOutlinedInput-root': {
...fieldSx['& .MuiOutlinedInput-root'],
fontFamily: c.font.mono,
},
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowApiKey(!showApiKey)}
edge="end"
size="small"
sx={{ color: c.text.tertiary }}
>
{showApiKey ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
</IconButton>
</InputAdornment>
),
}}
/>
</Box>
{/* ── Advanced ── */}
<Typography sx={{ ...sectionSx, mt: 3 }}>Advanced</Typography>
@@ -873,6 +950,156 @@ const Settings: React.FC = () => {
</Box>
</Box>
) : activeTab === 'models' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1, gap: 2.5 }}>
<Typography sx={descSx}>
Connect your AI model providers. Each key is stored locally on your device.
</Typography>
{/* OpenRouter — recommended */}
<Box sx={{ p: 2, borderRadius: `${c.radius.md}px`, bgcolor: `${c.accent.primary}06`, border: `1px solid ${c.accent.primary}20` }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Typography sx={{ ...labelSx, mb: 0 }}>OpenRouter</Typography>
<Typography sx={{ fontSize: '0.65rem', fontWeight: 600, color: c.accent.primary, bgcolor: `${c.accent.primary}15`, px: 1, py: 0.25, borderRadius: '4px' }}>
RECOMMENDED
</Typography>
</Box>
<Typography sx={{ ...descSx, mb: 1 }}>
One key for 300+ models Llama, DeepSeek, Mistral, Qwen, Grok, and more. Easiest way to get started.
</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
type="password"
value={(form as any).openrouter_api_key ?? ''}
onChange={(e) => setForm({ ...form, openrouter_api_key: e.target.value || null } as any)}
size="small"
fullWidth
placeholder="sk-or-..."
sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
/>
<Typography
component="a"
href="https://openrouter.ai/keys"
target="_blank"
rel="noopener"
sx={{ color: c.accent.primary, fontSize: '0.72rem', whiteSpace: 'nowrap', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 0.3, '&:hover': { textDecoration: 'underline' } }}
>
Get key <OpenInNewIcon sx={{ fontSize: 11 }} />
</Typography>
</Box>
</Box>
{/* Anthropic */}
<Box>
<Typography sx={labelSx}>Anthropic</Typography>
<Typography sx={{ ...descSx, mb: 1 }}>Claude Sonnet, Opus, Haiku direct API access.</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
type={showApiKey ? 'text' : 'password'}
value={form.anthropic_api_key ?? ''}
onChange={(e) => setForm({ ...form, anthropic_api_key: e.target.value || null })}
size="small"
fullWidth
placeholder="sk-ant-..."
sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowApiKey(!showApiKey)} edge="end" size="small" sx={{ color: c.text.tertiary }}>
{showApiKey ? <VisibilityOffIcon sx={{ fontSize: 16 }} /> : <VisibilityIcon sx={{ fontSize: 16 }} />}
</IconButton>
</InputAdornment>
),
}}
/>
<Typography
component="a"
href="https://console.anthropic.com/settings/keys"
target="_blank"
rel="noopener"
sx={{ color: c.accent.primary, fontSize: '0.72rem', whiteSpace: 'nowrap', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 0.3, '&:hover': { textDecoration: 'underline' } }}
>
Get key <OpenInNewIcon sx={{ fontSize: 11 }} />
</Typography>
</Box>
</Box>
{/* OpenAI */}
<Box>
<Typography sx={labelSx}>OpenAI</Typography>
<Typography sx={{ ...descSx, mb: 1 }}>GPT-5.4, o3, o4-mini direct API access.</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
type="password"
value={(form as any).openai_api_key ?? ''}
onChange={(e) => setForm({ ...form, openai_api_key: e.target.value || null } as any)}
size="small"
fullWidth
placeholder="sk-..."
sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
/>
<Typography
component="a"
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener"
sx={{ color: c.accent.primary, fontSize: '0.72rem', whiteSpace: 'nowrap', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 0.3, '&:hover': { textDecoration: 'underline' } }}
>
Get key <OpenInNewIcon sx={{ fontSize: 11 }} />
</Typography>
</Box>
</Box>
{/* Google */}
<Box>
<Typography sx={labelSx}>Google</Typography>
<Typography sx={{ ...descSx, mb: 1 }}>Gemini 2.5 Pro and Flash direct API access.</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
type="password"
value={(form as any).google_api_key ?? ''}
onChange={(e) => setForm({ ...form, google_api_key: e.target.value || null } as any)}
size="small"
fullWidth
placeholder="AIza..."
sx={{ ...fieldSx, '& .MuiOutlinedInput-root': { ...fieldSx['& .MuiOutlinedInput-root'], fontFamily: c.font.mono } }}
/>
<Typography
component="a"
href="https://aistudio.google.com/apikey"
target="_blank"
rel="noopener"
sx={{ color: c.accent.primary, fontSize: '0.72rem', whiteSpace: 'nowrap', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 0.3, '&:hover': { textDecoration: 'underline' } }}
>
Get key <OpenInNewIcon sx={{ fontSize: 11 }} />
</Typography>
</Box>
</Box>
</Box>
) : activeTab === 'usage' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', pt: 2.5, pb: 1 }}>
<UsageStats />
{/* ── Analytics ── */}
<Typography sx={{ ...sectionSx, mt: 1 }}>Analytics</Typography>
<Box sx={inlineRowLastSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Share anonymous usage data</Typography>
<Typography sx={descSx}>
Help improve OpenSwarm by sharing anonymous statistics like session counts, model usage, and feature adoption. No conversations, file paths, or personal information is ever collected.
</Typography>
</Box>
<Switch
checked={(form as any).analytics_opt_in ?? true}
onChange={(e) => setForm({ ...form, analytics_opt_in: e.target.checked } as any)}
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: c.accent.primary },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { bgcolor: c.accent.primary },
}}
/>
</Box>
</Box>
) : (
<Box sx={{ pt: 2.5, pb: 1 }}>
<CommandsContent />
@@ -519,6 +519,7 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
const [autoRunEnabled, setAutoRunEnabled] = useState(savedAutoRun?.enabled ?? false);
const [autoRunMode, setAutoRunMode] = useState(savedAutoRun?.mode ?? 'agent');
const [autoRunModel, setAutoRunModel] = useState(savedAutoRun?.model ?? 'sonnet');
const [autoRunProvider, setAutoRunProvider] = useState('anthropic');
const [autoRunning, setAutoRunning] = useState(false);
const autoRunInputRef = useRef<ChatInputHandle>(null);
const autoRunInitialized = useRef(false);
@@ -1539,6 +1540,8 @@ const ViewEditor: React.FC<Props> = ({ output, onClose }) => {
onModeChange={setAutoRunMode}
model={autoRunModel}
onModelChange={setAutoRunModel}
provider={autoRunProvider}
onProviderChange={setAutoRunProvider}
/>
<Button
variant="contained"
+1
View File
@@ -3,3 +3,4 @@ const host = window.location.hostname || 'localhost';
export const API_BASE = `http://${host}:${port}/api`;
export const WS_BASE = `ws://${host}:${port}`;
export const OPENSWARM_DEFAULT_PROXY_URL = 'https://api.openswarm.ai';
+17 -4
View File
@@ -50,6 +50,7 @@ export interface AgentSession {
id: string;
name: string;
status: 'draft' | 'running' | 'waiting_approval' | 'completed' | 'error' | 'stopped';
provider: string;
model: string;
mode: string;
worktree_path: string | null;
@@ -75,6 +76,7 @@ export interface AgentSession {
export interface AgentConfig {
name?: string;
provider?: string;
model?: string;
mode?: string;
system_prompt?: string;
@@ -151,6 +153,7 @@ export interface SendMessagePayload {
prompt: string;
mode?: string;
model?: string;
provider?: string;
images?: Array<{ data: string; media_type: string }>;
contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>;
forcedTools?: string[];
@@ -161,11 +164,11 @@ export interface SendMessagePayload {
export const sendMessage = createAsyncThunk(
'agents/sendMessage',
async ({ sessionId, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills, hidden, selectedBrowserIds }: SendMessagePayload) => {
async ({ sessionId, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, hidden, selectedBrowserIds }: SendMessagePayload) => {
await fetch(`${AGENTS_API}/sessions/${sessionId}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, mode, model, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, hidden, selected_browser_ids: selectedBrowserIds }),
body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, hidden, selected_browser_ids: selectedBrowserIds }),
});
return { sessionId, prompt };
}
@@ -213,6 +216,7 @@ export interface LaunchAndSendPayload {
prompt: string;
mode: string;
model: string;
provider?: string;
images?: Array<{ data: string; media_type: string }>;
contextPaths?: Array<{ path: string; type: 'file' | 'directory' }>;
forcedTools?: string[];
@@ -232,7 +236,7 @@ export const fetchSession = createAsyncThunk(
export const launchAndSendFirstMessage = createAsyncThunk(
'agents/launchAndSendFirstMessage',
async ({ draftId, config, prompt, mode, model, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds }: LaunchAndSendPayload) => {
async ({ draftId, config, prompt, mode, model, provider, images, contextPaths, forcedTools, attachedSkills, selectedBrowserIds }: LaunchAndSendPayload) => {
const launchRes = await fetch(`${AGENTS_API}/launch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -244,7 +248,7 @@ export const launchAndSendFirstMessage = createAsyncThunk(
await fetch(`${AGENTS_API}/sessions/${session.id}/message`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, mode, model, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, selected_browser_ids: selectedBrowserIds }),
body: JSON.stringify({ prompt, mode, model, provider, images, context_paths: contextPaths, forced_tools: forcedTools, attached_skills: attachedSkills, selected_browser_ids: selectedBrowserIds }),
});
const refreshRes = await fetch(`${AGENTS_API}/sessions/${session.id}`);
@@ -421,6 +425,7 @@ const agentsSlice = createSlice({
id: draftId,
name: 'New chat',
status: 'draft',
provider: 'anthropic',
model: 'sonnet',
mode,
worktree_path: null,
@@ -648,6 +653,13 @@ const agentsSlice = createSlice({
}
},
updateSessionProvider(state, action: PayloadAction<{ sessionId: string; provider: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) {
session.provider = action.payload.provider;
}
},
updateSessionModel(state, action: PayloadAction<{ sessionId: string; model: string }>) {
const session = state.sessions[action.payload.sessionId];
if (session) {
@@ -942,6 +954,7 @@ export const {
updateSessionCost,
addBranch,
setActiveBranch,
updateSessionProvider,
updateSessionModel,
updateSessionMode,
closeSessionFromWs,
+235
View File
@@ -0,0 +1,235 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const ANALYTICS_API = `${API_BASE}/analytics`;
export interface AnalyticsSummary {
total_sessions: number;
total_cost_usd: number;
total_messages: number;
total_tool_calls: number;
avg_session_duration_seconds: number;
session_completion_rate: number;
approval_rate: number;
models_used: Record<string, number>;
modes_used: Record<string, number>;
top_tools: [string, number][];
}
export interface UsagePoint {
date: string;
sessions: number;
cost: number;
}
export interface CostPoint {
date: string;
cost: number;
}
export interface ToolRank {
tool: string;
count: number;
}
export interface ApprovalStats {
allow: number;
deny: number;
total: number;
rate: number;
avg_latency_ms: number;
}
export interface SessionStats {
completed: number;
stopped: number;
error: number;
total: number;
completion_rate: number;
avg_duration_seconds: number;
}
export interface HourlyPoint {
hour: number;
count: number;
}
export interface DurationBucket {
label: string;
count: number;
}
export interface CostByModel {
model: string;
cost: number;
sessions: number;
}
export interface CumulativeCostPoint {
date: string;
cumulative: number;
daily: number;
}
export interface ToolDuration {
tool: string;
calls: number;
avg_ms: number;
max_ms: number;
}
export interface SessionCost {
timestamp: string;
model: string;
cost: number;
duration: number;
messages: number;
}
interface AnalyticsState {
summary: AnalyticsSummary | null;
usage: UsagePoint[];
cost: CostPoint[];
tools: ToolRank[];
approvals: ApprovalStats | null;
sessionStats: SessionStats | null;
hourly: HourlyPoint[];
durationDist: DurationBucket[];
costByModel: CostByModel[];
cumulativeCost: CumulativeCostPoint[];
toolDurations: ToolDuration[];
sessionCosts: SessionCost[];
exportPreview: any | null;
loading: boolean;
}
const initialState: AnalyticsState = {
summary: null,
usage: [],
cost: [],
tools: [],
approvals: null,
sessionStats: null,
hourly: [],
durationDist: [],
costByModel: [],
cumulativeCost: [],
toolDurations: [],
sessionCosts: [],
exportPreview: null,
loading: false,
};
export const fetchAnalyticsSummary = createAsyncThunk('analytics/fetchSummary', async () => {
const res = await fetch(`${ANALYTICS_API}/summary`);
return (await res.json()) as AnalyticsSummary;
});
export const fetchUsage = createAsyncThunk(
'analytics/fetchUsage',
async ({ period, range }: { period: string; range: number }) => {
const res = await fetch(`${ANALYTICS_API}/usage?period=${period}&range=${range}`);
const data = await res.json();
return data.data as UsagePoint[];
},
);
export const fetchCost = createAsyncThunk(
'analytics/fetchCost',
async ({ period, range }: { period: string; range: number }) => {
const res = await fetch(`${ANALYTICS_API}/cost?period=${period}&range=${range}`);
const data = await res.json();
return data.data as CostPoint[];
},
);
export const fetchTools = createAsyncThunk('analytics/fetchTools', async () => {
const res = await fetch(`${ANALYTICS_API}/tools?limit=20`);
const data = await res.json();
return data.data as ToolRank[];
});
export const fetchApprovals = createAsyncThunk('analytics/fetchApprovals', async () => {
const res = await fetch(`${ANALYTICS_API}/approvals`);
return (await res.json()) as ApprovalStats;
});
export const fetchSessionStats = createAsyncThunk('analytics/fetchSessionStats', async () => {
const res = await fetch(`${ANALYTICS_API}/sessions-stats`);
return (await res.json()) as SessionStats;
});
export const fetchHourlyActivity = createAsyncThunk('analytics/fetchHourly', async () => {
const res = await fetch(`${ANALYTICS_API}/hourly-activity`);
const data = await res.json();
return data.data as HourlyPoint[];
});
export const fetchDurationDistribution = createAsyncThunk('analytics/fetchDurationDist', async () => {
const res = await fetch(`${ANALYTICS_API}/duration-distribution`);
const data = await res.json();
return data.data as DurationBucket[];
});
export const fetchCostByModel = createAsyncThunk('analytics/fetchCostByModel', async () => {
const res = await fetch(`${ANALYTICS_API}/cost-by-model`);
const data = await res.json();
return data.data as CostByModel[];
});
export const fetchCumulativeCost = createAsyncThunk('analytics/fetchCumulativeCost', async () => {
const res = await fetch(`${ANALYTICS_API}/cumulative-cost?range=90`);
const data = await res.json();
return data.data as CumulativeCostPoint[];
});
export const fetchToolDurations = createAsyncThunk('analytics/fetchToolDurations', async () => {
const res = await fetch(`${ANALYTICS_API}/tool-durations`);
const data = await res.json();
return data.data as ToolDuration[];
});
export const fetchSessionCosts = createAsyncThunk('analytics/fetchSessionCosts', async () => {
const res = await fetch(`${ANALYTICS_API}/cost-per-session?limit=50`);
const data = await res.json();
return data.data as SessionCost[];
});
export const fetchExportPreview = createAsyncThunk('analytics/fetchExportPreview', async () => {
const res = await fetch(`${ANALYTICS_API}/export/preview`);
return await res.json();
});
export const doExport = createAsyncThunk('analytics/doExport', async () => {
const res = await fetch(`${ANALYTICS_API}/export`, { method: 'POST' });
return await res.json();
});
const analyticsSlice = createSlice({
name: 'analytics',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchAnalyticsSummary.pending, (state) => { state.loading = true; })
.addCase(fetchAnalyticsSummary.fulfilled, (state, action) => {
state.loading = false;
state.summary = action.payload;
})
.addCase(fetchAnalyticsSummary.rejected, (state) => { state.loading = false; })
.addCase(fetchUsage.fulfilled, (state, action) => { state.usage = action.payload; })
.addCase(fetchCost.fulfilled, (state, action) => { state.cost = action.payload; })
.addCase(fetchTools.fulfilled, (state, action) => { state.tools = action.payload; })
.addCase(fetchApprovals.fulfilled, (state, action) => { state.approvals = action.payload; })
.addCase(fetchSessionStats.fulfilled, (state, action) => { state.sessionStats = action.payload; })
.addCase(fetchHourlyActivity.fulfilled, (state, action) => { state.hourly = action.payload; })
.addCase(fetchDurationDistribution.fulfilled, (state, action) => { state.durationDist = action.payload; })
.addCase(fetchCostByModel.fulfilled, (state, action) => { state.costByModel = action.payload; })
.addCase(fetchCumulativeCost.fulfilled, (state, action) => { state.cumulativeCost = action.payload; })
.addCase(fetchToolDurations.fulfilled, (state, action) => { state.toolDurations = action.payload; })
.addCase(fetchSessionCosts.fulfilled, (state, action) => { state.sessionCosts = action.payload; })
.addCase(fetchExportPreview.fulfilled, (state, action) => { state.exportPreview = action.payload; });
},
});
export default analyticsSlice.reducer;
+49
View File
@@ -0,0 +1,49 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { API_BASE } from '@/shared/config';
const AGENTS_API = `${API_BASE}/agents`;
export interface ModelOption {
value: string;
label: string;
version?: string;
context_window: number;
}
interface ModelsState {
byProvider: Record<string, ModelOption[]>;
loaded: boolean;
}
const initialState: ModelsState = {
byProvider: {},
loaded: false,
};
export const fetchModels = createAsyncThunk('models/fetchModels', async () => {
const res = await fetch(`${AGENTS_API}/models`);
if (!res.ok) throw new Error('Failed to fetch models');
const data = await res.json();
// API returns { models: { provider: [...] } }
const models = data.models || data;
return models as Record<string, ModelOption[]>;
});
const modelsSlice = createSlice({
name: 'models',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchModels.fulfilled, (state, action) => {
state.byProvider = action.payload;
state.loaded = true;
})
.addCase(fetchModels.rejected, (state) => {
// Mark as loaded even on failure so we fall back to hardcoded options
state.loaded = true;
});
},
});
export default modelsSlice.reducer;
@@ -11,6 +11,13 @@ export const DEFAULT_SYSTEM_PROMPT =
`If a Browser is selected, prioritize this over other tools when it makes sense (so the user also has observability).\n\n` +
`If multiple Browsers are selected, parallelize the tasks across them.`;
export interface CustomProvider {
name: string;
base_url: string;
api_key: string;
models: Array<{ value: string; label: string; context_window?: number }>;
}
export interface AppSettings {
default_system_prompt: string | null;
default_folder: string | null;
@@ -21,6 +28,10 @@ export interface AppSettings {
theme: 'light' | 'dark';
new_agent_shortcut: string;
anthropic_api_key: string | null;
openai_api_key?: string | null;
google_api_key?: string | null;
openrouter_api_key?: string | null;
custom_providers?: CustomProvider[];
browser_homepage: string;
auto_select_mode_on_new_agent: boolean;
expand_new_chats_in_dashboard: boolean;
+4
View File
@@ -13,6 +13,8 @@ import dashboardLayoutReducer from './dashboardLayoutSlice';
import dashboardsReducer from './dashboardsSlice';
import updateReducer from './updateSlice';
import channelsReducer from './channelsSlice';
import analyticsReducer from './analyticsSlice';
import modelsReducer from './modelsSlice';
export const store = configureStore({
reducer: {
@@ -30,6 +32,8 @@ export const store = configureStore({
dashboards: dashboardsReducer,
update: updateReducer,
channels: channelsReducer,
analytics: analyticsReducer,
models: modelsReducer,
},
});