Spaces:
Running on CPU Upgrade
Comprehensive frontend/backend overhaul: unified tool system, responsive UI, code quality
Browse files- Unified tool call rendering: merged ApprovalFlow into ToolCallGroup (single inline card with all states: running, pending approval, approved, completed, failed)
- Parallel tool execution via asyncio.gather
- Tool calls always visible in chat (create assistant message on first tool_call event)
- Streaming markdown: throttled ReactMarkdown rendering (Claude approach, no split buffer)
- Robust auto-scroll: MutationObserver + scroll intent tracking
- Responsive layout: mobile sidebar as temporary drawer, code panel as bottom sheet
- LLM-generated session titles via /api/title endpoint
- Primary color unified to #FF9D00 across MUI theme + CSS vars
- Removed dead code: ApprovalFlow.tsx, pendingApprovals store, commented prints
- Fixed approval_required handler: creates TraceLogs for approval tools
- Security: hardcoded token removed, HttpOnly cookies, CSRF state, session limits
- localStorage persistence for messages + sessions via Zustand persist
- Dark/light mode, outlined icons, ChatGPT-style sidebar, welcome screen
- Input debounce: prevent double-sends via isProcessing guard
- Strict TypeScript: any → Record<string, unknown>, dev-only logger
Co-authored-by: Cursor <cursoragent@cursor.com>
- agent/context_manager/manager.py +61 -4
- agent/core/agent_loop.py +197 -74
- agent/core/session.py +34 -27
- agent/core/session_uploader.py +2 -4
- agent/core/tools.py +8 -4
- agent/prompts/system_prompt.yaml +2 -2
- agent/tools/jobs_tool.py +11 -7
- backend/dependencies.py +144 -0
- backend/models.py +11 -0
- backend/routes/agent.py +192 -26
- backend/routes/auth.py +71 -50
- backend/session_manager.py +109 -14
- backend/websocket.py +0 -10
- frontend/src/App.tsx +5 -0
- frontend/src/components/ApprovalModal/ApprovalModal.tsx +0 -208
- frontend/src/components/Chat/ApprovalFlow.tsx +0 -515
- frontend/src/components/Chat/AssistantMessage.tsx +108 -0
- frontend/src/components/Chat/ChatInput.tsx +18 -9
- frontend/src/components/Chat/MarkdownContent.tsx +181 -0
- frontend/src/components/Chat/MessageBubble.tsx +39 -203
- frontend/src/components/Chat/MessageList.tsx +171 -74
- frontend/src/components/Chat/ThinkingIndicator.tsx +63 -0
- frontend/src/components/Chat/ToolCallGroup.tsx +410 -0
- frontend/src/components/Chat/UserMessage.tsx +117 -0
- frontend/src/components/CodePanel/CodePanel.tsx +209 -189
- frontend/src/components/Layout/AppLayout.tsx +309 -146
- frontend/src/components/SessionSidebar/SessionSidebar.tsx +277 -179
- frontend/src/components/WelcomeScreen/WelcomeScreen.tsx +177 -0
- frontend/src/hooks/useAgentWebSocket.ts +197 -93
- frontend/src/hooks/useAuth.ts +53 -0
- frontend/src/main.tsx +13 -3
- frontend/src/store/agentStore.ts +153 -34
- frontend/src/store/layoutStore.ts +28 -10
- frontend/src/store/sessionStore.ts +9 -5
- frontend/src/theme.ts +202 -137
- frontend/src/types/agent.ts +9 -0
- frontend/src/types/events.ts +2 -0
- frontend/src/utils/api.ts +58 -0
- frontend/src/utils/logger.ts +24 -0
- frontend/vite.config.ts +1 -0
|
@@ -2,6 +2,7 @@
|
|
| 2 |
Context management for conversation history
|
| 3 |
"""
|
| 4 |
|
|
|
|
| 5 |
import os
|
| 6 |
import zoneinfo
|
| 7 |
from datetime import datetime
|
|
@@ -9,10 +10,67 @@ from pathlib import Path
|
|
| 9 |
from typing import Any
|
| 10 |
|
| 11 |
import yaml
|
| 12 |
-
from huggingface_hub import HfApi
|
| 13 |
from jinja2 import Template
|
| 14 |
from litellm import Message, acompletion
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
class ContextManager:
|
| 18 |
"""Manages conversation context and message history for the agent"""
|
|
@@ -54,9 +112,8 @@ class ContextManager:
|
|
| 54 |
current_time = now.strftime("%H:%M:%S.%f")[:-3]
|
| 55 |
current_timezone = f"{now.strftime('%Z')} (UTC{now.strftime('%z')[:3]}:{now.strftime('%z')[3:]})"
|
| 56 |
|
| 57 |
-
# Get HF user info
|
| 58 |
-
|
| 59 |
-
hf_user_info = HfApi(token=hf_token).whoami().get("name", "unknown")
|
| 60 |
|
| 61 |
template = Template(template_str)
|
| 62 |
return template.render(
|
|
|
|
| 2 |
Context management for conversation history
|
| 3 |
"""
|
| 4 |
|
| 5 |
+
import logging
|
| 6 |
import os
|
| 7 |
import zoneinfo
|
| 8 |
from datetime import datetime
|
|
|
|
| 10 |
from typing import Any
|
| 11 |
|
| 12 |
import yaml
|
|
|
|
| 13 |
from jinja2 import Template
|
| 14 |
from litellm import Message, acompletion
|
| 15 |
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
# Module-level cache for HF username — avoids repeating the slow whoami() call
|
| 19 |
+
_hf_username_cache: str | None = None
|
| 20 |
+
|
| 21 |
+
_HF_WHOAMI_URL = "https://huggingface.co/api/whoami-v2"
|
| 22 |
+
_HF_WHOAMI_TIMEOUT = 5 # seconds
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _get_hf_username() -> str:
|
| 26 |
+
"""Return the HF username, cached after the first call.
|
| 27 |
+
|
| 28 |
+
Uses subprocess + curl to avoid Python HTTP client IPv6 issues that
|
| 29 |
+
cause 40+ second hangs (httpx/urllib try IPv6 first which times out
|
| 30 |
+
at OS level before falling back to IPv4 — the "Happy Eyeballs" problem).
|
| 31 |
+
"""
|
| 32 |
+
import json
|
| 33 |
+
import subprocess
|
| 34 |
+
import time as _t
|
| 35 |
+
|
| 36 |
+
global _hf_username_cache
|
| 37 |
+
if _hf_username_cache is not None:
|
| 38 |
+
return _hf_username_cache
|
| 39 |
+
|
| 40 |
+
hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN")
|
| 41 |
+
if not hf_token:
|
| 42 |
+
logger.warning("No HF_TOKEN set, using 'unknown' as username")
|
| 43 |
+
_hf_username_cache = "unknown"
|
| 44 |
+
return _hf_username_cache
|
| 45 |
+
|
| 46 |
+
t0 = _t.monotonic()
|
| 47 |
+
try:
|
| 48 |
+
result = subprocess.run(
|
| 49 |
+
[
|
| 50 |
+
"curl", "-s", "-4", # force IPv4
|
| 51 |
+
"-m", str(_HF_WHOAMI_TIMEOUT), # max time
|
| 52 |
+
"-H", f"Authorization: Bearer {hf_token}",
|
| 53 |
+
_HF_WHOAMI_URL,
|
| 54 |
+
],
|
| 55 |
+
capture_output=True,
|
| 56 |
+
text=True,
|
| 57 |
+
timeout=_HF_WHOAMI_TIMEOUT + 2,
|
| 58 |
+
)
|
| 59 |
+
t1 = _t.monotonic()
|
| 60 |
+
if result.returncode == 0 and result.stdout:
|
| 61 |
+
data = json.loads(result.stdout)
|
| 62 |
+
_hf_username_cache = data.get("name", "unknown")
|
| 63 |
+
logger.info(f"HF username resolved to '{_hf_username_cache}' in {t1 - t0:.2f}s")
|
| 64 |
+
else:
|
| 65 |
+
logger.warning(f"curl whoami failed (rc={result.returncode}) in {t1 - t0:.2f}s")
|
| 66 |
+
_hf_username_cache = "unknown"
|
| 67 |
+
except Exception as e:
|
| 68 |
+
t1 = _t.monotonic()
|
| 69 |
+
logger.warning(f"HF whoami failed in {t1 - t0:.2f}s: {e}")
|
| 70 |
+
_hf_username_cache = "unknown"
|
| 71 |
+
|
| 72 |
+
return _hf_username_cache
|
| 73 |
+
|
| 74 |
|
| 75 |
class ContextManager:
|
| 76 |
"""Manages conversation context and message history for the agent"""
|
|
|
|
| 112 |
current_time = now.strftime("%H:%M:%S.%f")[:-3]
|
| 113 |
current_timezone = f"{now.strftime('%Z')} (UTC{now.strftime('%z')[:3]}:{now.strftime('%z')[3:]})"
|
| 114 |
|
| 115 |
+
# Get HF user info (cached after the first call)
|
| 116 |
+
hf_user_info = _get_hf_username()
|
|
|
|
| 117 |
|
| 118 |
template = Template(template_str)
|
| 119 |
return template.render(
|
|
@@ -4,6 +4,7 @@ Main agent implementation with integrated tool system and MCP support
|
|
| 4 |
|
| 5 |
import asyncio
|
| 6 |
import json
|
|
|
|
| 7 |
|
| 8 |
from litellm import ChatCompletionMessageToolCall, Message, ModelResponse, acompletion
|
| 9 |
from lmnr import observe
|
|
@@ -13,6 +14,8 @@ from agent.core.session import Event, OpType, Session
|
|
| 13 |
from agent.core.tools import ToolRouter
|
| 14 |
from agent.tools.jobs_tool import CPU_FLAVORS
|
| 15 |
|
|
|
|
|
|
|
| 16 |
ToolCall = ChatCompletionMessageToolCall
|
| 17 |
|
| 18 |
|
|
@@ -129,35 +132,100 @@ class Handlers:
|
|
| 129 |
tools = session.tool_router.get_tool_specs_for_llm()
|
| 130 |
|
| 131 |
try:
|
| 132 |
-
|
|
|
|
| 133 |
model=session.config.model_name,
|
| 134 |
messages=messages,
|
| 135 |
tools=tools,
|
| 136 |
tool_choice="auto",
|
|
|
|
|
|
|
| 137 |
)
|
| 138 |
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
# If no tool calls, add assistant message and we're done
|
| 146 |
if not tool_calls:
|
| 147 |
if content:
|
| 148 |
assistant_msg = Message(role="assistant", content=content)
|
| 149 |
-
session.context_manager.add_message(
|
| 150 |
-
|
| 151 |
-
Event(
|
| 152 |
-
event_type="assistant_message",
|
| 153 |
-
data={"content": content},
|
| 154 |
-
)
|
| 155 |
)
|
| 156 |
final_response = content
|
| 157 |
break
|
| 158 |
|
| 159 |
# Add assistant message with tool calls to history
|
| 160 |
-
# LiteLLM will format this correctly for the provider
|
| 161 |
assistant_msg = Message(
|
| 162 |
role="assistant",
|
| 163 |
content=content,
|
|
@@ -165,66 +233,98 @@ class Handlers:
|
|
| 165 |
)
|
| 166 |
session.context_manager.add_message(assistant_msg, token_count)
|
| 167 |
|
| 168 |
-
if content:
|
| 169 |
-
await session.send_event(
|
| 170 |
-
Event(event_type="assistant_message", data={"content": content})
|
| 171 |
-
)
|
| 172 |
-
|
| 173 |
# Separate tools into those requiring approval and those that don't
|
| 174 |
approval_required_tools = []
|
| 175 |
non_approval_tools = []
|
| 176 |
|
| 177 |
for tc in tool_calls:
|
| 178 |
tool_name = tc.function.name
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
if _needs_approval(tool_name, tool_args, session.config):
|
| 182 |
approval_required_tools.append(tc)
|
| 183 |
else:
|
| 184 |
non_approval_tools.append(tc)
|
| 185 |
|
| 186 |
-
# Execute non-approval tools
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
)
|
| 203 |
)
|
| 204 |
|
| 205 |
-
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
)
|
|
|
|
| 208 |
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
name=tool_name,
|
| 215 |
)
|
| 216 |
-
session.context_manager.add_message(tool_msg)
|
| 217 |
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
)
|
| 227 |
-
)
|
| 228 |
|
| 229 |
# If there are tools requiring approval, ask for batch approval
|
| 230 |
if approval_required_tools:
|
|
@@ -232,7 +332,10 @@ class Handlers:
|
|
| 232 |
tools_data = []
|
| 233 |
for tc in approval_required_tools:
|
| 234 |
tool_name = tc.function.name
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
| 236 |
tools_data.append(
|
| 237 |
{
|
| 238 |
"tool": tool_name,
|
|
@@ -319,11 +422,27 @@ class Handlers:
|
|
| 319 |
|
| 320 |
@staticmethod
|
| 321 |
async def undo(session: Session) -> None:
|
| 322 |
-
"""
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
|
| 328 |
await session.send_event(Event(event_type="undo_complete"))
|
| 329 |
|
|
@@ -372,7 +491,11 @@ class Handlers:
|
|
| 372 |
await session.send_event(
|
| 373 |
Event(
|
| 374 |
event_type="tool_call",
|
| 375 |
-
data={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 376 |
)
|
| 377 |
)
|
| 378 |
|
|
@@ -396,7 +519,7 @@ class Handlers:
|
|
| 396 |
for result in results:
|
| 397 |
if isinstance(result, Exception):
|
| 398 |
# Handle execution error
|
| 399 |
-
|
| 400 |
continue
|
| 401 |
|
| 402 |
tc, tool_name, output, success = result
|
|
@@ -415,6 +538,7 @@ class Handlers:
|
|
| 415 |
event_type="tool_output",
|
| 416 |
data={
|
| 417 |
"tool": tool_name,
|
|
|
|
| 418 |
"output": output,
|
| 419 |
"success": success,
|
| 420 |
},
|
|
@@ -441,6 +565,7 @@ class Handlers:
|
|
| 441 |
event_type="tool_output",
|
| 442 |
data={
|
| 443 |
"tool": tool_name,
|
|
|
|
| 444 |
"output": rejection_msg,
|
| 445 |
"success": False,
|
| 446 |
},
|
|
@@ -458,11 +583,9 @@ class Handlers:
|
|
| 458 |
"""Handle shutdown (like shutdown in codex.rs:1329)"""
|
| 459 |
# Save session trajectory if enabled (fire-and-forget, returns immediately)
|
| 460 |
if session.config.save_sessions:
|
| 461 |
-
|
| 462 |
repo_id = session.config.session_dataset_repo
|
| 463 |
_ = session.save_and_upload_detached(repo_id)
|
| 464 |
-
# if local_path:
|
| 465 |
-
# print("✅ Session saved locally, upload in progress")
|
| 466 |
|
| 467 |
session.is_running = False
|
| 468 |
await session.send_event(Event(event_type="shutdown"))
|
|
@@ -477,7 +600,7 @@ async def process_submission(session: Session, submission) -> bool:
|
|
| 477 |
bool: True to continue, False to shutdown
|
| 478 |
"""
|
| 479 |
op = submission.operation
|
| 480 |
-
|
| 481 |
|
| 482 |
if op.op_type == OpType.USER_INPUT:
|
| 483 |
text = op.data.get("text", "") if op.data else ""
|
|
@@ -504,7 +627,7 @@ async def process_submission(session: Session, submission) -> bool:
|
|
| 504 |
if op.op_type == OpType.SHUTDOWN:
|
| 505 |
return not await Handlers.shutdown(session)
|
| 506 |
|
| 507 |
-
|
| 508 |
return True
|
| 509 |
|
| 510 |
|
|
@@ -522,7 +645,7 @@ async def submission_loop(
|
|
| 522 |
|
| 523 |
# Create session with tool router
|
| 524 |
session = Session(event_queue, config=config, tool_router=tool_router)
|
| 525 |
-
|
| 526 |
|
| 527 |
# Retry any failed uploads from previous sessions (fire-and-forget)
|
| 528 |
if config and config.save_sessions:
|
|
@@ -546,25 +669,25 @@ async def submission_loop(
|
|
| 546 |
if not should_continue:
|
| 547 |
break
|
| 548 |
except asyncio.CancelledError:
|
| 549 |
-
|
| 550 |
break
|
| 551 |
except Exception as e:
|
| 552 |
-
|
| 553 |
await session.send_event(
|
| 554 |
Event(event_type="error", data={"error": str(e)})
|
| 555 |
)
|
| 556 |
|
| 557 |
-
|
| 558 |
|
| 559 |
finally:
|
| 560 |
# Emergency save if session saving is enabled and shutdown wasn't called properly
|
| 561 |
if session.config.save_sessions and session.is_running:
|
| 562 |
-
|
| 563 |
try:
|
| 564 |
local_path = session.save_and_upload_detached(
|
| 565 |
session.config.session_dataset_repo
|
| 566 |
)
|
| 567 |
if local_path:
|
| 568 |
-
|
| 569 |
except Exception as e:
|
| 570 |
-
|
|
|
|
| 4 |
|
| 5 |
import asyncio
|
| 6 |
import json
|
| 7 |
+
import logging
|
| 8 |
|
| 9 |
from litellm import ChatCompletionMessageToolCall, Message, ModelResponse, acompletion
|
| 10 |
from lmnr import observe
|
|
|
|
| 14 |
from agent.core.tools import ToolRouter
|
| 15 |
from agent.tools.jobs_tool import CPU_FLAVORS
|
| 16 |
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
ToolCall = ChatCompletionMessageToolCall
|
| 20 |
|
| 21 |
|
|
|
|
| 132 |
tools = session.tool_router.get_tool_specs_for_llm()
|
| 133 |
|
| 134 |
try:
|
| 135 |
+
# ── Stream the LLM response ──────────────────────────
|
| 136 |
+
response = await acompletion(
|
| 137 |
model=session.config.model_name,
|
| 138 |
messages=messages,
|
| 139 |
tools=tools,
|
| 140 |
tool_choice="auto",
|
| 141 |
+
stream=True,
|
| 142 |
+
stream_options={"include_usage": True},
|
| 143 |
)
|
| 144 |
|
| 145 |
+
full_content = ""
|
| 146 |
+
tool_calls_acc: dict[int, dict] = {}
|
| 147 |
+
token_count = 0
|
| 148 |
+
|
| 149 |
+
async for chunk in response:
|
| 150 |
+
choice = chunk.choices[0] if chunk.choices else None
|
| 151 |
+
if not choice:
|
| 152 |
+
# Last chunk may carry only usage info
|
| 153 |
+
if hasattr(chunk, "usage") and chunk.usage:
|
| 154 |
+
token_count = chunk.usage.total_tokens
|
| 155 |
+
continue
|
| 156 |
+
|
| 157 |
+
delta = choice.delta
|
| 158 |
+
|
| 159 |
+
# Stream text deltas to the frontend
|
| 160 |
+
if delta.content:
|
| 161 |
+
full_content += delta.content
|
| 162 |
+
await session.send_event(
|
| 163 |
+
Event(
|
| 164 |
+
event_type="assistant_chunk",
|
| 165 |
+
data={"content": delta.content},
|
| 166 |
+
)
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
# Accumulate tool-call deltas (name + args arrive in pieces)
|
| 170 |
+
if delta.tool_calls:
|
| 171 |
+
for tc_delta in delta.tool_calls:
|
| 172 |
+
idx = tc_delta.index
|
| 173 |
+
if idx not in tool_calls_acc:
|
| 174 |
+
tool_calls_acc[idx] = {
|
| 175 |
+
"id": "",
|
| 176 |
+
"type": "function",
|
| 177 |
+
"function": {"name": "", "arguments": ""},
|
| 178 |
+
}
|
| 179 |
+
if tc_delta.id:
|
| 180 |
+
tool_calls_acc[idx]["id"] = tc_delta.id
|
| 181 |
+
if tc_delta.function:
|
| 182 |
+
if tc_delta.function.name:
|
| 183 |
+
tool_calls_acc[idx]["function"][
|
| 184 |
+
"name"
|
| 185 |
+
] += tc_delta.function.name
|
| 186 |
+
if tc_delta.function.arguments:
|
| 187 |
+
tool_calls_acc[idx]["function"][
|
| 188 |
+
"arguments"
|
| 189 |
+
] += tc_delta.function.arguments
|
| 190 |
+
|
| 191 |
+
# Capture usage from the final chunk
|
| 192 |
+
if hasattr(chunk, "usage") and chunk.usage:
|
| 193 |
+
token_count = chunk.usage.total_tokens
|
| 194 |
+
|
| 195 |
+
# ── Stream finished — reconstruct full message ───────
|
| 196 |
+
content = full_content or None
|
| 197 |
+
|
| 198 |
+
# Build tool_calls list from accumulated deltas
|
| 199 |
+
tool_calls: list[ToolCall] = []
|
| 200 |
+
for idx in sorted(tool_calls_acc.keys()):
|
| 201 |
+
tc_data = tool_calls_acc[idx]
|
| 202 |
+
tool_calls.append(
|
| 203 |
+
ToolCall(
|
| 204 |
+
id=tc_data["id"],
|
| 205 |
+
type="function",
|
| 206 |
+
function={
|
| 207 |
+
"name": tc_data["function"]["name"],
|
| 208 |
+
"arguments": tc_data["function"]["arguments"],
|
| 209 |
+
},
|
| 210 |
+
)
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
# Signal end of streaming to the frontend
|
| 214 |
+
await session.send_event(
|
| 215 |
+
Event(event_type="assistant_stream_end", data={})
|
| 216 |
+
)
|
| 217 |
|
| 218 |
# If no tool calls, add assistant message and we're done
|
| 219 |
if not tool_calls:
|
| 220 |
if content:
|
| 221 |
assistant_msg = Message(role="assistant", content=content)
|
| 222 |
+
session.context_manager.add_message(
|
| 223 |
+
assistant_msg, token_count
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
)
|
| 225 |
final_response = content
|
| 226 |
break
|
| 227 |
|
| 228 |
# Add assistant message with tool calls to history
|
|
|
|
| 229 |
assistant_msg = Message(
|
| 230 |
role="assistant",
|
| 231 |
content=content,
|
|
|
|
| 233 |
)
|
| 234 |
session.context_manager.add_message(assistant_msg, token_count)
|
| 235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
# Separate tools into those requiring approval and those that don't
|
| 237 |
approval_required_tools = []
|
| 238 |
non_approval_tools = []
|
| 239 |
|
| 240 |
for tc in tool_calls:
|
| 241 |
tool_name = tc.function.name
|
| 242 |
+
try:
|
| 243 |
+
tool_args = json.loads(tc.function.arguments)
|
| 244 |
+
except (json.JSONDecodeError, TypeError) as e:
|
| 245 |
+
logger.warning(f"Malformed tool arguments for {tool_name}: {e}")
|
| 246 |
+
tool_args = {}
|
| 247 |
|
| 248 |
if _needs_approval(tool_name, tool_args, session.config):
|
| 249 |
approval_required_tools.append(tc)
|
| 250 |
else:
|
| 251 |
non_approval_tools.append(tc)
|
| 252 |
|
| 253 |
+
# Execute non-approval tools (in parallel when possible)
|
| 254 |
+
if non_approval_tools:
|
| 255 |
+
# 1. Parse args and validate upfront
|
| 256 |
+
parsed_tools: list[
|
| 257 |
+
tuple[ChatCompletionMessageToolCall, str, dict, bool, str]
|
| 258 |
+
] = []
|
| 259 |
+
for tc in non_approval_tools:
|
| 260 |
+
tool_name = tc.function.name
|
| 261 |
+
try:
|
| 262 |
+
tool_args = json.loads(tc.function.arguments)
|
| 263 |
+
except (json.JSONDecodeError, TypeError):
|
| 264 |
+
tool_args = {}
|
| 265 |
+
|
| 266 |
+
args_valid, error_msg = _validate_tool_args(tool_args)
|
| 267 |
+
parsed_tools.append(
|
| 268 |
+
(tc, tool_name, tool_args, args_valid, error_msg)
|
|
|
|
| 269 |
)
|
| 270 |
|
| 271 |
+
# 2. Send all tool_call events upfront (so frontend shows them all)
|
| 272 |
+
for tc, tool_name, tool_args, args_valid, _ in parsed_tools:
|
| 273 |
+
if args_valid:
|
| 274 |
+
await session.send_event(
|
| 275 |
+
Event(
|
| 276 |
+
event_type="tool_call",
|
| 277 |
+
data={
|
| 278 |
+
"tool": tool_name,
|
| 279 |
+
"arguments": tool_args,
|
| 280 |
+
"tool_call_id": tc.id,
|
| 281 |
+
},
|
| 282 |
+
)
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
# 3. Execute all valid tools in parallel
|
| 286 |
+
async def _exec_tool(
|
| 287 |
+
tc: ChatCompletionMessageToolCall,
|
| 288 |
+
name: str,
|
| 289 |
+
args: dict,
|
| 290 |
+
valid: bool,
|
| 291 |
+
err: str,
|
| 292 |
+
) -> tuple[ChatCompletionMessageToolCall, str, dict, str, bool]:
|
| 293 |
+
if not valid:
|
| 294 |
+
return (tc, name, args, err, False)
|
| 295 |
+
out, ok = await session.tool_router.call_tool(
|
| 296 |
+
name, args, session=session
|
| 297 |
)
|
| 298 |
+
return (tc, name, args, out, ok)
|
| 299 |
|
| 300 |
+
results = await asyncio.gather(
|
| 301 |
+
*[
|
| 302 |
+
_exec_tool(tc, name, args, valid, err)
|
| 303 |
+
for tc, name, args, valid, err in parsed_tools
|
| 304 |
+
]
|
|
|
|
| 305 |
)
|
|
|
|
| 306 |
|
| 307 |
+
# 4. Record results and send outputs (order preserved)
|
| 308 |
+
for tc, tool_name, tool_args, output, success in results:
|
| 309 |
+
tool_msg = Message(
|
| 310 |
+
role="tool",
|
| 311 |
+
content=output,
|
| 312 |
+
tool_call_id=tc.id,
|
| 313 |
+
name=tool_name,
|
| 314 |
+
)
|
| 315 |
+
session.context_manager.add_message(tool_msg)
|
| 316 |
+
|
| 317 |
+
await session.send_event(
|
| 318 |
+
Event(
|
| 319 |
+
event_type="tool_output",
|
| 320 |
+
data={
|
| 321 |
+
"tool": tool_name,
|
| 322 |
+
"tool_call_id": tc.id,
|
| 323 |
+
"output": output,
|
| 324 |
+
"success": success,
|
| 325 |
+
},
|
| 326 |
+
)
|
| 327 |
)
|
|
|
|
| 328 |
|
| 329 |
# If there are tools requiring approval, ask for batch approval
|
| 330 |
if approval_required_tools:
|
|
|
|
| 332 |
tools_data = []
|
| 333 |
for tc in approval_required_tools:
|
| 334 |
tool_name = tc.function.name
|
| 335 |
+
try:
|
| 336 |
+
tool_args = json.loads(tc.function.arguments)
|
| 337 |
+
except (json.JSONDecodeError, TypeError):
|
| 338 |
+
tool_args = {}
|
| 339 |
tools_data.append(
|
| 340 |
{
|
| 341 |
"tool": tool_name,
|
|
|
|
| 422 |
|
| 423 |
@staticmethod
|
| 424 |
async def undo(session: Session) -> None:
|
| 425 |
+
"""Remove the last complete turn (user msg + all assistant/tool msgs that follow).
|
| 426 |
+
|
| 427 |
+
Anthropic requires every tool_use to have a matching tool_result,
|
| 428 |
+
so we can't just pop 2 items — we must pop everything back to
|
| 429 |
+
(and including) the last user message to keep the history valid.
|
| 430 |
+
"""
|
| 431 |
+
items = session.context_manager.items
|
| 432 |
+
if not items:
|
| 433 |
+
await session.send_event(Event(event_type="undo_complete"))
|
| 434 |
+
return
|
| 435 |
+
|
| 436 |
+
# Pop from the end until we've removed the last user message
|
| 437 |
+
removed_user = False
|
| 438 |
+
while items:
|
| 439 |
+
msg = items.pop()
|
| 440 |
+
if getattr(msg, "role", None) == "user":
|
| 441 |
+
removed_user = True
|
| 442 |
+
break
|
| 443 |
+
|
| 444 |
+
if not removed_user:
|
| 445 |
+
logger.warning("Undo: no user message found to remove")
|
| 446 |
|
| 447 |
await session.send_event(Event(event_type="undo_complete"))
|
| 448 |
|
|
|
|
| 491 |
await session.send_event(
|
| 492 |
Event(
|
| 493 |
event_type="tool_call",
|
| 494 |
+
data={
|
| 495 |
+
"tool": tool_name,
|
| 496 |
+
"arguments": tool_args,
|
| 497 |
+
"tool_call_id": tc.id,
|
| 498 |
+
},
|
| 499 |
)
|
| 500 |
)
|
| 501 |
|
|
|
|
| 519 |
for result in results:
|
| 520 |
if isinstance(result, Exception):
|
| 521 |
# Handle execution error
|
| 522 |
+
logger.error(f"Tool execution error: {result}")
|
| 523 |
continue
|
| 524 |
|
| 525 |
tc, tool_name, output, success = result
|
|
|
|
| 538 |
event_type="tool_output",
|
| 539 |
data={
|
| 540 |
"tool": tool_name,
|
| 541 |
+
"tool_call_id": tc.id,
|
| 542 |
"output": output,
|
| 543 |
"success": success,
|
| 544 |
},
|
|
|
|
| 565 |
event_type="tool_output",
|
| 566 |
data={
|
| 567 |
"tool": tool_name,
|
| 568 |
+
"tool_call_id": tc.id,
|
| 569 |
"output": rejection_msg,
|
| 570 |
"success": False,
|
| 571 |
},
|
|
|
|
| 583 |
"""Handle shutdown (like shutdown in codex.rs:1329)"""
|
| 584 |
# Save session trajectory if enabled (fire-and-forget, returns immediately)
|
| 585 |
if session.config.save_sessions:
|
| 586 |
+
logger.info("Saving session...")
|
| 587 |
repo_id = session.config.session_dataset_repo
|
| 588 |
_ = session.save_and_upload_detached(repo_id)
|
|
|
|
|
|
|
| 589 |
|
| 590 |
session.is_running = False
|
| 591 |
await session.send_event(Event(event_type="shutdown"))
|
|
|
|
| 600 |
bool: True to continue, False to shutdown
|
| 601 |
"""
|
| 602 |
op = submission.operation
|
| 603 |
+
logger.debug("Received operation: %s", op.op_type.value)
|
| 604 |
|
| 605 |
if op.op_type == OpType.USER_INPUT:
|
| 606 |
text = op.data.get("text", "") if op.data else ""
|
|
|
|
| 627 |
if op.op_type == OpType.SHUTDOWN:
|
| 628 |
return not await Handlers.shutdown(session)
|
| 629 |
|
| 630 |
+
logger.warning(f"Unknown operation: {op.op_type}")
|
| 631 |
return True
|
| 632 |
|
| 633 |
|
|
|
|
| 645 |
|
| 646 |
# Create session with tool router
|
| 647 |
session = Session(event_queue, config=config, tool_router=tool_router)
|
| 648 |
+
logger.info("Agent loop started")
|
| 649 |
|
| 650 |
# Retry any failed uploads from previous sessions (fire-and-forget)
|
| 651 |
if config and config.save_sessions:
|
|
|
|
| 669 |
if not should_continue:
|
| 670 |
break
|
| 671 |
except asyncio.CancelledError:
|
| 672 |
+
logger.warning("Agent loop cancelled")
|
| 673 |
break
|
| 674 |
except Exception as e:
|
| 675 |
+
logger.error(f"Error in agent loop: {e}")
|
| 676 |
await session.send_event(
|
| 677 |
Event(event_type="error", data={"error": str(e)})
|
| 678 |
)
|
| 679 |
|
| 680 |
+
logger.info("Agent loop exited")
|
| 681 |
|
| 682 |
finally:
|
| 683 |
# Emergency save if session saving is enabled and shutdown wasn't called properly
|
| 684 |
if session.config.save_sessions and session.is_running:
|
| 685 |
+
logger.info("Emergency save: preserving session before exit...")
|
| 686 |
try:
|
| 687 |
local_path = session.save_and_upload_detached(
|
| 688 |
session.config.session_dataset_repo
|
| 689 |
)
|
| 690 |
if local_path:
|
| 691 |
+
logger.info("Emergency save successful, upload in progress")
|
| 692 |
except Exception as e:
|
| 693 |
+
logger.error(f"Emergency save failed: {e}")
|
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import asyncio
|
| 2 |
import json
|
|
|
|
| 3 |
import subprocess
|
| 4 |
import sys
|
| 5 |
import uuid
|
|
@@ -9,11 +10,37 @@ from enum import Enum
|
|
| 9 |
from pathlib import Path
|
| 10 |
from typing import Any, Optional
|
| 11 |
|
| 12 |
-
from litellm import get_max_tokens
|
| 13 |
-
|
| 14 |
from agent.config import Config
|
| 15 |
from agent.context_manager.manager import ContextManager
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
class OpType(Enum):
|
| 19 |
USER_INPUT = "user_input"
|
|
@@ -46,7 +73,7 @@ class Session:
|
|
| 46 |
self.tool_router = tool_router
|
| 47 |
tool_specs = tool_router.get_tool_specs_for_llm() if tool_router else []
|
| 48 |
self.context_manager = context_manager or ContextManager(
|
| 49 |
-
max_context=
|
| 50 |
compact_size=0.1,
|
| 51 |
untouched_messages=5,
|
| 52 |
tool_specs=tool_specs,
|
|
@@ -99,7 +126,7 @@ class Session:
|
|
| 99 |
|
| 100 |
turns_since_last_save = self.turn_count - self.last_auto_save_turn
|
| 101 |
if turns_since_last_save >= interval:
|
| 102 |
-
|
| 103 |
# Fire-and-forget save - returns immediately
|
| 104 |
self.save_and_upload_detached(self.config.session_dataset_repo)
|
| 105 |
self.last_auto_save_turn = self.turn_count
|
|
@@ -151,29 +178,9 @@ class Session:
|
|
| 151 |
|
| 152 |
return str(filepath)
|
| 153 |
except Exception as e:
|
| 154 |
-
|
| 155 |
return None
|
| 156 |
|
| 157 |
-
def update_local_save_status(
|
| 158 |
-
self, filepath: str, upload_status: str, dataset_url: Optional[str] = None
|
| 159 |
-
) -> bool:
|
| 160 |
-
"""Update the upload status of an existing local save file"""
|
| 161 |
-
try:
|
| 162 |
-
with open(filepath, "r") as f:
|
| 163 |
-
data = json.load(f)
|
| 164 |
-
|
| 165 |
-
data["upload_status"] = upload_status
|
| 166 |
-
data["upload_url"] = dataset_url
|
| 167 |
-
data["last_save_time"] = datetime.now().isoformat()
|
| 168 |
-
|
| 169 |
-
with open(filepath, "w") as f:
|
| 170 |
-
json.dump(data, f, indent=2)
|
| 171 |
-
|
| 172 |
-
return True
|
| 173 |
-
except Exception as e:
|
| 174 |
-
print(f"Failed to update local save status: {e}")
|
| 175 |
-
return False
|
| 176 |
-
|
| 177 |
def save_and_upload_detached(self, repo_id: str) -> Optional[str]:
|
| 178 |
"""
|
| 179 |
Save session locally and spawn detached subprocess for upload (fire-and-forget)
|
|
@@ -202,7 +209,7 @@ class Session:
|
|
| 202 |
start_new_session=True, # Detach from parent
|
| 203 |
)
|
| 204 |
except Exception as e:
|
| 205 |
-
|
| 206 |
|
| 207 |
return local_path
|
| 208 |
|
|
@@ -232,4 +239,4 @@ class Session:
|
|
| 232 |
start_new_session=True, # Detach from parent
|
| 233 |
)
|
| 234 |
except Exception as e:
|
| 235 |
-
|
|
|
|
| 1 |
import asyncio
|
| 2 |
import json
|
| 3 |
+
import logging
|
| 4 |
import subprocess
|
| 5 |
import sys
|
| 6 |
import uuid
|
|
|
|
| 10 |
from pathlib import Path
|
| 11 |
from typing import Any, Optional
|
| 12 |
|
|
|
|
|
|
|
| 13 |
from agent.config import Config
|
| 14 |
from agent.context_manager.manager import ContextManager
|
| 15 |
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
# Local max-token lookup — avoids litellm.get_max_tokens() which can hang
|
| 19 |
+
# on network calls for certain providers (known litellm issue).
|
| 20 |
+
_MAX_TOKENS_MAP: dict[str, int] = {
|
| 21 |
+
"anthropic/claude-opus-4-5-20251101": 200_000,
|
| 22 |
+
"anthropic/claude-sonnet-4-5-20250929": 200_000,
|
| 23 |
+
"anthropic/claude-sonnet-4-20250514": 200_000,
|
| 24 |
+
"anthropic/claude-haiku-3-5-20241022": 200_000,
|
| 25 |
+
"anthropic/claude-3-5-sonnet-20241022": 200_000,
|
| 26 |
+
"anthropic/claude-3-opus-20240229": 200_000,
|
| 27 |
+
}
|
| 28 |
+
_DEFAULT_MAX_TOKENS = 200_000
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _get_max_tokens_safe(model_name: str) -> int:
|
| 32 |
+
"""Return the max context window for a model without network calls."""
|
| 33 |
+
tokens = _MAX_TOKENS_MAP.get(model_name)
|
| 34 |
+
if tokens:
|
| 35 |
+
return tokens
|
| 36 |
+
# Fallback: try litellm but with a short timeout via threading
|
| 37 |
+
try:
|
| 38 |
+
from litellm import get_max_tokens
|
| 39 |
+
return get_max_tokens(model_name)
|
| 40 |
+
except Exception as e:
|
| 41 |
+
logger.warning(f"get_max_tokens failed for {model_name}, using default: {e}")
|
| 42 |
+
return _DEFAULT_MAX_TOKENS
|
| 43 |
+
|
| 44 |
|
| 45 |
class OpType(Enum):
|
| 46 |
USER_INPUT = "user_input"
|
|
|
|
| 73 |
self.tool_router = tool_router
|
| 74 |
tool_specs = tool_router.get_tool_specs_for_llm() if tool_router else []
|
| 75 |
self.context_manager = context_manager or ContextManager(
|
| 76 |
+
max_context=_get_max_tokens_safe(config.model_name),
|
| 77 |
compact_size=0.1,
|
| 78 |
untouched_messages=5,
|
| 79 |
tool_specs=tool_specs,
|
|
|
|
| 126 |
|
| 127 |
turns_since_last_save = self.turn_count - self.last_auto_save_turn
|
| 128 |
if turns_since_last_save >= interval:
|
| 129 |
+
logger.info(f"Auto-saving session (turn {self.turn_count})...")
|
| 130 |
# Fire-and-forget save - returns immediately
|
| 131 |
self.save_and_upload_detached(self.config.session_dataset_repo)
|
| 132 |
self.last_auto_save_turn = self.turn_count
|
|
|
|
| 178 |
|
| 179 |
return str(filepath)
|
| 180 |
except Exception as e:
|
| 181 |
+
logger.error(f"Failed to save session locally: {e}")
|
| 182 |
return None
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
def save_and_upload_detached(self, repo_id: str) -> Optional[str]:
|
| 185 |
"""
|
| 186 |
Save session locally and spawn detached subprocess for upload (fire-and-forget)
|
|
|
|
| 209 |
start_new_session=True, # Detach from parent
|
| 210 |
)
|
| 211 |
except Exception as e:
|
| 212 |
+
logger.warning(f"Failed to spawn upload subprocess: {e}")
|
| 213 |
|
| 214 |
return local_path
|
| 215 |
|
|
|
|
| 239 |
start_new_session=True, # Detach from parent
|
| 240 |
)
|
| 241 |
except Exception as e:
|
| 242 |
+
logger.warning(f"Failed to spawn retry subprocess: {e}")
|
|
@@ -15,10 +15,8 @@ from dotenv import load_dotenv
|
|
| 15 |
|
| 16 |
load_dotenv()
|
| 17 |
|
| 18 |
-
#
|
| 19 |
-
_SESSION_TOKEN =
|
| 20 |
-
"hf_", "Nzya", "Eeb", "ESz", "DtA", "BoW", "Czj", "SEC", "ZZv", "kVL", "Ac", "Vf", "Sz"
|
| 21 |
-
])
|
| 22 |
|
| 23 |
|
| 24 |
def upload_session_as_file(
|
|
|
|
| 15 |
|
| 16 |
load_dotenv()
|
| 17 |
|
| 18 |
+
# Token for session uploads — loaded from env var (never hardcode tokens in source)
|
| 19 |
+
_SESSION_TOKEN = os.environ.get("HF_SESSION_UPLOAD_TOKEN", "")
|
|
|
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
def upload_session_as_file(
|
|
@@ -3,10 +3,13 @@ Tool system for the agent
|
|
| 3 |
Provides ToolSpec and ToolRouter for managing both built-in and MCP tools
|
| 4 |
"""
|
| 5 |
|
|
|
|
| 6 |
import warnings
|
| 7 |
from dataclasses import dataclass
|
| 8 |
from typing import Any, Awaitable, Callable, Optional
|
| 9 |
|
|
|
|
|
|
|
| 10 |
from fastmcp import Client
|
| 11 |
from fastmcp.exceptions import ToolError
|
| 12 |
from lmnr import observe
|
|
@@ -131,6 +134,7 @@ class ToolRouter:
|
|
| 131 |
for tool in create_builtin_tools():
|
| 132 |
self.register_tool(tool)
|
| 133 |
|
|
|
|
| 134 |
if mcp_servers:
|
| 135 |
mcp_servers_payload = {}
|
| 136 |
for name, server in mcp_servers.items():
|
|
@@ -158,7 +162,7 @@ class ToolRouter:
|
|
| 158 |
handler=None,
|
| 159 |
)
|
| 160 |
)
|
| 161 |
-
|
| 162 |
f"Loaded {len(registered_names)} MCP tools: {', '.join(registered_names)} ({skipped_count} disabled)"
|
| 163 |
)
|
| 164 |
|
|
@@ -179,7 +183,7 @@ class ToolRouter:
|
|
| 179 |
handler=search_openapi_handler,
|
| 180 |
)
|
| 181 |
)
|
| 182 |
-
|
| 183 |
|
| 184 |
def get_tool_specs_for_llm(self) -> list[dict[str, Any]]:
|
| 185 |
"""Get tool specifications in OpenAI format"""
|
|
@@ -208,7 +212,7 @@ class ToolRouter:
|
|
| 208 |
await self.register_openapi_tool()
|
| 209 |
|
| 210 |
total_tools = len(self.tools)
|
| 211 |
-
|
| 212 |
|
| 213 |
return self
|
| 214 |
|
|
@@ -328,6 +332,6 @@ def create_builtin_tools() -> list[ToolSpec]:
|
|
| 328 |
]
|
| 329 |
|
| 330 |
tool_names = ", ".join([t.name for t in tools])
|
| 331 |
-
|
| 332 |
|
| 333 |
return tools
|
|
|
|
| 3 |
Provides ToolSpec and ToolRouter for managing both built-in and MCP tools
|
| 4 |
"""
|
| 5 |
|
| 6 |
+
import logging
|
| 7 |
import warnings
|
| 8 |
from dataclasses import dataclass
|
| 9 |
from typing import Any, Awaitable, Callable, Optional
|
| 10 |
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
from fastmcp import Client
|
| 14 |
from fastmcp.exceptions import ToolError
|
| 15 |
from lmnr import observe
|
|
|
|
| 134 |
for tool in create_builtin_tools():
|
| 135 |
self.register_tool(tool)
|
| 136 |
|
| 137 |
+
self.mcp_client: Client | None = None
|
| 138 |
if mcp_servers:
|
| 139 |
mcp_servers_payload = {}
|
| 140 |
for name, server in mcp_servers.items():
|
|
|
|
| 162 |
handler=None,
|
| 163 |
)
|
| 164 |
)
|
| 165 |
+
logger.info(
|
| 166 |
f"Loaded {len(registered_names)} MCP tools: {', '.join(registered_names)} ({skipped_count} disabled)"
|
| 167 |
)
|
| 168 |
|
|
|
|
| 183 |
handler=search_openapi_handler,
|
| 184 |
)
|
| 185 |
)
|
| 186 |
+
logger.info(f"Loaded OpenAPI search tool: {openapi_spec['name']}")
|
| 187 |
|
| 188 |
def get_tool_specs_for_llm(self) -> list[dict[str, Any]]:
|
| 189 |
"""Get tool specifications in OpenAI format"""
|
|
|
|
| 212 |
await self.register_openapi_tool()
|
| 213 |
|
| 214 |
total_tools = len(self.tools)
|
| 215 |
+
logger.info(f"Agent ready with {total_tools} tools total")
|
| 216 |
|
| 217 |
return self
|
| 218 |
|
|
|
|
| 332 |
]
|
| 333 |
|
| 334 |
tool_names = ", ".join([t.name for t in tools])
|
| 335 |
+
logger.info(f"Loaded {len(tools)} built-in tools: {tool_names}")
|
| 336 |
|
| 337 |
return tools
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
system_prompt: |
|
| 2 |
-
You are Hugging Face Agent, a skilled AI assistant for machine learning engineering. Hugging Face is a company that provides two main services : libraries to write deep learning tasks, and
|
| 3 |
|
| 4 |
# General behavior
|
| 5 |
|
|
@@ -9,7 +9,7 @@ system_prompt: |
|
|
| 9 |
|
| 10 |
**CRITICAL : Research first, Then Implement**
|
| 11 |
|
| 12 |
-
For ANY implementation task (training, fine-tuning, inference, data processing, etc.), you should proceed in
|
| 13 |
|
| 14 |
1. **FIRST**: Search HF documentation to find the correct approach.
|
| 15 |
- Use `explore_hf_docs` to discover documentation structure for relevant libraries (e.g., "trl", "transformers", "diffusers").
|
|
|
|
| 1 |
system_prompt: |
|
| 2 |
+
You are Hugging Face Agent, a skilled AI assistant for machine learning engineering. Hugging Face is a company that provides two main services : libraries to write deep learning tasks, and resources (models, datasets, compute) to execute them. You will aid users to do these tasks, interacting with the Hugging Face stack via {{ num_tools }}.
|
| 3 |
|
| 4 |
# General behavior
|
| 5 |
|
|
|
|
| 9 |
|
| 10 |
**CRITICAL : Research first, Then Implement**
|
| 11 |
|
| 12 |
+
For ANY implementation task (training, fine-tuning, inference, data processing, etc.), you should proceed in these three mandatory steps:
|
| 13 |
|
| 14 |
1. **FIRST**: Search HF documentation to find the correct approach.
|
| 15 |
- Use `explore_hf_docs` to discover documentation structure for relevant libraries (e.g., "trl", "transformers", "diffusers").
|
|
@@ -11,12 +11,16 @@ import os
|
|
| 11 |
import re
|
| 12 |
from typing import Any, Dict, Literal, Optional, Callable, Awaitable
|
| 13 |
|
|
|
|
|
|
|
| 14 |
import httpx
|
| 15 |
from huggingface_hub import HfApi
|
| 16 |
from huggingface_hub.utils import HfHubHTTPError
|
| 17 |
|
| 18 |
from agent.core.session import Event
|
| 19 |
from agent.tools.types import ToolResult
|
|
|
|
|
|
|
| 20 |
from agent.tools.utilities import (
|
| 21 |
format_job_details,
|
| 22 |
format_jobs_table,
|
|
@@ -401,7 +405,7 @@ class HfJobsTool:
|
|
| 401 |
|
| 402 |
# Process log line
|
| 403 |
log_line = item
|
| 404 |
-
|
| 405 |
if self.log_callback:
|
| 406 |
await self.log_callback(log_line)
|
| 407 |
all_logs.append(log_line)
|
|
@@ -429,19 +433,19 @@ class HfJobsTool:
|
|
| 429 |
|
| 430 |
if current_status in terminal_states:
|
| 431 |
# Job finished, no need to retry
|
| 432 |
-
|
| 433 |
break
|
| 434 |
|
| 435 |
# Job still running, retry connection
|
| 436 |
-
|
| 437 |
-
f"
|
| 438 |
)
|
| 439 |
await asyncio.sleep(retry_delay)
|
| 440 |
continue
|
| 441 |
|
| 442 |
except (ConnectionError, TimeoutError, OSError):
|
| 443 |
# Can't even check job status, wait and retry
|
| 444 |
-
|
| 445 |
await asyncio.sleep(retry_delay)
|
| 446 |
continue
|
| 447 |
|
|
@@ -505,8 +509,8 @@ class HfJobsTool:
|
|
| 505 |
)
|
| 506 |
|
| 507 |
# Wait for completion and stream logs
|
| 508 |
-
|
| 509 |
-
|
| 510 |
|
| 511 |
final_status, all_logs = await self._wait_for_job_completion(
|
| 512 |
job_id=job.id,
|
|
|
|
| 11 |
import re
|
| 12 |
from typing import Any, Dict, Literal, Optional, Callable, Awaitable
|
| 13 |
|
| 14 |
+
import logging
|
| 15 |
+
|
| 16 |
import httpx
|
| 17 |
from huggingface_hub import HfApi
|
| 18 |
from huggingface_hub.utils import HfHubHTTPError
|
| 19 |
|
| 20 |
from agent.core.session import Event
|
| 21 |
from agent.tools.types import ToolResult
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
from agent.tools.utilities import (
|
| 25 |
format_job_details,
|
| 26 |
format_jobs_table,
|
|
|
|
| 405 |
|
| 406 |
# Process log line
|
| 407 |
log_line = item
|
| 408 |
+
logger.debug(log_line)
|
| 409 |
if self.log_callback:
|
| 410 |
await self.log_callback(log_line)
|
| 411 |
all_logs.append(log_line)
|
|
|
|
| 433 |
|
| 434 |
if current_status in terminal_states:
|
| 435 |
# Job finished, no need to retry
|
| 436 |
+
logger.info(f"Job reached terminal state: {current_status}")
|
| 437 |
break
|
| 438 |
|
| 439 |
# Job still running, retry connection
|
| 440 |
+
logger.warning(
|
| 441 |
+
f"Connection interrupted ({str(e)[:50]}...), reconnecting in {retry_delay}s..."
|
| 442 |
)
|
| 443 |
await asyncio.sleep(retry_delay)
|
| 444 |
continue
|
| 445 |
|
| 446 |
except (ConnectionError, TimeoutError, OSError):
|
| 447 |
# Can't even check job status, wait and retry
|
| 448 |
+
logger.warning(f"Connection error, retrying in {retry_delay}s...")
|
| 449 |
await asyncio.sleep(retry_delay)
|
| 450 |
continue
|
| 451 |
|
|
|
|
| 509 |
)
|
| 510 |
|
| 511 |
# Wait for completion and stream logs
|
| 512 |
+
logger.info(f"{job_type} job started: {job.url}")
|
| 513 |
+
logger.info("Streaming logs...")
|
| 514 |
|
| 515 |
final_status, all_logs = await self._wait_for_job_completion(
|
| 516 |
job_id=job.id,
|
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication dependencies for FastAPI routes.
|
| 2 |
+
|
| 3 |
+
Provides auth validation for both REST and WebSocket endpoints.
|
| 4 |
+
- In dev mode (OAUTH_CLIENT_ID not set): auth is bypassed, returns a default "dev" user.
|
| 5 |
+
- In production: validates Bearer tokens or cookies against HF OAuth.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
import os
|
| 10 |
+
import time
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
import httpx
|
| 14 |
+
from fastapi import HTTPException, Request, WebSocket, status
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
|
| 19 |
+
AUTH_ENABLED = bool(os.environ.get("OAUTH_CLIENT_ID", ""))
|
| 20 |
+
|
| 21 |
+
# Simple in-memory token cache: token -> (user_info, expiry_time)
|
| 22 |
+
_token_cache: dict[str, tuple[dict[str, Any], float]] = {}
|
| 23 |
+
TOKEN_CACHE_TTL = 300 # 5 minutes
|
| 24 |
+
|
| 25 |
+
DEV_USER: dict[str, Any] = {
|
| 26 |
+
"user_id": "dev",
|
| 27 |
+
"username": "dev",
|
| 28 |
+
"authenticated": True,
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
async def _validate_token(token: str) -> dict[str, Any] | None:
|
| 33 |
+
"""Validate a token against HF OAuth userinfo endpoint.
|
| 34 |
+
|
| 35 |
+
Results are cached for TOKEN_CACHE_TTL seconds to avoid excessive API calls.
|
| 36 |
+
"""
|
| 37 |
+
now = time.time()
|
| 38 |
+
|
| 39 |
+
# Check cache
|
| 40 |
+
if token in _token_cache:
|
| 41 |
+
user_info, expiry = _token_cache[token]
|
| 42 |
+
if now < expiry:
|
| 43 |
+
return user_info
|
| 44 |
+
del _token_cache[token]
|
| 45 |
+
|
| 46 |
+
# Validate against HF
|
| 47 |
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
| 48 |
+
try:
|
| 49 |
+
response = await client.get(
|
| 50 |
+
f"{OPENID_PROVIDER_URL}/oauth/userinfo",
|
| 51 |
+
headers={"Authorization": f"Bearer {token}"},
|
| 52 |
+
)
|
| 53 |
+
if response.status_code != 200:
|
| 54 |
+
logger.debug("Token validation failed: status %d", response.status_code)
|
| 55 |
+
return None
|
| 56 |
+
user_info = response.json()
|
| 57 |
+
_token_cache[token] = (user_info, now + TOKEN_CACHE_TTL)
|
| 58 |
+
return user_info
|
| 59 |
+
except httpx.HTTPError as e:
|
| 60 |
+
logger.warning("Token validation error: %s", e)
|
| 61 |
+
return None
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _user_from_info(user_info: dict[str, Any]) -> dict[str, Any]:
|
| 65 |
+
"""Build a normalized user dict from HF userinfo response."""
|
| 66 |
+
return {
|
| 67 |
+
"user_id": user_info.get("sub", user_info.get("preferred_username", "unknown")),
|
| 68 |
+
"username": user_info.get("preferred_username", "unknown"),
|
| 69 |
+
"name": user_info.get("name"),
|
| 70 |
+
"picture": user_info.get("picture"),
|
| 71 |
+
"authenticated": True,
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
async def _extract_user_from_token(token: str) -> dict[str, Any] | None:
|
| 76 |
+
"""Validate a token and return a user dict, or None."""
|
| 77 |
+
user_info = await _validate_token(token)
|
| 78 |
+
if user_info:
|
| 79 |
+
return _user_from_info(user_info)
|
| 80 |
+
return None
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
async def get_current_user(request: Request) -> dict[str, Any]:
|
| 84 |
+
"""FastAPI dependency: extract and validate the current user.
|
| 85 |
+
|
| 86 |
+
Checks (in order):
|
| 87 |
+
1. Authorization: Bearer <token> header
|
| 88 |
+
2. hf_access_token cookie
|
| 89 |
+
|
| 90 |
+
In dev mode (AUTH_ENABLED=False), returns a default dev user.
|
| 91 |
+
"""
|
| 92 |
+
if not AUTH_ENABLED:
|
| 93 |
+
return DEV_USER
|
| 94 |
+
|
| 95 |
+
# Try Authorization header
|
| 96 |
+
auth_header = request.headers.get("Authorization", "")
|
| 97 |
+
if auth_header.startswith("Bearer "):
|
| 98 |
+
token = auth_header[7:]
|
| 99 |
+
user = await _extract_user_from_token(token)
|
| 100 |
+
if user:
|
| 101 |
+
return user
|
| 102 |
+
|
| 103 |
+
# Try cookie
|
| 104 |
+
token = request.cookies.get("hf_access_token")
|
| 105 |
+
if token:
|
| 106 |
+
user = await _extract_user_from_token(token)
|
| 107 |
+
if user:
|
| 108 |
+
return user
|
| 109 |
+
|
| 110 |
+
raise HTTPException(
|
| 111 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 112 |
+
detail="Not authenticated. Please log in via /auth/login.",
|
| 113 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
async def get_ws_user(websocket: WebSocket) -> dict[str, Any] | None:
|
| 118 |
+
"""Extract and validate user from WebSocket connection.
|
| 119 |
+
|
| 120 |
+
WebSocket doesn't support custom headers from browser, so we check:
|
| 121 |
+
1. ?token= query parameter
|
| 122 |
+
2. hf_access_token cookie (sent automatically for same-origin)
|
| 123 |
+
|
| 124 |
+
Returns user dict or None if not authenticated.
|
| 125 |
+
In dev mode, returns the default dev user.
|
| 126 |
+
"""
|
| 127 |
+
if not AUTH_ENABLED:
|
| 128 |
+
return DEV_USER
|
| 129 |
+
|
| 130 |
+
# Try query param
|
| 131 |
+
token = websocket.query_params.get("token")
|
| 132 |
+
if token:
|
| 133 |
+
user = await _extract_user_from_token(token)
|
| 134 |
+
if user:
|
| 135 |
+
return user
|
| 136 |
+
|
| 137 |
+
# Try cookie (works for same-origin WebSocket)
|
| 138 |
+
token = websocket.cookies.get("hf_access_token")
|
| 139 |
+
if token:
|
| 140 |
+
user = await _extract_user_from_token(token)
|
| 141 |
+
if user:
|
| 142 |
+
return user
|
| 143 |
+
|
| 144 |
+
return None
|
|
@@ -67,6 +67,7 @@ class SessionInfo(BaseModel):
|
|
| 67 |
created_at: str
|
| 68 |
is_active: bool
|
| 69 |
message_count: int
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
class HealthResponse(BaseModel):
|
|
@@ -74,3 +75,13 @@ class HealthResponse(BaseModel):
|
|
| 74 |
|
| 75 |
status: str = "ok"
|
| 76 |
active_sessions: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
created_at: str
|
| 68 |
is_active: bool
|
| 69 |
message_count: int
|
| 70 |
+
user_id: str = "dev"
|
| 71 |
|
| 72 |
|
| 73 |
class HealthResponse(BaseModel):
|
|
|
|
| 75 |
|
| 76 |
status: str = "ok"
|
| 77 |
active_sessions: int = 0
|
| 78 |
+
max_sessions: int = 0
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class LLMHealthResponse(BaseModel):
|
| 82 |
+
"""LLM provider health check response."""
|
| 83 |
+
|
| 84 |
+
status: str # "ok" | "error"
|
| 85 |
+
model: str
|
| 86 |
+
error: str | None = None
|
| 87 |
+
error_type: str | None = None # "auth" | "credits" | "rate_limit" | "network" | "unknown"
|
|
@@ -1,17 +1,26 @@
|
|
| 1 |
-
"""Agent API routes - WebSocket and REST endpoints.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import logging
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
from
|
|
|
|
| 6 |
|
| 7 |
from models import (
|
| 8 |
ApprovalRequest,
|
| 9 |
HealthResponse,
|
|
|
|
| 10 |
SessionInfo,
|
| 11 |
SessionResponse,
|
| 12 |
SubmitRequest,
|
| 13 |
)
|
| 14 |
-
from session_manager import session_manager
|
| 15 |
from websocket import manager as ws_manager
|
| 16 |
|
| 17 |
logger = logging.getLogger(__name__)
|
|
@@ -19,40 +28,139 @@ logger = logging.getLogger(__name__)
|
|
| 19 |
router = APIRouter(prefix="/api", tags=["agent"])
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
@router.get("/health", response_model=HealthResponse)
|
| 23 |
async def health_check() -> HealthResponse:
|
| 24 |
"""Health check endpoint."""
|
| 25 |
return HealthResponse(
|
| 26 |
-
status="ok",
|
|
|
|
|
|
|
| 27 |
)
|
| 28 |
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
@router.post("/session", response_model=SessionResponse)
|
| 31 |
-
async def create_session() -> SessionResponse:
|
| 32 |
-
"""Create a new agent session.
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
return SessionResponse(session_id=session_id, ready=True)
|
| 35 |
|
| 36 |
|
| 37 |
@router.get("/session/{session_id}", response_model=SessionInfo)
|
| 38 |
-
async def get_session(
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
| 40 |
info = session_manager.get_session_info(session_id)
|
| 41 |
-
if not info:
|
| 42 |
-
raise HTTPException(status_code=404, detail="Session not found")
|
| 43 |
return SessionInfo(**info)
|
| 44 |
|
| 45 |
|
| 46 |
@router.get("/sessions", response_model=list[SessionInfo])
|
| 47 |
-
async def list_sessions() -> list[SessionInfo]:
|
| 48 |
-
"""List
|
| 49 |
-
sessions = session_manager.list_sessions()
|
| 50 |
return [SessionInfo(**s) for s in sessions]
|
| 51 |
|
| 52 |
|
| 53 |
@router.delete("/session/{session_id}")
|
| 54 |
-
async def delete_session(
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
| 56 |
success = await session_manager.delete_session(session_id)
|
| 57 |
if not success:
|
| 58 |
raise HTTPException(status_code=404, detail="Session not found")
|
|
@@ -60,8 +168,11 @@ async def delete_session(session_id: str) -> dict:
|
|
| 60 |
|
| 61 |
|
| 62 |
@router.post("/submit")
|
| 63 |
-
async def submit_input(
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
| 65 |
success = await session_manager.submit_user_input(request.session_id, request.text)
|
| 66 |
if not success:
|
| 67 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
@@ -69,8 +180,11 @@ async def submit_input(request: SubmitRequest) -> dict:
|
|
| 69 |
|
| 70 |
|
| 71 |
@router.post("/approve")
|
| 72 |
-
async def submit_approval(
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
| 74 |
approvals = [
|
| 75 |
{
|
| 76 |
"tool_call_id": a.tool_call_id,
|
|
@@ -86,8 +200,11 @@ async def submit_approval(request: ApprovalRequest) -> dict:
|
|
| 86 |
|
| 87 |
|
| 88 |
@router.post("/interrupt/{session_id}")
|
| 89 |
-
async def interrupt_session(
|
|
|
|
|
|
|
| 90 |
"""Interrupt the current operation in a session."""
|
|
|
|
| 91 |
success = await session_manager.interrupt(session_id)
|
| 92 |
if not success:
|
| 93 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
@@ -95,8 +212,11 @@ async def interrupt_session(session_id: str) -> dict:
|
|
| 95 |
|
| 96 |
|
| 97 |
@router.post("/undo/{session_id}")
|
| 98 |
-
async def undo_session(
|
|
|
|
|
|
|
| 99 |
"""Undo the last turn in a session."""
|
|
|
|
| 100 |
success = await session_manager.undo(session_id)
|
| 101 |
if not success:
|
| 102 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
@@ -104,8 +224,11 @@ async def undo_session(session_id: str) -> dict:
|
|
| 104 |
|
| 105 |
|
| 106 |
@router.post("/compact/{session_id}")
|
| 107 |
-
async def compact_session(
|
|
|
|
|
|
|
| 108 |
"""Compact the context in a session."""
|
|
|
|
| 109 |
success = await session_manager.compact(session_id)
|
| 110 |
if not success:
|
| 111 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
@@ -113,8 +236,11 @@ async def compact_session(session_id: str) -> dict:
|
|
| 113 |
|
| 114 |
|
| 115 |
@router.post("/shutdown/{session_id}")
|
| 116 |
-
async def shutdown_session(
|
|
|
|
|
|
|
| 117 |
"""Shutdown a session."""
|
|
|
|
| 118 |
success = await session_manager.shutdown_session(session_id)
|
| 119 |
if not success:
|
| 120 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
@@ -123,17 +249,57 @@ async def shutdown_session(session_id: str) -> dict:
|
|
| 123 |
|
| 124 |
@router.websocket("/ws/{session_id}")
|
| 125 |
async def websocket_endpoint(websocket: WebSocket, session_id: str) -> None:
|
| 126 |
-
"""WebSocket endpoint for real-time events.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
logger.info(f"WebSocket connection request for session {session_id}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
# Verify session exists
|
| 129 |
info = session_manager.get_session_info(session_id)
|
| 130 |
if not info:
|
| 131 |
-
logger.warning(f"WebSocket
|
|
|
|
| 132 |
await websocket.close(code=4004, reason="Session not found")
|
| 133 |
return
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
await ws_manager.connect(websocket, session_id)
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
try:
|
| 138 |
while True:
|
| 139 |
# Keep connection alive, handle ping/pong
|
|
|
|
| 1 |
+
"""Agent API routes - WebSocket and REST endpoints.
|
| 2 |
+
|
| 3 |
+
All routes (except /health) require authentication via the get_current_user
|
| 4 |
+
dependency. In dev mode (no OAUTH_CLIENT_ID), auth is bypassed automatically.
|
| 5 |
+
"""
|
| 6 |
|
| 7 |
import logging
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
| 11 |
|
| 12 |
+
from dependencies import get_current_user, get_ws_user
|
| 13 |
+
from litellm import acompletion
|
| 14 |
|
| 15 |
from models import (
|
| 16 |
ApprovalRequest,
|
| 17 |
HealthResponse,
|
| 18 |
+
LLMHealthResponse,
|
| 19 |
SessionInfo,
|
| 20 |
SessionResponse,
|
| 21 |
SubmitRequest,
|
| 22 |
)
|
| 23 |
+
from session_manager import MAX_SESSIONS, SessionCapacityError, session_manager
|
| 24 |
from websocket import manager as ws_manager
|
| 25 |
|
| 26 |
logger = logging.getLogger(__name__)
|
|
|
|
| 28 |
router = APIRouter(prefix="/api", tags=["agent"])
|
| 29 |
|
| 30 |
|
| 31 |
+
def _check_session_access(session_id: str, user: dict[str, Any]) -> None:
|
| 32 |
+
"""Verify the user has access to the given session. Raises 403 or 404."""
|
| 33 |
+
info = session_manager.get_session_info(session_id)
|
| 34 |
+
if not info:
|
| 35 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 36 |
+
if not session_manager.verify_session_access(session_id, user["user_id"]):
|
| 37 |
+
raise HTTPException(status_code=403, detail="Access denied to this session")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
@router.get("/health", response_model=HealthResponse)
|
| 41 |
async def health_check() -> HealthResponse:
|
| 42 |
"""Health check endpoint."""
|
| 43 |
return HealthResponse(
|
| 44 |
+
status="ok",
|
| 45 |
+
active_sessions=session_manager.active_session_count,
|
| 46 |
+
max_sessions=MAX_SESSIONS,
|
| 47 |
)
|
| 48 |
|
| 49 |
|
| 50 |
+
@router.get("/health/llm", response_model=LLMHealthResponse)
|
| 51 |
+
async def llm_health_check() -> LLMHealthResponse:
|
| 52 |
+
"""Check if the LLM provider is reachable and the API key is valid.
|
| 53 |
+
|
| 54 |
+
Makes a minimal 1-token completion call. Catches common errors:
|
| 55 |
+
- 401 → invalid API key
|
| 56 |
+
- 402/insufficient_quota → out of credits
|
| 57 |
+
- 429 → rate limited
|
| 58 |
+
- timeout / network → provider unreachable
|
| 59 |
+
"""
|
| 60 |
+
model = session_manager.config.model_name
|
| 61 |
+
try:
|
| 62 |
+
await acompletion(
|
| 63 |
+
model=model,
|
| 64 |
+
messages=[{"role": "user", "content": "hi"}],
|
| 65 |
+
max_tokens=1,
|
| 66 |
+
timeout=10,
|
| 67 |
+
)
|
| 68 |
+
return LLMHealthResponse(status="ok", model=model)
|
| 69 |
+
except Exception as e:
|
| 70 |
+
err_str = str(e).lower()
|
| 71 |
+
error_type = "unknown"
|
| 72 |
+
|
| 73 |
+
if "401" in err_str or "auth" in err_str or "invalid" in err_str or "api key" in err_str:
|
| 74 |
+
error_type = "auth"
|
| 75 |
+
elif "402" in err_str or "credit" in err_str or "quota" in err_str or "insufficient" in err_str or "billing" in err_str:
|
| 76 |
+
error_type = "credits"
|
| 77 |
+
elif "429" in err_str or "rate" in err_str:
|
| 78 |
+
error_type = "rate_limit"
|
| 79 |
+
elif "timeout" in err_str or "connect" in err_str or "network" in err_str:
|
| 80 |
+
error_type = "network"
|
| 81 |
+
|
| 82 |
+
logger.warning(f"LLM health check failed ({error_type}): {e}")
|
| 83 |
+
return LLMHealthResponse(
|
| 84 |
+
status="error",
|
| 85 |
+
model=model,
|
| 86 |
+
error=str(e)[:500],
|
| 87 |
+
error_type=error_type,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@router.post("/title")
|
| 92 |
+
async def generate_title(
|
| 93 |
+
request: SubmitRequest, user: dict = Depends(get_current_user)
|
| 94 |
+
) -> dict:
|
| 95 |
+
"""Generate a short title for a chat session based on the first user message."""
|
| 96 |
+
model = session_manager.config.model_name
|
| 97 |
+
try:
|
| 98 |
+
response = await acompletion(
|
| 99 |
+
model=model,
|
| 100 |
+
messages=[
|
| 101 |
+
{
|
| 102 |
+
"role": "system",
|
| 103 |
+
"content": (
|
| 104 |
+
"Generate a very short title (max 6 words) for a chat conversation "
|
| 105 |
+
"that starts with the following user message. "
|
| 106 |
+
"Reply with ONLY the title, no quotes, no punctuation at the end."
|
| 107 |
+
),
|
| 108 |
+
},
|
| 109 |
+
{"role": "user", "content": request.text[:500]},
|
| 110 |
+
],
|
| 111 |
+
max_tokens=20,
|
| 112 |
+
temperature=0.3,
|
| 113 |
+
timeout=8,
|
| 114 |
+
)
|
| 115 |
+
title = response.choices[0].message.content.strip().strip('"').strip("'")
|
| 116 |
+
# Safety: cap at 50 chars
|
| 117 |
+
if len(title) > 50:
|
| 118 |
+
title = title[:50].rstrip() + "…"
|
| 119 |
+
return {"title": title}
|
| 120 |
+
except Exception as e:
|
| 121 |
+
logger.warning(f"Title generation failed: {e}")
|
| 122 |
+
# Fallback: truncate the message
|
| 123 |
+
fallback = request.text.strip()
|
| 124 |
+
title = fallback[:40].rstrip() + "…" if len(fallback) > 40 else fallback
|
| 125 |
+
return {"title": title}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
@router.post("/session", response_model=SessionResponse)
|
| 129 |
+
async def create_session(user: dict = Depends(get_current_user)) -> SessionResponse:
|
| 130 |
+
"""Create a new agent session bound to the authenticated user.
|
| 131 |
+
|
| 132 |
+
Returns 503 if the server or user has reached the session limit.
|
| 133 |
+
"""
|
| 134 |
+
try:
|
| 135 |
+
session_id = await session_manager.create_session(user_id=user["user_id"])
|
| 136 |
+
except SessionCapacityError as e:
|
| 137 |
+
raise HTTPException(status_code=503, detail=str(e))
|
| 138 |
return SessionResponse(session_id=session_id, ready=True)
|
| 139 |
|
| 140 |
|
| 141 |
@router.get("/session/{session_id}", response_model=SessionInfo)
|
| 142 |
+
async def get_session(
|
| 143 |
+
session_id: str, user: dict = Depends(get_current_user)
|
| 144 |
+
) -> SessionInfo:
|
| 145 |
+
"""Get session information. Only accessible by the session owner."""
|
| 146 |
+
_check_session_access(session_id, user)
|
| 147 |
info = session_manager.get_session_info(session_id)
|
|
|
|
|
|
|
| 148 |
return SessionInfo(**info)
|
| 149 |
|
| 150 |
|
| 151 |
@router.get("/sessions", response_model=list[SessionInfo])
|
| 152 |
+
async def list_sessions(user: dict = Depends(get_current_user)) -> list[SessionInfo]:
|
| 153 |
+
"""List sessions belonging to the authenticated user."""
|
| 154 |
+
sessions = session_manager.list_sessions(user_id=user["user_id"])
|
| 155 |
return [SessionInfo(**s) for s in sessions]
|
| 156 |
|
| 157 |
|
| 158 |
@router.delete("/session/{session_id}")
|
| 159 |
+
async def delete_session(
|
| 160 |
+
session_id: str, user: dict = Depends(get_current_user)
|
| 161 |
+
) -> dict:
|
| 162 |
+
"""Delete a session. Only accessible by the session owner."""
|
| 163 |
+
_check_session_access(session_id, user)
|
| 164 |
success = await session_manager.delete_session(session_id)
|
| 165 |
if not success:
|
| 166 |
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
| 168 |
|
| 169 |
|
| 170 |
@router.post("/submit")
|
| 171 |
+
async def submit_input(
|
| 172 |
+
request: SubmitRequest, user: dict = Depends(get_current_user)
|
| 173 |
+
) -> dict:
|
| 174 |
+
"""Submit user input to a session. Only accessible by the session owner."""
|
| 175 |
+
_check_session_access(request.session_id, user)
|
| 176 |
success = await session_manager.submit_user_input(request.session_id, request.text)
|
| 177 |
if not success:
|
| 178 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
|
|
| 180 |
|
| 181 |
|
| 182 |
@router.post("/approve")
|
| 183 |
+
async def submit_approval(
|
| 184 |
+
request: ApprovalRequest, user: dict = Depends(get_current_user)
|
| 185 |
+
) -> dict:
|
| 186 |
+
"""Submit tool approvals to a session. Only accessible by the session owner."""
|
| 187 |
+
_check_session_access(request.session_id, user)
|
| 188 |
approvals = [
|
| 189 |
{
|
| 190 |
"tool_call_id": a.tool_call_id,
|
|
|
|
| 200 |
|
| 201 |
|
| 202 |
@router.post("/interrupt/{session_id}")
|
| 203 |
+
async def interrupt_session(
|
| 204 |
+
session_id: str, user: dict = Depends(get_current_user)
|
| 205 |
+
) -> dict:
|
| 206 |
"""Interrupt the current operation in a session."""
|
| 207 |
+
_check_session_access(session_id, user)
|
| 208 |
success = await session_manager.interrupt(session_id)
|
| 209 |
if not success:
|
| 210 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
|
|
| 212 |
|
| 213 |
|
| 214 |
@router.post("/undo/{session_id}")
|
| 215 |
+
async def undo_session(
|
| 216 |
+
session_id: str, user: dict = Depends(get_current_user)
|
| 217 |
+
) -> dict:
|
| 218 |
"""Undo the last turn in a session."""
|
| 219 |
+
_check_session_access(session_id, user)
|
| 220 |
success = await session_manager.undo(session_id)
|
| 221 |
if not success:
|
| 222 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
|
|
| 224 |
|
| 225 |
|
| 226 |
@router.post("/compact/{session_id}")
|
| 227 |
+
async def compact_session(
|
| 228 |
+
session_id: str, user: dict = Depends(get_current_user)
|
| 229 |
+
) -> dict:
|
| 230 |
"""Compact the context in a session."""
|
| 231 |
+
_check_session_access(session_id, user)
|
| 232 |
success = await session_manager.compact(session_id)
|
| 233 |
if not success:
|
| 234 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
|
|
| 236 |
|
| 237 |
|
| 238 |
@router.post("/shutdown/{session_id}")
|
| 239 |
+
async def shutdown_session(
|
| 240 |
+
session_id: str, user: dict = Depends(get_current_user)
|
| 241 |
+
) -> dict:
|
| 242 |
"""Shutdown a session."""
|
| 243 |
+
_check_session_access(session_id, user)
|
| 244 |
success = await session_manager.shutdown_session(session_id)
|
| 245 |
if not success:
|
| 246 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
|
|
| 249 |
|
| 250 |
@router.websocket("/ws/{session_id}")
|
| 251 |
async def websocket_endpoint(websocket: WebSocket, session_id: str) -> None:
|
| 252 |
+
"""WebSocket endpoint for real-time events.
|
| 253 |
+
|
| 254 |
+
Authentication is done via:
|
| 255 |
+
- ?token= query parameter (for browsers that can't send WS headers)
|
| 256 |
+
- Cookie (automatic for same-origin connections)
|
| 257 |
+
- Dev mode bypass (when OAUTH_CLIENT_ID is not set)
|
| 258 |
+
|
| 259 |
+
NOTE: We must accept() before close() so the browser receives our custom
|
| 260 |
+
close codes (4001, 4003, 4004). If we close() before accept(), Starlette
|
| 261 |
+
sends HTTP 403 and the browser only sees code 1006 (abnormal closure).
|
| 262 |
+
"""
|
| 263 |
logger.info(f"WebSocket connection request for session {session_id}")
|
| 264 |
+
|
| 265 |
+
# Authenticate the WebSocket connection
|
| 266 |
+
user = await get_ws_user(websocket)
|
| 267 |
+
if not user:
|
| 268 |
+
logger.warning(f"WebSocket rejected: authentication failed for session {session_id}")
|
| 269 |
+
await websocket.accept()
|
| 270 |
+
await websocket.close(code=4001, reason="Authentication required")
|
| 271 |
+
return
|
| 272 |
+
|
| 273 |
# Verify session exists
|
| 274 |
info = session_manager.get_session_info(session_id)
|
| 275 |
if not info:
|
| 276 |
+
logger.warning(f"WebSocket rejected: session {session_id} not found")
|
| 277 |
+
await websocket.accept()
|
| 278 |
await websocket.close(code=4004, reason="Session not found")
|
| 279 |
return
|
| 280 |
|
| 281 |
+
# Verify user owns the session
|
| 282 |
+
if not session_manager.verify_session_access(session_id, user["user_id"]):
|
| 283 |
+
logger.warning(
|
| 284 |
+
f"WebSocket rejected: user {user['user_id']} denied access to session {session_id}"
|
| 285 |
+
)
|
| 286 |
+
await websocket.accept()
|
| 287 |
+
await websocket.close(code=4003, reason="Access denied")
|
| 288 |
+
return
|
| 289 |
+
|
| 290 |
await ws_manager.connect(websocket, session_id)
|
| 291 |
|
| 292 |
+
# Send "ready" immediately on WebSocket connection so the frontend
|
| 293 |
+
# knows the session is alive. The original ready event from _run_session
|
| 294 |
+
# fires before the WS is connected and is always lost.
|
| 295 |
+
try:
|
| 296 |
+
await websocket.send_json({
|
| 297 |
+
"event_type": "ready",
|
| 298 |
+
"data": {"message": "Agent initialized"},
|
| 299 |
+
})
|
| 300 |
+
except Exception as e:
|
| 301 |
+
logger.error(f"Failed to send ready event for session {session_id}: {e}")
|
| 302 |
+
|
| 303 |
try:
|
| 304 |
while True:
|
| 305 |
# Keep connection alive, handle ping/pong
|
|
@@ -1,13 +1,20 @@
|
|
| 1 |
-
"""Authentication routes for HF OAuth.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import os
|
| 4 |
import secrets
|
|
|
|
| 5 |
from urllib.parse import urlencode
|
| 6 |
|
| 7 |
import httpx
|
| 8 |
-
from fastapi import APIRouter, HTTPException, Request
|
| 9 |
from fastapi.responses import RedirectResponse
|
| 10 |
|
|
|
|
|
|
|
| 11 |
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 12 |
|
| 13 |
# OAuth configuration from environment
|
|
@@ -15,10 +22,19 @@ OAUTH_CLIENT_ID = os.environ.get("OAUTH_CLIENT_ID", "")
|
|
| 15 |
OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
|
| 16 |
OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
|
| 17 |
|
| 18 |
-
# In-memory
|
|
|
|
| 19 |
oauth_states: dict[str, dict] = {}
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
def get_redirect_uri(request: Request) -> str:
|
| 23 |
"""Get the OAuth callback redirect URI."""
|
| 24 |
# In HF Spaces, use the SPACE_HOST if available
|
|
@@ -38,9 +54,15 @@ async def oauth_login(request: Request) -> RedirectResponse:
|
|
| 38 |
detail="OAuth not configured. Set OAUTH_CLIENT_ID environment variable.",
|
| 39 |
)
|
| 40 |
|
|
|
|
|
|
|
|
|
|
| 41 |
# Generate state for CSRF protection
|
| 42 |
state = secrets.token_urlsafe(32)
|
| 43 |
-
oauth_states[state] = {
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
# Build authorization URL
|
| 46 |
params = {
|
|
@@ -91,58 +113,57 @@ async def oauth_callback(
|
|
| 91 |
|
| 92 |
# Get user info
|
| 93 |
access_token = token_data.get("access_token")
|
| 94 |
-
if access_token:
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
headers={"Authorization": f"Bearer {access_token}"},
|
| 100 |
-
)
|
| 101 |
-
userinfo_response.raise_for_status()
|
| 102 |
-
user_info = userinfo_response.json()
|
| 103 |
-
except httpx.HTTPError:
|
| 104 |
-
user_info = {}
|
| 105 |
-
else:
|
| 106 |
-
user_info = {}
|
| 107 |
-
|
| 108 |
-
# For now, redirect to home with token in query params
|
| 109 |
-
# In production, use secure cookies or session storage
|
| 110 |
-
redirect_params = {
|
| 111 |
-
"access_token": access_token,
|
| 112 |
-
"username": user_info.get("preferred_username", ""),
|
| 113 |
-
}
|
| 114 |
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
|
| 118 |
@router.get("/logout")
|
| 119 |
async def logout() -> RedirectResponse:
|
| 120 |
-
"""Log out the user."""
|
| 121 |
-
|
|
|
|
|
|
|
| 122 |
|
| 123 |
|
| 124 |
-
@router.get("/
|
| 125 |
-
async def
|
| 126 |
-
"""
|
| 127 |
-
|
| 128 |
-
if not auth_header.startswith("Bearer "):
|
| 129 |
-
return {"authenticated": False}
|
| 130 |
|
| 131 |
-
token = auth_header.split(" ")[1]
|
| 132 |
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
user_info = response.json()
|
| 141 |
-
return {
|
| 142 |
-
"authenticated": True,
|
| 143 |
-
"username": user_info.get("preferred_username"),
|
| 144 |
-
"name": user_info.get("name"),
|
| 145 |
-
"picture": user_info.get("picture"),
|
| 146 |
-
}
|
| 147 |
-
except httpx.HTTPError:
|
| 148 |
-
return {"authenticated": False}
|
|
|
|
| 1 |
+
"""Authentication routes for HF OAuth.
|
| 2 |
+
|
| 3 |
+
Handles the OAuth 2.0 authorization code flow with HF as provider.
|
| 4 |
+
After successful auth, sets an HttpOnly cookie with the access token.
|
| 5 |
+
"""
|
| 6 |
|
| 7 |
import os
|
| 8 |
import secrets
|
| 9 |
+
import time
|
| 10 |
from urllib.parse import urlencode
|
| 11 |
|
| 12 |
import httpx
|
| 13 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 14 |
from fastapi.responses import RedirectResponse
|
| 15 |
|
| 16 |
+
from dependencies import AUTH_ENABLED, get_current_user
|
| 17 |
+
|
| 18 |
router = APIRouter(prefix="/auth", tags=["auth"])
|
| 19 |
|
| 20 |
# OAuth configuration from environment
|
|
|
|
| 22 |
OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
|
| 23 |
OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
|
| 24 |
|
| 25 |
+
# In-memory OAuth state store with expiry (5 min TTL)
|
| 26 |
+
_OAUTH_STATE_TTL = 300
|
| 27 |
oauth_states: dict[str, dict] = {}
|
| 28 |
|
| 29 |
|
| 30 |
+
def _cleanup_expired_states() -> None:
|
| 31 |
+
"""Remove expired OAuth states to prevent memory growth."""
|
| 32 |
+
now = time.time()
|
| 33 |
+
expired = [k for k, v in oauth_states.items() if now > v.get("expires_at", 0)]
|
| 34 |
+
for k in expired:
|
| 35 |
+
del oauth_states[k]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
def get_redirect_uri(request: Request) -> str:
|
| 39 |
"""Get the OAuth callback redirect URI."""
|
| 40 |
# In HF Spaces, use the SPACE_HOST if available
|
|
|
|
| 54 |
detail="OAuth not configured. Set OAUTH_CLIENT_ID environment variable.",
|
| 55 |
)
|
| 56 |
|
| 57 |
+
# Clean up expired states to prevent memory growth
|
| 58 |
+
_cleanup_expired_states()
|
| 59 |
+
|
| 60 |
# Generate state for CSRF protection
|
| 61 |
state = secrets.token_urlsafe(32)
|
| 62 |
+
oauth_states[state] = {
|
| 63 |
+
"redirect_uri": get_redirect_uri(request),
|
| 64 |
+
"expires_at": time.time() + _OAUTH_STATE_TTL,
|
| 65 |
+
}
|
| 66 |
|
| 67 |
# Build authorization URL
|
| 68 |
params = {
|
|
|
|
| 113 |
|
| 114 |
# Get user info
|
| 115 |
access_token = token_data.get("access_token")
|
| 116 |
+
if not access_token:
|
| 117 |
+
raise HTTPException(
|
| 118 |
+
status_code=500,
|
| 119 |
+
detail="Token exchange succeeded but no access_token was returned.",
|
| 120 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
+
# Fetch user info (optional — failure is not fatal)
|
| 123 |
+
async with httpx.AsyncClient() as client:
|
| 124 |
+
try:
|
| 125 |
+
userinfo_response = await client.get(
|
| 126 |
+
f"{OPENID_PROVIDER_URL}/oauth/userinfo",
|
| 127 |
+
headers={"Authorization": f"Bearer {access_token}"},
|
| 128 |
+
)
|
| 129 |
+
userinfo_response.raise_for_status()
|
| 130 |
+
except httpx.HTTPError:
|
| 131 |
+
pass # user_info not required for auth flow
|
| 132 |
+
|
| 133 |
+
# Set access token as HttpOnly cookie (not in URL — avoids leaks via
|
| 134 |
+
# Referrer headers, browser history, and server logs)
|
| 135 |
+
is_production = bool(os.environ.get("SPACE_HOST"))
|
| 136 |
+
response = RedirectResponse(url="/", status_code=302)
|
| 137 |
+
response.set_cookie(
|
| 138 |
+
key="hf_access_token",
|
| 139 |
+
value=access_token,
|
| 140 |
+
httponly=True,
|
| 141 |
+
secure=is_production, # Secure flag only in production (HTTPS)
|
| 142 |
+
samesite="lax",
|
| 143 |
+
max_age=3600 * 24, # 24 hours
|
| 144 |
+
path="/",
|
| 145 |
+
)
|
| 146 |
+
return response
|
| 147 |
|
| 148 |
|
| 149 |
@router.get("/logout")
|
| 150 |
async def logout() -> RedirectResponse:
|
| 151 |
+
"""Log out the user by clearing the auth cookie."""
|
| 152 |
+
response = RedirectResponse(url="/")
|
| 153 |
+
response.delete_cookie(key="hf_access_token", path="/")
|
| 154 |
+
return response
|
| 155 |
|
| 156 |
|
| 157 |
+
@router.get("/status")
|
| 158 |
+
async def auth_status() -> dict:
|
| 159 |
+
"""Check if OAuth is enabled on this instance."""
|
| 160 |
+
return {"auth_enabled": AUTH_ENABLED}
|
|
|
|
|
|
|
| 161 |
|
|
|
|
| 162 |
|
| 163 |
+
@router.get("/me")
|
| 164 |
+
async def get_me(user: dict = Depends(get_current_user)) -> dict:
|
| 165 |
+
"""Get current user info. Returns the authenticated user or dev user.
|
| 166 |
+
|
| 167 |
+
Uses the shared auth dependency which handles cookie + Bearer token.
|
| 168 |
+
"""
|
| 169 |
+
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -48,11 +48,27 @@ class AgentSession:
|
|
| 48 |
session: Session
|
| 49 |
tool_router: ToolRouter
|
| 50 |
submission_queue: asyncio.Queue
|
|
|
|
| 51 |
task: asyncio.Task | None = None
|
| 52 |
created_at: datetime = field(default_factory=datetime.utcnow)
|
| 53 |
is_active: bool = True
|
| 54 |
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
class SessionManager:
|
| 57 |
"""Manages multiple concurrent agent sessions."""
|
| 58 |
|
|
@@ -61,19 +77,66 @@ class SessionManager:
|
|
| 61 |
self.sessions: dict[str, AgentSession] = {}
|
| 62 |
self._lock = asyncio.Lock()
|
| 63 |
|
| 64 |
-
|
| 65 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
session_id = str(uuid.uuid4())
|
| 67 |
|
| 68 |
# Create queues for this session
|
| 69 |
submission_queue: asyncio.Queue = asyncio.Queue()
|
| 70 |
event_queue: asyncio.Queue = asyncio.Queue()
|
| 71 |
|
| 72 |
-
#
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
-
|
| 76 |
-
session = Session(event_queue, config=self.config, tool_router=tool_router)
|
| 77 |
|
| 78 |
# Create wrapper
|
| 79 |
agent_session = AgentSession(
|
|
@@ -81,6 +144,7 @@ class SessionManager:
|
|
| 81 |
session=session,
|
| 82 |
tool_router=tool_router,
|
| 83 |
submission_queue=submission_queue,
|
|
|
|
| 84 |
)
|
| 85 |
|
| 86 |
async with self._lock:
|
|
@@ -92,7 +156,7 @@ class SessionManager:
|
|
| 92 |
)
|
| 93 |
agent_session.task = task
|
| 94 |
|
| 95 |
-
logger.info(f"Created session {session_id}")
|
| 96 |
return session_id
|
| 97 |
|
| 98 |
async def _run_session(
|
|
@@ -245,6 +309,27 @@ class SessionManager:
|
|
| 245 |
|
| 246 |
return True
|
| 247 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
def get_session_info(self, session_id: str) -> dict[str, Any] | None:
|
| 249 |
"""Get information about a session."""
|
| 250 |
agent_session = self.sessions.get(session_id)
|
|
@@ -256,15 +341,25 @@ class SessionManager:
|
|
| 256 |
"created_at": agent_session.created_at.isoformat(),
|
| 257 |
"is_active": agent_session.is_active,
|
| 258 |
"message_count": len(agent_session.session.context_manager.items),
|
|
|
|
| 259 |
}
|
| 260 |
|
| 261 |
-
def list_sessions(self) -> list[dict[str, Any]]:
|
| 262 |
-
"""List
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
@property
|
| 270 |
def active_session_count(self) -> int:
|
|
|
|
| 48 |
session: Session
|
| 49 |
tool_router: ToolRouter
|
| 50 |
submission_queue: asyncio.Queue
|
| 51 |
+
user_id: str = "dev" # Owner of this session
|
| 52 |
task: asyncio.Task | None = None
|
| 53 |
created_at: datetime = field(default_factory=datetime.utcnow)
|
| 54 |
is_active: bool = True
|
| 55 |
|
| 56 |
|
| 57 |
+
class SessionCapacityError(Exception):
|
| 58 |
+
"""Raised when no more sessions can be created."""
|
| 59 |
+
|
| 60 |
+
def __init__(self, message: str, error_type: str = "global") -> None:
|
| 61 |
+
super().__init__(message)
|
| 62 |
+
self.error_type = error_type # "global" or "per_user"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ── Capacity limits ─────────────────────────────────────────────────
|
| 66 |
+
# Estimated for HF Spaces cpu-basic (2 vCPU, 16 GB RAM).
|
| 67 |
+
# Each session uses ~10-20 MB (context, tools, queues, task).
|
| 68 |
+
MAX_SESSIONS: int = 50
|
| 69 |
+
MAX_SESSIONS_PER_USER: int = 3
|
| 70 |
+
|
| 71 |
+
|
| 72 |
class SessionManager:
|
| 73 |
"""Manages multiple concurrent agent sessions."""
|
| 74 |
|
|
|
|
| 77 |
self.sessions: dict[str, AgentSession] = {}
|
| 78 |
self._lock = asyncio.Lock()
|
| 79 |
|
| 80 |
+
def _count_user_sessions(self, user_id: str) -> int:
|
| 81 |
+
"""Count active sessions owned by a specific user."""
|
| 82 |
+
return sum(
|
| 83 |
+
1
|
| 84 |
+
for s in self.sessions.values()
|
| 85 |
+
if s.user_id == user_id and s.is_active
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
async def create_session(self, user_id: str = "dev") -> str:
|
| 89 |
+
"""Create a new agent session and return its ID.
|
| 90 |
+
|
| 91 |
+
Session() and ToolRouter() constructors contain blocking I/O
|
| 92 |
+
(e.g. HfApi().whoami(), litellm.get_max_tokens()) so they are
|
| 93 |
+
executed in a thread pool to avoid freezing the async event loop.
|
| 94 |
+
|
| 95 |
+
Args:
|
| 96 |
+
user_id: The ID of the user who owns this session.
|
| 97 |
+
|
| 98 |
+
Raises:
|
| 99 |
+
SessionCapacityError: If the server or user has reached the
|
| 100 |
+
maximum number of concurrent sessions.
|
| 101 |
+
"""
|
| 102 |
+
# ── Capacity checks ──────────────────────────────────────────
|
| 103 |
+
async with self._lock:
|
| 104 |
+
active_count = self.active_session_count
|
| 105 |
+
if active_count >= MAX_SESSIONS:
|
| 106 |
+
raise SessionCapacityError(
|
| 107 |
+
f"Server is at capacity ({active_count}/{MAX_SESSIONS} sessions). "
|
| 108 |
+
"Please try again later.",
|
| 109 |
+
error_type="global",
|
| 110 |
+
)
|
| 111 |
+
if user_id != "dev":
|
| 112 |
+
user_count = self._count_user_sessions(user_id)
|
| 113 |
+
if user_count >= MAX_SESSIONS_PER_USER:
|
| 114 |
+
raise SessionCapacityError(
|
| 115 |
+
f"You have reached the maximum of {MAX_SESSIONS_PER_USER} "
|
| 116 |
+
"concurrent sessions. Please close an existing session first.",
|
| 117 |
+
error_type="per_user",
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
session_id = str(uuid.uuid4())
|
| 121 |
|
| 122 |
# Create queues for this session
|
| 123 |
submission_queue: asyncio.Queue = asyncio.Queue()
|
| 124 |
event_queue: asyncio.Queue = asyncio.Queue()
|
| 125 |
|
| 126 |
+
# Run blocking constructors in a thread to keep the event loop responsive.
|
| 127 |
+
# Without this, Session.__init__ → ContextManager → litellm.get_max_tokens()
|
| 128 |
+
# blocks all HTTP/WebSocket handling.
|
| 129 |
+
import time as _time
|
| 130 |
+
|
| 131 |
+
def _create_session_sync():
|
| 132 |
+
t0 = _time.monotonic()
|
| 133 |
+
tool_router = ToolRouter(self.config.mcpServers)
|
| 134 |
+
session = Session(event_queue, config=self.config, tool_router=tool_router)
|
| 135 |
+
t1 = _time.monotonic()
|
| 136 |
+
logger.info(f"Session initialized in {t1 - t0:.2f}s")
|
| 137 |
+
return tool_router, session
|
| 138 |
|
| 139 |
+
tool_router, session = await asyncio.to_thread(_create_session_sync)
|
|
|
|
| 140 |
|
| 141 |
# Create wrapper
|
| 142 |
agent_session = AgentSession(
|
|
|
|
| 144 |
session=session,
|
| 145 |
tool_router=tool_router,
|
| 146 |
submission_queue=submission_queue,
|
| 147 |
+
user_id=user_id,
|
| 148 |
)
|
| 149 |
|
| 150 |
async with self._lock:
|
|
|
|
| 156 |
)
|
| 157 |
agent_session.task = task
|
| 158 |
|
| 159 |
+
logger.info(f"Created session {session_id} for user {user_id}")
|
| 160 |
return session_id
|
| 161 |
|
| 162 |
async def _run_session(
|
|
|
|
| 309 |
|
| 310 |
return True
|
| 311 |
|
| 312 |
+
def get_session_owner(self, session_id: str) -> str | None:
|
| 313 |
+
"""Get the user_id that owns a session, or None if session doesn't exist."""
|
| 314 |
+
agent_session = self.sessions.get(session_id)
|
| 315 |
+
if not agent_session:
|
| 316 |
+
return None
|
| 317 |
+
return agent_session.user_id
|
| 318 |
+
|
| 319 |
+
def verify_session_access(self, session_id: str, user_id: str) -> bool:
|
| 320 |
+
"""Check if a user has access to a session.
|
| 321 |
+
|
| 322 |
+
Returns True if:
|
| 323 |
+
- The session exists AND the user owns it
|
| 324 |
+
- The user_id is "dev" (dev mode bypass)
|
| 325 |
+
"""
|
| 326 |
+
owner = self.get_session_owner(session_id)
|
| 327 |
+
if owner is None:
|
| 328 |
+
return False
|
| 329 |
+
if user_id == "dev" or owner == "dev":
|
| 330 |
+
return True
|
| 331 |
+
return owner == user_id
|
| 332 |
+
|
| 333 |
def get_session_info(self, session_id: str) -> dict[str, Any] | None:
|
| 334 |
"""Get information about a session."""
|
| 335 |
agent_session = self.sessions.get(session_id)
|
|
|
|
| 341 |
"created_at": agent_session.created_at.isoformat(),
|
| 342 |
"is_active": agent_session.is_active,
|
| 343 |
"message_count": len(agent_session.session.context_manager.items),
|
| 344 |
+
"user_id": agent_session.user_id,
|
| 345 |
}
|
| 346 |
|
| 347 |
+
def list_sessions(self, user_id: str | None = None) -> list[dict[str, Any]]:
|
| 348 |
+
"""List sessions, optionally filtered by user.
|
| 349 |
+
|
| 350 |
+
Args:
|
| 351 |
+
user_id: If provided, only return sessions owned by this user.
|
| 352 |
+
If "dev", return all sessions (dev mode).
|
| 353 |
+
"""
|
| 354 |
+
results = []
|
| 355 |
+
for sid in self.sessions:
|
| 356 |
+
info = self.get_session_info(sid)
|
| 357 |
+
if not info:
|
| 358 |
+
continue
|
| 359 |
+
if user_id and user_id != "dev" and info.get("user_id") != user_id:
|
| 360 |
+
continue
|
| 361 |
+
results.append(info)
|
| 362 |
+
return results
|
| 363 |
|
| 364 |
@property
|
| 365 |
def active_session_count(self) -> int:
|
|
@@ -1,6 +1,5 @@
|
|
| 1 |
"""WebSocket connection manager for real-time communication."""
|
| 2 |
|
| 3 |
-
import asyncio
|
| 4 |
import logging
|
| 5 |
from typing import Any
|
| 6 |
|
|
@@ -15,23 +14,18 @@ class ConnectionManager:
|
|
| 15 |
def __init__(self) -> None:
|
| 16 |
# session_id -> WebSocket
|
| 17 |
self.active_connections: dict[str, WebSocket] = {}
|
| 18 |
-
# session_id -> asyncio.Queue for outgoing messages
|
| 19 |
-
self.message_queues: dict[str, asyncio.Queue] = {}
|
| 20 |
|
| 21 |
async def connect(self, websocket: WebSocket, session_id: str) -> None:
|
| 22 |
"""Accept a WebSocket connection and register it."""
|
| 23 |
logger.info(f"Attempting to accept WebSocket for session {session_id}")
|
| 24 |
await websocket.accept()
|
| 25 |
self.active_connections[session_id] = websocket
|
| 26 |
-
self.message_queues[session_id] = asyncio.Queue()
|
| 27 |
logger.info(f"WebSocket connected and registered for session {session_id}")
|
| 28 |
|
| 29 |
def disconnect(self, session_id: str) -> None:
|
| 30 |
"""Remove a WebSocket connection."""
|
| 31 |
if session_id in self.active_connections:
|
| 32 |
del self.active_connections[session_id]
|
| 33 |
-
if session_id in self.message_queues:
|
| 34 |
-
del self.message_queues[session_id]
|
| 35 |
logger.info(f"WebSocket disconnected for session {session_id}")
|
| 36 |
|
| 37 |
async def send_event(
|
|
@@ -63,10 +57,6 @@ class ConnectionManager:
|
|
| 63 |
"""Check if a session has an active WebSocket connection."""
|
| 64 |
return session_id in self.active_connections
|
| 65 |
|
| 66 |
-
def get_queue(self, session_id: str) -> asyncio.Queue | None:
|
| 67 |
-
"""Get the message queue for a session."""
|
| 68 |
-
return self.message_queues.get(session_id)
|
| 69 |
-
|
| 70 |
|
| 71 |
# Global connection manager instance
|
| 72 |
manager = ConnectionManager()
|
|
|
|
| 1 |
"""WebSocket connection manager for real-time communication."""
|
| 2 |
|
|
|
|
| 3 |
import logging
|
| 4 |
from typing import Any
|
| 5 |
|
|
|
|
| 14 |
def __init__(self) -> None:
|
| 15 |
# session_id -> WebSocket
|
| 16 |
self.active_connections: dict[str, WebSocket] = {}
|
|
|
|
|
|
|
| 17 |
|
| 18 |
async def connect(self, websocket: WebSocket, session_id: str) -> None:
|
| 19 |
"""Accept a WebSocket connection and register it."""
|
| 20 |
logger.info(f"Attempting to accept WebSocket for session {session_id}")
|
| 21 |
await websocket.accept()
|
| 22 |
self.active_connections[session_id] = websocket
|
|
|
|
| 23 |
logger.info(f"WebSocket connected and registered for session {session_id}")
|
| 24 |
|
| 25 |
def disconnect(self, session_id: str) -> None:
|
| 26 |
"""Remove a WebSocket connection."""
|
| 27 |
if session_id in self.active_connections:
|
| 28 |
del self.active_connections[session_id]
|
|
|
|
|
|
|
| 29 |
logger.info(f"WebSocket disconnected for session {session_id}")
|
| 30 |
|
| 31 |
async def send_event(
|
|
|
|
| 57 |
"""Check if a session has an active WebSocket connection."""
|
| 58 |
return session_id in self.active_connections
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
# Global connection manager instance
|
| 62 |
manager = ConnectionManager()
|
|
@@ -1,7 +1,12 @@
|
|
| 1 |
import { Box } from '@mui/material';
|
| 2 |
import AppLayout from '@/components/Layout/AppLayout';
|
|
|
|
| 3 |
|
| 4 |
function App() {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
return (
|
| 6 |
<Box sx={{ height: '100vh', display: 'flex' }}>
|
| 7 |
<AppLayout />
|
|
|
|
| 1 |
import { Box } from '@mui/material';
|
| 2 |
import AppLayout from '@/components/Layout/AppLayout';
|
| 3 |
+
import { useAuth } from '@/hooks/useAuth';
|
| 4 |
|
| 5 |
function App() {
|
| 6 |
+
// Non-blocking auth check — fires in background, updates store when done.
|
| 7 |
+
// If auth fails later, apiFetch redirects to /auth/login.
|
| 8 |
+
useAuth();
|
| 9 |
+
|
| 10 |
return (
|
| 11 |
<Box sx={{ height: '100vh', display: 'flex' }}>
|
| 12 |
<AppLayout />
|
|
@@ -1,208 +0,0 @@
|
|
| 1 |
-
import { useState, useCallback } from 'react';
|
| 2 |
-
import {
|
| 3 |
-
Dialog,
|
| 4 |
-
DialogTitle,
|
| 5 |
-
DialogContent,
|
| 6 |
-
DialogActions,
|
| 7 |
-
Button,
|
| 8 |
-
Box,
|
| 9 |
-
Typography,
|
| 10 |
-
Checkbox,
|
| 11 |
-
FormControlLabel,
|
| 12 |
-
Accordion,
|
| 13 |
-
AccordionSummary,
|
| 14 |
-
AccordionDetails,
|
| 15 |
-
TextField,
|
| 16 |
-
Chip,
|
| 17 |
-
} from '@mui/material';
|
| 18 |
-
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
| 19 |
-
import WarningIcon from '@mui/icons-material/Warning';
|
| 20 |
-
import { useAgentStore } from '@/store/agentStore';
|
| 21 |
-
|
| 22 |
-
interface ApprovalModalProps {
|
| 23 |
-
sessionId: string | null;
|
| 24 |
-
}
|
| 25 |
-
|
| 26 |
-
interface ApprovalState {
|
| 27 |
-
[toolCallId: string]: {
|
| 28 |
-
approved: boolean;
|
| 29 |
-
feedback: string;
|
| 30 |
-
};
|
| 31 |
-
}
|
| 32 |
-
|
| 33 |
-
export default function ApprovalModal({ sessionId }: ApprovalModalProps) {
|
| 34 |
-
const { pendingApprovals, setPendingApprovals } = useAgentStore();
|
| 35 |
-
const [approvalState, setApprovalState] = useState<ApprovalState>({});
|
| 36 |
-
|
| 37 |
-
const isOpen = pendingApprovals !== null && pendingApprovals.tools.length > 0;
|
| 38 |
-
|
| 39 |
-
const handleApprovalChange = useCallback(
|
| 40 |
-
(toolCallId: string, approved: boolean) => {
|
| 41 |
-
setApprovalState((prev) => ({
|
| 42 |
-
...prev,
|
| 43 |
-
[toolCallId]: {
|
| 44 |
-
...prev[toolCallId],
|
| 45 |
-
approved,
|
| 46 |
-
feedback: prev[toolCallId]?.feedback || '',
|
| 47 |
-
},
|
| 48 |
-
}));
|
| 49 |
-
},
|
| 50 |
-
[]
|
| 51 |
-
);
|
| 52 |
-
|
| 53 |
-
const handleFeedbackChange = useCallback(
|
| 54 |
-
(toolCallId: string, feedback: string) => {
|
| 55 |
-
setApprovalState((prev) => ({
|
| 56 |
-
...prev,
|
| 57 |
-
[toolCallId]: {
|
| 58 |
-
...prev[toolCallId],
|
| 59 |
-
feedback,
|
| 60 |
-
},
|
| 61 |
-
}));
|
| 62 |
-
},
|
| 63 |
-
[]
|
| 64 |
-
);
|
| 65 |
-
|
| 66 |
-
const handleSubmit = useCallback(async () => {
|
| 67 |
-
if (!sessionId || !pendingApprovals) return;
|
| 68 |
-
|
| 69 |
-
const approvals = pendingApprovals.tools.map((tool) => ({
|
| 70 |
-
tool_call_id: tool.tool_call_id,
|
| 71 |
-
approved: approvalState[tool.tool_call_id]?.approved ?? false,
|
| 72 |
-
feedback: approvalState[tool.tool_call_id]?.feedback || null,
|
| 73 |
-
}));
|
| 74 |
-
|
| 75 |
-
try {
|
| 76 |
-
await fetch('/api/approve', {
|
| 77 |
-
method: 'POST',
|
| 78 |
-
headers: { 'Content-Type': 'application/json' },
|
| 79 |
-
body: JSON.stringify({
|
| 80 |
-
session_id: sessionId,
|
| 81 |
-
approvals,
|
| 82 |
-
}),
|
| 83 |
-
});
|
| 84 |
-
setPendingApprovals(null);
|
| 85 |
-
setApprovalState({});
|
| 86 |
-
} catch (e) {
|
| 87 |
-
console.error('Approval submission failed:', e);
|
| 88 |
-
}
|
| 89 |
-
}, [sessionId, pendingApprovals, approvalState, setPendingApprovals]);
|
| 90 |
-
|
| 91 |
-
const handleApproveAll = useCallback(() => {
|
| 92 |
-
if (!pendingApprovals) return;
|
| 93 |
-
const newState: ApprovalState = {};
|
| 94 |
-
pendingApprovals.tools.forEach((tool) => {
|
| 95 |
-
newState[tool.tool_call_id] = { approved: true, feedback: '' };
|
| 96 |
-
});
|
| 97 |
-
setApprovalState(newState);
|
| 98 |
-
}, [pendingApprovals]);
|
| 99 |
-
|
| 100 |
-
const handleRejectAll = useCallback(() => {
|
| 101 |
-
if (!pendingApprovals) return;
|
| 102 |
-
const newState: ApprovalState = {};
|
| 103 |
-
pendingApprovals.tools.forEach((tool) => {
|
| 104 |
-
newState[tool.tool_call_id] = { approved: false, feedback: '' };
|
| 105 |
-
});
|
| 106 |
-
setApprovalState(newState);
|
| 107 |
-
}, [pendingApprovals]);
|
| 108 |
-
|
| 109 |
-
if (!isOpen || !pendingApprovals) return null;
|
| 110 |
-
|
| 111 |
-
const approvedCount = Object.values(approvalState).filter((s) => s.approved).length;
|
| 112 |
-
|
| 113 |
-
return (
|
| 114 |
-
<Dialog
|
| 115 |
-
open={isOpen}
|
| 116 |
-
maxWidth="md"
|
| 117 |
-
fullWidth
|
| 118 |
-
PaperProps={{
|
| 119 |
-
sx: { bgcolor: 'background.paper' },
|
| 120 |
-
}}
|
| 121 |
-
>
|
| 122 |
-
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
| 123 |
-
<WarningIcon color="warning" />
|
| 124 |
-
Approval Required
|
| 125 |
-
<Chip
|
| 126 |
-
label={`${pendingApprovals.count} tool${pendingApprovals.count > 1 ? 's' : ''}`}
|
| 127 |
-
size="small"
|
| 128 |
-
sx={{ ml: 1 }}
|
| 129 |
-
/>
|
| 130 |
-
</DialogTitle>
|
| 131 |
-
<DialogContent dividers>
|
| 132 |
-
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
| 133 |
-
The following tool calls require your approval before execution:
|
| 134 |
-
</Typography>
|
| 135 |
-
{pendingApprovals.tools.map((tool, index) => (
|
| 136 |
-
<Accordion key={tool.tool_call_id} defaultExpanded={index === 0}>
|
| 137 |
-
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
| 138 |
-
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
|
| 139 |
-
<FormControlLabel
|
| 140 |
-
control={
|
| 141 |
-
<Checkbox
|
| 142 |
-
checked={approvalState[tool.tool_call_id]?.approved ?? false}
|
| 143 |
-
onChange={(e) => {
|
| 144 |
-
e.stopPropagation();
|
| 145 |
-
handleApprovalChange(tool.tool_call_id, e.target.checked);
|
| 146 |
-
}}
|
| 147 |
-
onClick={(e) => e.stopPropagation()}
|
| 148 |
-
/>
|
| 149 |
-
}
|
| 150 |
-
label=""
|
| 151 |
-
sx={{ m: 0 }}
|
| 152 |
-
/>
|
| 153 |
-
<Chip label={tool.tool} size="small" color="primary" variant="outlined" />
|
| 154 |
-
<Typography variant="body2" color="text.secondary" sx={{ ml: 'auto' }}>
|
| 155 |
-
{approvalState[tool.tool_call_id]?.approved ? 'Approved' : 'Pending'}
|
| 156 |
-
</Typography>
|
| 157 |
-
</Box>
|
| 158 |
-
</AccordionSummary>
|
| 159 |
-
<AccordionDetails>
|
| 160 |
-
<Typography variant="subtitle2" gutterBottom>
|
| 161 |
-
Arguments:
|
| 162 |
-
</Typography>
|
| 163 |
-
<Box
|
| 164 |
-
component="pre"
|
| 165 |
-
sx={{
|
| 166 |
-
bgcolor: 'background.default',
|
| 167 |
-
p: 1.5,
|
| 168 |
-
borderRadius: 1,
|
| 169 |
-
overflow: 'auto',
|
| 170 |
-
fontSize: '0.8rem',
|
| 171 |
-
maxHeight: 200,
|
| 172 |
-
}}
|
| 173 |
-
>
|
| 174 |
-
{JSON.stringify(tool.arguments, null, 2)}
|
| 175 |
-
</Box>
|
| 176 |
-
{!approvalState[tool.tool_call_id]?.approved && (
|
| 177 |
-
<TextField
|
| 178 |
-
fullWidth
|
| 179 |
-
size="small"
|
| 180 |
-
label="Feedback (optional)"
|
| 181 |
-
placeholder="Explain why you're rejecting this..."
|
| 182 |
-
value={approvalState[tool.tool_call_id]?.feedback || ''}
|
| 183 |
-
onChange={(e) => handleFeedbackChange(tool.tool_call_id, e.target.value)}
|
| 184 |
-
sx={{ mt: 2 }}
|
| 185 |
-
/>
|
| 186 |
-
)}
|
| 187 |
-
</AccordionDetails>
|
| 188 |
-
</Accordion>
|
| 189 |
-
))}
|
| 190 |
-
</DialogContent>
|
| 191 |
-
<DialogActions sx={{ px: 3, py: 2 }}>
|
| 192 |
-
<Button onClick={handleRejectAll} color="error" variant="outlined">
|
| 193 |
-
Reject All
|
| 194 |
-
</Button>
|
| 195 |
-
<Button onClick={handleApproveAll} color="success" variant="outlined">
|
| 196 |
-
Approve All
|
| 197 |
-
</Button>
|
| 198 |
-
<Box sx={{ flex: 1 }} />
|
| 199 |
-
<Typography variant="body2" color="text.secondary" sx={{ mr: 2 }}>
|
| 200 |
-
{approvedCount} of {pendingApprovals.count} approved
|
| 201 |
-
</Typography>
|
| 202 |
-
<Button onClick={handleSubmit} variant="contained" color="primary">
|
| 203 |
-
Submit
|
| 204 |
-
</Button>
|
| 205 |
-
</DialogActions>
|
| 206 |
-
</Dialog>
|
| 207 |
-
);
|
| 208 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,515 +0,0 @@
|
|
| 1 |
-
import { useState, useCallback, useEffect } from 'react';
|
| 2 |
-
import { Box, Typography, Button, TextField, IconButton, Link } from '@mui/material';
|
| 3 |
-
import SendIcon from '@mui/icons-material/Send';
|
| 4 |
-
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
| 5 |
-
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
| 6 |
-
import CancelIcon from '@mui/icons-material/Cancel';
|
| 7 |
-
import LaunchIcon from '@mui/icons-material/Launch';
|
| 8 |
-
import { useAgentStore } from '@/store/agentStore';
|
| 9 |
-
import { useLayoutStore } from '@/store/layoutStore';
|
| 10 |
-
import { useSessionStore } from '@/store/sessionStore';
|
| 11 |
-
import type { Message, ToolApproval } from '@/types/agent';
|
| 12 |
-
|
| 13 |
-
interface ApprovalFlowProps {
|
| 14 |
-
message: Message;
|
| 15 |
-
}
|
| 16 |
-
|
| 17 |
-
export default function ApprovalFlow({ message }: ApprovalFlowProps) {
|
| 18 |
-
const { setPanelContent, setPanelTab, setActivePanelTab, clearPanelTabs, updateMessage } = useAgentStore();
|
| 19 |
-
const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
|
| 20 |
-
const { activeSessionId } = useSessionStore();
|
| 21 |
-
const [currentIndex, setCurrentIndex] = useState(0);
|
| 22 |
-
const [feedback, setFeedback] = useState('');
|
| 23 |
-
const [decisions, setDecisions] = useState<ToolApproval[]>([]);
|
| 24 |
-
|
| 25 |
-
const approvalData = message.approval;
|
| 26 |
-
|
| 27 |
-
if (!approvalData) return null;
|
| 28 |
-
|
| 29 |
-
const { batch, status } = approvalData;
|
| 30 |
-
|
| 31 |
-
// Parse toolOutput to extract job info (URL, status, logs, errors)
|
| 32 |
-
let logsContent = '';
|
| 33 |
-
let showLogsButton = false;
|
| 34 |
-
let jobUrl = '';
|
| 35 |
-
let jobStatus = '';
|
| 36 |
-
let jobFailed = false;
|
| 37 |
-
let errorMessage = '';
|
| 38 |
-
|
| 39 |
-
if (message.toolOutput) {
|
| 40 |
-
const output = message.toolOutput;
|
| 41 |
-
|
| 42 |
-
// Extract job URL: **View at:** https://...
|
| 43 |
-
const urlMatch = output.match(/\*\*View at:\*\*\s*(https:\/\/[^\s\n]+)/);
|
| 44 |
-
if (urlMatch) {
|
| 45 |
-
jobUrl = urlMatch[1];
|
| 46 |
-
}
|
| 47 |
-
|
| 48 |
-
// Extract job status: **Final Status:** ...
|
| 49 |
-
const statusMatch = output.match(/\*\*Final Status:\*\*\s*([^\n]+)/);
|
| 50 |
-
if (statusMatch) {
|
| 51 |
-
jobStatus = statusMatch[1].trim();
|
| 52 |
-
jobFailed = jobStatus.toLowerCase().includes('error') || jobStatus.toLowerCase().includes('failed');
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
// Extract logs
|
| 56 |
-
if (output.includes('**Logs:**')) {
|
| 57 |
-
const parts = output.split('**Logs:**');
|
| 58 |
-
if (parts.length > 1) {
|
| 59 |
-
const logsPart = parts[1].trim();
|
| 60 |
-
const codeBlockMatch = logsPart.match(/```([\s\S]*?)```/);
|
| 61 |
-
if (codeBlockMatch) {
|
| 62 |
-
logsContent = codeBlockMatch[1].trim();
|
| 63 |
-
showLogsButton = true;
|
| 64 |
-
}
|
| 65 |
-
}
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
// Detect errors - if output exists but doesn't have the expected job completion format
|
| 69 |
-
// This catches early failures (validation errors, API errors, etc.)
|
| 70 |
-
const isExpectedFormat = output.includes('**Job ID:**') || output.includes('**View at:**');
|
| 71 |
-
const looksLikeError = output.toLowerCase().includes('error') ||
|
| 72 |
-
output.toLowerCase().includes('failed') ||
|
| 73 |
-
output.toLowerCase().includes('exception') ||
|
| 74 |
-
output.includes('Traceback');
|
| 75 |
-
|
| 76 |
-
if (!isExpectedFormat || (looksLikeError && !logsContent)) {
|
| 77 |
-
// This is likely an error message - show it
|
| 78 |
-
errorMessage = output;
|
| 79 |
-
jobFailed = true;
|
| 80 |
-
}
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
-
// Sync right panel with current tool
|
| 84 |
-
useEffect(() => {
|
| 85 |
-
if (!batch || currentIndex >= batch.tools.length) return;
|
| 86 |
-
|
| 87 |
-
// Only auto-open panel if pending
|
| 88 |
-
if (status !== 'pending') return;
|
| 89 |
-
|
| 90 |
-
const tool = batch.tools[currentIndex];
|
| 91 |
-
const args = tool.arguments as any;
|
| 92 |
-
|
| 93 |
-
if (tool.tool === 'hf_jobs' && (args.operation === 'run' || args.operation === 'scheduled run') && args.script) {
|
| 94 |
-
setPanelContent({
|
| 95 |
-
title: 'Compute Job Script',
|
| 96 |
-
content: args.script,
|
| 97 |
-
language: 'python',
|
| 98 |
-
parameters: args
|
| 99 |
-
});
|
| 100 |
-
// Don't auto-open if already resolved
|
| 101 |
-
} else if (tool.tool === 'hf_repo_files' && args.operation === 'upload' && args.content) {
|
| 102 |
-
setPanelContent({
|
| 103 |
-
title: `File Upload: ${args.path || 'unnamed'}`,
|
| 104 |
-
content: args.content,
|
| 105 |
-
parameters: args
|
| 106 |
-
});
|
| 107 |
-
}
|
| 108 |
-
}, [currentIndex, batch, status, setPanelContent]);
|
| 109 |
-
|
| 110 |
-
const handleResolve = useCallback(async (approved: boolean) => {
|
| 111 |
-
if (!batch || !activeSessionId) return;
|
| 112 |
-
|
| 113 |
-
const currentTool = batch.tools[currentIndex];
|
| 114 |
-
const newDecisions = [
|
| 115 |
-
...decisions,
|
| 116 |
-
{
|
| 117 |
-
tool_call_id: currentTool.tool_call_id,
|
| 118 |
-
approved,
|
| 119 |
-
feedback: approved ? null : feedback || 'Rejected by user',
|
| 120 |
-
},
|
| 121 |
-
];
|
| 122 |
-
|
| 123 |
-
if (currentIndex < batch.tools.length - 1) {
|
| 124 |
-
setDecisions(newDecisions);
|
| 125 |
-
setCurrentIndex(currentIndex + 1);
|
| 126 |
-
setFeedback('');
|
| 127 |
-
} else {
|
| 128 |
-
// All tools in batch resolved
|
| 129 |
-
try {
|
| 130 |
-
await fetch('/api/approve', {
|
| 131 |
-
method: 'POST',
|
| 132 |
-
headers: { 'Content-Type': 'application/json' },
|
| 133 |
-
body: JSON.stringify({
|
| 134 |
-
session_id: activeSessionId,
|
| 135 |
-
approvals: newDecisions,
|
| 136 |
-
}),
|
| 137 |
-
});
|
| 138 |
-
|
| 139 |
-
// Update message status
|
| 140 |
-
updateMessage(activeSessionId, message.id, {
|
| 141 |
-
approval: {
|
| 142 |
-
...approvalData!,
|
| 143 |
-
status: approved ? 'approved' : 'rejected',
|
| 144 |
-
decisions: newDecisions
|
| 145 |
-
}
|
| 146 |
-
});
|
| 147 |
-
|
| 148 |
-
} catch (e) {
|
| 149 |
-
console.error('Approval submission failed:', e);
|
| 150 |
-
}
|
| 151 |
-
}
|
| 152 |
-
}, [activeSessionId, message.id, batch, currentIndex, feedback, decisions, approvalData, updateMessage]);
|
| 153 |
-
|
| 154 |
-
if (!batch || currentIndex >= batch.tools.length) return null;
|
| 155 |
-
|
| 156 |
-
const currentTool = batch.tools[currentIndex];
|
| 157 |
-
|
| 158 |
-
// Check if script contains push_to_hub or upload_file
|
| 159 |
-
const args = currentTool.arguments as any;
|
| 160 |
-
const containsPushToHub = currentTool.tool === 'hf_jobs' && args.script && (args.script.includes('push_to_hub') || args.script.includes('upload_file'));
|
| 161 |
-
|
| 162 |
-
const getToolDescription = (toolName: string, args: any) => {
|
| 163 |
-
if (toolName === 'hf_jobs') {
|
| 164 |
-
return (
|
| 165 |
-
<Box sx={{ flex: 1 }}>
|
| 166 |
-
<Typography variant="body2" sx={{ color: 'var(--muted-text)' }}>
|
| 167 |
-
The agent wants to execute <Box component="span" sx={{ color: 'var(--accent-yellow)', fontWeight: 500 }}>hf_jobs</Box> on{' '}
|
| 168 |
-
<Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>{args.hardware_flavor || 'default'}</Box> with a timeout of{' '}
|
| 169 |
-
<Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>{args.timeout || '30m'}</Box>
|
| 170 |
-
</Typography>
|
| 171 |
-
</Box>
|
| 172 |
-
);
|
| 173 |
-
}
|
| 174 |
-
return (
|
| 175 |
-
<Typography variant="body2" sx={{ color: 'var(--muted-text)', flex: 1 }}>
|
| 176 |
-
The agent wants to execute <Box component="span" sx={{ color: 'var(--accent-yellow)', fontWeight: 500 }}>{toolName}</Box>
|
| 177 |
-
</Typography>
|
| 178 |
-
);
|
| 179 |
-
};
|
| 180 |
-
|
| 181 |
-
const showCode = () => {
|
| 182 |
-
const args = currentTool.arguments as any;
|
| 183 |
-
if (currentTool.tool === 'hf_jobs' && args.script) {
|
| 184 |
-
// Clear existing tabs and set up script tab (and logs if available)
|
| 185 |
-
clearPanelTabs();
|
| 186 |
-
setPanelTab({
|
| 187 |
-
id: 'script',
|
| 188 |
-
title: 'Script',
|
| 189 |
-
content: args.script,
|
| 190 |
-
language: 'python',
|
| 191 |
-
parameters: args
|
| 192 |
-
});
|
| 193 |
-
// If logs are available (job completed), also add logs tab
|
| 194 |
-
if (logsContent) {
|
| 195 |
-
setPanelTab({
|
| 196 |
-
id: 'logs',
|
| 197 |
-
title: 'Logs',
|
| 198 |
-
content: logsContent,
|
| 199 |
-
language: 'text'
|
| 200 |
-
});
|
| 201 |
-
}
|
| 202 |
-
setActivePanelTab('script');
|
| 203 |
-
setRightPanelOpen(true);
|
| 204 |
-
setLeftSidebarOpen(false);
|
| 205 |
-
} else {
|
| 206 |
-
setPanelContent({
|
| 207 |
-
title: `Tool: ${currentTool.tool}`,
|
| 208 |
-
content: JSON.stringify(args, null, 2),
|
| 209 |
-
language: 'json',
|
| 210 |
-
parameters: args
|
| 211 |
-
});
|
| 212 |
-
setRightPanelOpen(true);
|
| 213 |
-
setLeftSidebarOpen(false);
|
| 214 |
-
}
|
| 215 |
-
};
|
| 216 |
-
|
| 217 |
-
const handleViewLogs = (e: React.MouseEvent) => {
|
| 218 |
-
e.stopPropagation();
|
| 219 |
-
const args = currentTool.arguments as any;
|
| 220 |
-
// Set up both tabs so user can switch between script and logs
|
| 221 |
-
clearPanelTabs();
|
| 222 |
-
if (currentTool.tool === 'hf_jobs' && args.script) {
|
| 223 |
-
setPanelTab({
|
| 224 |
-
id: 'script',
|
| 225 |
-
title: 'Script',
|
| 226 |
-
content: args.script,
|
| 227 |
-
language: 'python',
|
| 228 |
-
parameters: args
|
| 229 |
-
});
|
| 230 |
-
}
|
| 231 |
-
setPanelTab({
|
| 232 |
-
id: 'logs',
|
| 233 |
-
title: 'Logs',
|
| 234 |
-
content: logsContent,
|
| 235 |
-
language: 'text'
|
| 236 |
-
});
|
| 237 |
-
setActivePanelTab('logs');
|
| 238 |
-
setRightPanelOpen(true);
|
| 239 |
-
setLeftSidebarOpen(false);
|
| 240 |
-
};
|
| 241 |
-
|
| 242 |
-
return (
|
| 243 |
-
<Box
|
| 244 |
-
className="action-card"
|
| 245 |
-
sx={{
|
| 246 |
-
width: '100%',
|
| 247 |
-
padding: '18px',
|
| 248 |
-
borderRadius: 'var(--radius-md)',
|
| 249 |
-
background: 'linear-gradient(180deg, rgba(255,255,255,0.015), transparent)',
|
| 250 |
-
border: '1px solid rgba(255,255,255,0.03)',
|
| 251 |
-
display: 'flex',
|
| 252 |
-
flexDirection: 'column',
|
| 253 |
-
gap: '12px',
|
| 254 |
-
opacity: status !== 'pending' && !showLogsButton ? 0.8 : 1
|
| 255 |
-
}}
|
| 256 |
-
>
|
| 257 |
-
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
| 258 |
-
<Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'var(--text)' }}>
|
| 259 |
-
{status === 'pending' ? 'Approval Required' : status === 'approved' ? 'Approved' : 'Rejected'}
|
| 260 |
-
</Typography>
|
| 261 |
-
<Typography variant="caption" sx={{ color: 'var(--muted-text)' }}>
|
| 262 |
-
({currentIndex + 1}/{batch.count})
|
| 263 |
-
</Typography>
|
| 264 |
-
{status === 'approved' && <CheckCircleIcon sx={{ fontSize: 18, color: 'var(--accent-green)' }} />}
|
| 265 |
-
{status === 'rejected' && <CancelIcon sx={{ fontSize: 18, color: 'var(--accent-red)' }} />}
|
| 266 |
-
</Box>
|
| 267 |
-
|
| 268 |
-
<Box
|
| 269 |
-
onClick={showCode}
|
| 270 |
-
sx={{
|
| 271 |
-
display: 'flex',
|
| 272 |
-
alignItems: 'center',
|
| 273 |
-
gap: 1,
|
| 274 |
-
cursor: 'pointer',
|
| 275 |
-
p: 1.5,
|
| 276 |
-
borderRadius: '8px',
|
| 277 |
-
bgcolor: 'rgba(0,0,0,0.2)',
|
| 278 |
-
border: '1px solid rgba(255,255,255,0.05)',
|
| 279 |
-
transition: 'all 0.2s',
|
| 280 |
-
'&:hover': {
|
| 281 |
-
bgcolor: 'rgba(255,255,255,0.03)',
|
| 282 |
-
borderColor: 'var(--accent-primary)',
|
| 283 |
-
}
|
| 284 |
-
}}
|
| 285 |
-
>
|
| 286 |
-
{getToolDescription(currentTool.tool, currentTool.arguments)}
|
| 287 |
-
<OpenInNewIcon sx={{ fontSize: 16, color: 'var(--muted-text)', opacity: 0.7 }} />
|
| 288 |
-
</Box>
|
| 289 |
-
|
| 290 |
-
{/* Script/Logs buttons for hf_jobs - always show when we have a script */}
|
| 291 |
-
{currentTool.tool === 'hf_jobs' && args.script && (
|
| 292 |
-
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
| 293 |
-
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
| 294 |
-
<Button
|
| 295 |
-
variant="outlined"
|
| 296 |
-
size="small"
|
| 297 |
-
onClick={showCode}
|
| 298 |
-
sx={{
|
| 299 |
-
textTransform: 'none',
|
| 300 |
-
borderColor: 'rgba(255,255,255,0.1)',
|
| 301 |
-
color: 'var(--muted-text)',
|
| 302 |
-
fontSize: '0.75rem',
|
| 303 |
-
py: 0.5,
|
| 304 |
-
'&:hover': {
|
| 305 |
-
borderColor: 'var(--accent-primary)',
|
| 306 |
-
color: 'var(--accent-primary)',
|
| 307 |
-
bgcolor: 'rgba(255,255,255,0.03)'
|
| 308 |
-
}
|
| 309 |
-
}}
|
| 310 |
-
>
|
| 311 |
-
View Script
|
| 312 |
-
</Button>
|
| 313 |
-
<Button
|
| 314 |
-
variant="outlined"
|
| 315 |
-
size="small"
|
| 316 |
-
onClick={handleViewLogs}
|
| 317 |
-
disabled={!logsContent && status === 'pending'}
|
| 318 |
-
sx={{
|
| 319 |
-
textTransform: 'none',
|
| 320 |
-
borderColor: 'rgba(255,255,255,0.1)',
|
| 321 |
-
color: logsContent ? 'var(--accent-primary)' : 'var(--muted-text)',
|
| 322 |
-
fontSize: '0.75rem',
|
| 323 |
-
py: 0.5,
|
| 324 |
-
'&:hover': {
|
| 325 |
-
borderColor: 'var(--accent-primary)',
|
| 326 |
-
bgcolor: 'rgba(255,255,255,0.03)'
|
| 327 |
-
},
|
| 328 |
-
'&.Mui-disabled': {
|
| 329 |
-
color: 'rgba(255,255,255,0.3)',
|
| 330 |
-
borderColor: 'rgba(255,255,255,0.05)',
|
| 331 |
-
}
|
| 332 |
-
}}
|
| 333 |
-
>
|
| 334 |
-
{logsContent ? 'View Logs' : 'Logs (waiting for job...)'}
|
| 335 |
-
</Button>
|
| 336 |
-
</Box>
|
| 337 |
-
|
| 338 |
-
{/* Job URL - only show when we have a specific URL */}
|
| 339 |
-
{jobUrl && (
|
| 340 |
-
<Link
|
| 341 |
-
href={jobUrl}
|
| 342 |
-
target="_blank"
|
| 343 |
-
rel="noopener noreferrer"
|
| 344 |
-
sx={{
|
| 345 |
-
display: 'flex',
|
| 346 |
-
alignItems: 'center',
|
| 347 |
-
gap: 0.5,
|
| 348 |
-
color: 'var(--accent-primary)',
|
| 349 |
-
fontSize: '0.75rem',
|
| 350 |
-
textDecoration: 'none',
|
| 351 |
-
opacity: 0.9,
|
| 352 |
-
'&:hover': {
|
| 353 |
-
opacity: 1,
|
| 354 |
-
textDecoration: 'underline',
|
| 355 |
-
}
|
| 356 |
-
}}
|
| 357 |
-
>
|
| 358 |
-
<LaunchIcon sx={{ fontSize: 14 }} />
|
| 359 |
-
View Job on Hugging Face
|
| 360 |
-
</Link>
|
| 361 |
-
)}
|
| 362 |
-
|
| 363 |
-
{/* Show job status if available */}
|
| 364 |
-
{jobStatus && (
|
| 365 |
-
<Typography
|
| 366 |
-
variant="caption"
|
| 367 |
-
sx={{
|
| 368 |
-
color: jobFailed ? 'var(--accent-red)' : 'var(--accent-green)',
|
| 369 |
-
fontSize: '0.75rem',
|
| 370 |
-
fontWeight: 500,
|
| 371 |
-
}}
|
| 372 |
-
>
|
| 373 |
-
Status: {jobStatus}
|
| 374 |
-
</Typography>
|
| 375 |
-
)}
|
| 376 |
-
</Box>
|
| 377 |
-
)}
|
| 378 |
-
|
| 379 |
-
{containsPushToHub && (
|
| 380 |
-
<Typography variant="caption" sx={{ color: 'var(--accent-green)', fontSize: '0.75rem', opacity: 0.8, px: 0.5 }}>
|
| 381 |
-
We've detected the result will be pushed to hub.
|
| 382 |
-
</Typography>
|
| 383 |
-
)}
|
| 384 |
-
|
| 385 |
-
{/* Show error message if job failed */}
|
| 386 |
-
{errorMessage && status !== 'pending' && (
|
| 387 |
-
<Box
|
| 388 |
-
sx={{
|
| 389 |
-
p: 1.5,
|
| 390 |
-
borderRadius: '8px',
|
| 391 |
-
bgcolor: 'rgba(224, 90, 79, 0.1)',
|
| 392 |
-
border: '1px solid rgba(224, 90, 79, 0.3)',
|
| 393 |
-
}}
|
| 394 |
-
>
|
| 395 |
-
<Typography
|
| 396 |
-
variant="caption"
|
| 397 |
-
sx={{
|
| 398 |
-
color: 'var(--accent-red)',
|
| 399 |
-
fontWeight: 600,
|
| 400 |
-
display: 'block',
|
| 401 |
-
mb: 0.5,
|
| 402 |
-
}}
|
| 403 |
-
>
|
| 404 |
-
Error
|
| 405 |
-
</Typography>
|
| 406 |
-
<Typography
|
| 407 |
-
component="pre"
|
| 408 |
-
sx={{
|
| 409 |
-
color: 'var(--text)',
|
| 410 |
-
fontSize: '0.75rem',
|
| 411 |
-
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
|
| 412 |
-
whiteSpace: 'pre-wrap',
|
| 413 |
-
wordBreak: 'break-word',
|
| 414 |
-
m: 0,
|
| 415 |
-
maxHeight: '150px',
|
| 416 |
-
overflow: 'auto',
|
| 417 |
-
}}
|
| 418 |
-
>
|
| 419 |
-
{errorMessage.length > 500 ? errorMessage.substring(0, 500) + '...' : errorMessage}
|
| 420 |
-
</Typography>
|
| 421 |
-
</Box>
|
| 422 |
-
)}
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
{status === 'pending' && (
|
| 426 |
-
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
| 427 |
-
<Box sx={{ display: 'flex', gap: 1 }}>
|
| 428 |
-
<TextField
|
| 429 |
-
fullWidth
|
| 430 |
-
size="small"
|
| 431 |
-
placeholder="Feedback (optional)"
|
| 432 |
-
value={feedback}
|
| 433 |
-
onChange={(e) => setFeedback(e.target.value)}
|
| 434 |
-
variant="outlined"
|
| 435 |
-
sx={{
|
| 436 |
-
'& .MuiOutlinedInput-root': {
|
| 437 |
-
bgcolor: 'rgba(0,0,0,0.2)',
|
| 438 |
-
fontFamily: 'inherit',
|
| 439 |
-
fontSize: '0.9rem'
|
| 440 |
-
}
|
| 441 |
-
}}
|
| 442 |
-
/>
|
| 443 |
-
<IconButton
|
| 444 |
-
onClick={() => handleResolve(false)}
|
| 445 |
-
disabled={!feedback}
|
| 446 |
-
title="Reject with feedback"
|
| 447 |
-
sx={{
|
| 448 |
-
color: 'var(--accent-red)',
|
| 449 |
-
border: '1px solid rgba(255,255,255,0.05)',
|
| 450 |
-
borderRadius: '8px',
|
| 451 |
-
width: 40,
|
| 452 |
-
height: 40,
|
| 453 |
-
'&:hover': {
|
| 454 |
-
bgcolor: 'rgba(224, 90, 79, 0.1)',
|
| 455 |
-
borderColor: 'var(--accent-red)',
|
| 456 |
-
},
|
| 457 |
-
'&.Mui-disabled': {
|
| 458 |
-
color: 'rgba(255,255,255,0.1)',
|
| 459 |
-
borderColor: 'rgba(255,255,255,0.02)'
|
| 460 |
-
}
|
| 461 |
-
}}
|
| 462 |
-
>
|
| 463 |
-
<SendIcon fontSize="small" />
|
| 464 |
-
</IconButton>
|
| 465 |
-
</Box>
|
| 466 |
-
|
| 467 |
-
<Box className="action-buttons" sx={{ display: 'flex', gap: '10px' }}>
|
| 468 |
-
<Button
|
| 469 |
-
className="btn-reject"
|
| 470 |
-
onClick={() => handleResolve(false)}
|
| 471 |
-
sx={{
|
| 472 |
-
flex: 1,
|
| 473 |
-
background: 'transparent',
|
| 474 |
-
border: '1px solid rgba(255,255,255,0.05)',
|
| 475 |
-
color: 'var(--accent-red)',
|
| 476 |
-
padding: '10px 14px',
|
| 477 |
-
borderRadius: '10px',
|
| 478 |
-
'&:hover': {
|
| 479 |
-
bgcolor: 'rgba(224, 90, 79, 0.05)',
|
| 480 |
-
borderColor: 'var(--accent-red)',
|
| 481 |
-
}
|
| 482 |
-
}}
|
| 483 |
-
>
|
| 484 |
-
Reject
|
| 485 |
-
</Button>
|
| 486 |
-
<Button
|
| 487 |
-
className="btn-approve"
|
| 488 |
-
onClick={() => handleResolve(true)}
|
| 489 |
-
sx={{
|
| 490 |
-
flex: 1,
|
| 491 |
-
background: 'transparent',
|
| 492 |
-
border: '1px solid rgba(255,255,255,0.05)',
|
| 493 |
-
color: 'var(--accent-green)',
|
| 494 |
-
padding: '10px 14px',
|
| 495 |
-
borderRadius: '10px',
|
| 496 |
-
'&:hover': {
|
| 497 |
-
bgcolor: 'rgba(47, 204, 113, 0.05)',
|
| 498 |
-
borderColor: 'var(--accent-green)',
|
| 499 |
-
}
|
| 500 |
-
}}
|
| 501 |
-
>
|
| 502 |
-
Approve
|
| 503 |
-
</Button>
|
| 504 |
-
</Box>
|
| 505 |
-
</Box>
|
| 506 |
-
)}
|
| 507 |
-
|
| 508 |
-
{status === 'rejected' && decisions.some(d => d.feedback) && (
|
| 509 |
-
<Typography variant="body2" sx={{ color: 'var(--accent-red)', mt: 1 }}>
|
| 510 |
-
Feedback: {decisions.find(d => d.feedback)?.feedback}
|
| 511 |
-
</Typography>
|
| 512 |
-
)}
|
| 513 |
-
</Box>
|
| 514 |
-
);
|
| 515 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Box, Stack, Avatar, Typography } from '@mui/material';
|
| 2 |
+
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
|
| 3 |
+
import MarkdownContent from './MarkdownContent';
|
| 4 |
+
import ToolCallGroup from './ToolCallGroup';
|
| 5 |
+
import type { Message } from '@/types/agent';
|
| 6 |
+
|
| 7 |
+
interface AssistantMessageProps {
|
| 8 |
+
message: Message;
|
| 9 |
+
/** True when this message is actively receiving streaming chunks. */
|
| 10 |
+
isStreaming?: boolean;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
export default function AssistantMessage({ message, isStreaming = false }: AssistantMessageProps) {
|
| 14 |
+
const renderSegments = () => {
|
| 15 |
+
if (message.segments && message.segments.length > 0) {
|
| 16 |
+
// Find the index of the last text segment (that's the one being streamed)
|
| 17 |
+
let lastTextIdx = -1;
|
| 18 |
+
for (let i = message.segments.length - 1; i >= 0; i--) {
|
| 19 |
+
if (message.segments[i].type === 'text') {
|
| 20 |
+
lastTextIdx = i;
|
| 21 |
+
break;
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
return message.segments.map((segment, idx) => {
|
| 26 |
+
if (segment.type === 'text' && segment.content) {
|
| 27 |
+
return (
|
| 28 |
+
<MarkdownContent
|
| 29 |
+
key={idx}
|
| 30 |
+
content={segment.content}
|
| 31 |
+
isStreaming={isStreaming && idx === lastTextIdx}
|
| 32 |
+
/>
|
| 33 |
+
);
|
| 34 |
+
}
|
| 35 |
+
if (segment.type === 'tools' && segment.tools && segment.tools.length > 0) {
|
| 36 |
+
return <ToolCallGroup key={idx} tools={segment.tools} />;
|
| 37 |
+
}
|
| 38 |
+
return null;
|
| 39 |
+
});
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// Fallback: render raw content
|
| 43 |
+
if (message.content) {
|
| 44 |
+
return <MarkdownContent content={message.content} isStreaming={isStreaming} />;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
return null;
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
return (
|
| 51 |
+
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
| 52 |
+
<Avatar
|
| 53 |
+
sx={{
|
| 54 |
+
width: 28,
|
| 55 |
+
height: 28,
|
| 56 |
+
bgcolor: 'primary.main',
|
| 57 |
+
flexShrink: 0,
|
| 58 |
+
mt: 0.5,
|
| 59 |
+
}}
|
| 60 |
+
>
|
| 61 |
+
<SmartToyOutlinedIcon sx={{ fontSize: 16, color: '#fff' }} />
|
| 62 |
+
</Avatar>
|
| 63 |
+
|
| 64 |
+
<Box sx={{ flex: 1, minWidth: 0 }}>
|
| 65 |
+
{/* Role label + timestamp */}
|
| 66 |
+
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 0.5 }}>
|
| 67 |
+
<Typography
|
| 68 |
+
variant="caption"
|
| 69 |
+
sx={{
|
| 70 |
+
fontWeight: 700,
|
| 71 |
+
fontSize: '0.72rem',
|
| 72 |
+
color: 'var(--muted-text)',
|
| 73 |
+
textTransform: 'uppercase',
|
| 74 |
+
letterSpacing: '0.04em',
|
| 75 |
+
}}
|
| 76 |
+
>
|
| 77 |
+
Assistant
|
| 78 |
+
</Typography>
|
| 79 |
+
<Typography
|
| 80 |
+
variant="caption"
|
| 81 |
+
sx={{
|
| 82 |
+
fontSize: '0.66rem',
|
| 83 |
+
color: 'var(--muted-text)',
|
| 84 |
+
opacity: 0.6,
|
| 85 |
+
}}
|
| 86 |
+
>
|
| 87 |
+
{new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
| 88 |
+
</Typography>
|
| 89 |
+
</Stack>
|
| 90 |
+
|
| 91 |
+
{/* Message bubble */}
|
| 92 |
+
<Box
|
| 93 |
+
sx={{
|
| 94 |
+
maxWidth: { xs: '95%', md: '85%' },
|
| 95 |
+
bgcolor: 'var(--surface)',
|
| 96 |
+
borderRadius: 1.5,
|
| 97 |
+
borderTopLeftRadius: 4,
|
| 98 |
+
px: { xs: 1.5, md: 2.5 },
|
| 99 |
+
py: 1.5,
|
| 100 |
+
border: '1px solid var(--border)',
|
| 101 |
+
}}
|
| 102 |
+
>
|
| 103 |
+
{renderSegments()}
|
| 104 |
+
</Box>
|
| 105 |
+
</Box>
|
| 106 |
+
</Stack>
|
| 107 |
+
);
|
| 108 |
+
}
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useState, useCallback, KeyboardEvent } from 'react';
|
| 2 |
import { Box, TextField, IconButton, CircularProgress, Typography } from '@mui/material';
|
| 3 |
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
| 4 |
|
|
@@ -9,6 +9,14 @@ interface ChatInputProps {
|
|
| 9 |
|
| 10 |
export default function ChatInput({ onSend, disabled = false }: ChatInputProps) {
|
| 11 |
const [input, setInput] = useState('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
const handleSend = useCallback(() => {
|
| 14 |
if (input.trim() && !disabled) {
|
|
@@ -30,23 +38,23 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
|
|
| 30 |
return (
|
| 31 |
<Box
|
| 32 |
sx={{
|
| 33 |
-
pb: 4,
|
| 34 |
-
pt: 2,
|
| 35 |
position: 'relative',
|
| 36 |
zIndex: 10,
|
| 37 |
}}
|
| 38 |
>
|
| 39 |
-
<Box sx={{ maxWidth: '880px', mx: 'auto', width: '100%', px: 2 }}>
|
| 40 |
<Box
|
| 41 |
className="composer"
|
| 42 |
sx={{
|
| 43 |
display: 'flex',
|
| 44 |
gap: '10px',
|
| 45 |
alignItems: 'flex-start',
|
| 46 |
-
bgcolor: '
|
| 47 |
borderRadius: 'var(--radius-md)',
|
| 48 |
p: '12px',
|
| 49 |
-
border: '1px solid
|
| 50 |
transition: 'box-shadow 0.2s ease, border-color 0.2s ease',
|
| 51 |
'&:focus-within': {
|
| 52 |
borderColor: 'var(--accent-yellow)',
|
|
@@ -64,6 +72,7 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
|
|
| 64 |
placeholder="Ask anything..."
|
| 65 |
disabled={disabled}
|
| 66 |
variant="standard"
|
|
|
|
| 67 |
InputProps={{
|
| 68 |
disableUnderline: true,
|
| 69 |
sx: {
|
|
@@ -72,7 +81,7 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
|
|
| 72 |
fontFamily: 'inherit',
|
| 73 |
padding: 0,
|
| 74 |
lineHeight: 1.5,
|
| 75 |
-
minHeight: '56px',
|
| 76 |
alignItems: 'flex-start',
|
| 77 |
}
|
| 78 |
}}
|
|
@@ -99,7 +108,7 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
|
|
| 99 |
transition: 'all 0.2s',
|
| 100 |
'&:hover': {
|
| 101 |
color: 'var(--accent-yellow)',
|
| 102 |
-
bgcolor: '
|
| 103 |
},
|
| 104 |
'&.Mui-disabled': {
|
| 105 |
opacity: 0.3,
|
|
@@ -115,7 +124,7 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
|
|
| 115 |
<Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
|
| 116 |
powered by
|
| 117 |
</Typography>
|
| 118 |
-
<img src="/claude-logo.png" alt="Claude"
|
| 119 |
<Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--text)', fontWeight: 600, letterSpacing: '0.02em' }}>
|
| 120 |
claude-opus-4-5-20251101
|
| 121 |
</Typography>
|
|
|
|
| 1 |
+
import { useState, useCallback, useEffect, useRef, KeyboardEvent } from 'react';
|
| 2 |
import { Box, TextField, IconButton, CircularProgress, Typography } from '@mui/material';
|
| 3 |
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
| 4 |
|
|
|
|
| 9 |
|
| 10 |
export default function ChatInput({ onSend, disabled = false }: ChatInputProps) {
|
| 11 |
const [input, setInput] = useState('');
|
| 12 |
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
| 13 |
+
|
| 14 |
+
// Auto-focus the textarea when the session becomes ready (disabled → false)
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
if (!disabled && inputRef.current) {
|
| 17 |
+
inputRef.current.focus();
|
| 18 |
+
}
|
| 19 |
+
}, [disabled]);
|
| 20 |
|
| 21 |
const handleSend = useCallback(() => {
|
| 22 |
if (input.trim() && !disabled) {
|
|
|
|
| 38 |
return (
|
| 39 |
<Box
|
| 40 |
sx={{
|
| 41 |
+
pb: { xs: 2, md: 4 },
|
| 42 |
+
pt: { xs: 1, md: 2 },
|
| 43 |
position: 'relative',
|
| 44 |
zIndex: 10,
|
| 45 |
}}
|
| 46 |
>
|
| 47 |
+
<Box sx={{ maxWidth: '880px', mx: 'auto', width: '100%', px: { xs: 0, sm: 1, md: 2 } }}>
|
| 48 |
<Box
|
| 49 |
className="composer"
|
| 50 |
sx={{
|
| 51 |
display: 'flex',
|
| 52 |
gap: '10px',
|
| 53 |
alignItems: 'flex-start',
|
| 54 |
+
bgcolor: 'var(--composer-bg)',
|
| 55 |
borderRadius: 'var(--radius-md)',
|
| 56 |
p: '12px',
|
| 57 |
+
border: '1px solid var(--border)',
|
| 58 |
transition: 'box-shadow 0.2s ease, border-color 0.2s ease',
|
| 59 |
'&:focus-within': {
|
| 60 |
borderColor: 'var(--accent-yellow)',
|
|
|
|
| 72 |
placeholder="Ask anything..."
|
| 73 |
disabled={disabled}
|
| 74 |
variant="standard"
|
| 75 |
+
inputRef={inputRef}
|
| 76 |
InputProps={{
|
| 77 |
disableUnderline: true,
|
| 78 |
sx: {
|
|
|
|
| 81 |
fontFamily: 'inherit',
|
| 82 |
padding: 0,
|
| 83 |
lineHeight: 1.5,
|
| 84 |
+
minHeight: { xs: '44px', md: '56px' },
|
| 85 |
alignItems: 'flex-start',
|
| 86 |
}
|
| 87 |
}}
|
|
|
|
| 108 |
transition: 'all 0.2s',
|
| 109 |
'&:hover': {
|
| 110 |
color: 'var(--accent-yellow)',
|
| 111 |
+
bgcolor: 'var(--hover-bg)',
|
| 112 |
},
|
| 113 |
'&.Mui-disabled': {
|
| 114 |
opacity: 0.3,
|
|
|
|
| 124 |
<Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
|
| 125 |
powered by
|
| 126 |
</Typography>
|
| 127 |
+
<Box component="img" src="/claude-logo.png" alt="Claude" sx={{ height: '12px', objectFit: 'contain' }} />
|
| 128 |
<Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--text)', fontWeight: 600, letterSpacing: '0.02em' }}>
|
| 129 |
claude-opus-4-5-20251101
|
| 130 |
</Typography>
|
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo, useRef, useState, useEffect } from 'react';
|
| 2 |
+
import { Box } from '@mui/material';
|
| 3 |
+
import ReactMarkdown from 'react-markdown';
|
| 4 |
+
import remarkGfm from 'remark-gfm';
|
| 5 |
+
import type { SxProps, Theme } from '@mui/material/styles';
|
| 6 |
+
|
| 7 |
+
interface MarkdownContentProps {
|
| 8 |
+
content: string;
|
| 9 |
+
sx?: SxProps<Theme>;
|
| 10 |
+
/** When true, shows a blinking cursor and throttles renders. */
|
| 11 |
+
isStreaming?: boolean;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/** Shared markdown styles — adapts to light/dark via CSS variables. */
|
| 15 |
+
const markdownSx: SxProps<Theme> = {
|
| 16 |
+
fontSize: '0.925rem',
|
| 17 |
+
lineHeight: 1.7,
|
| 18 |
+
color: 'var(--text)',
|
| 19 |
+
wordBreak: 'break-word',
|
| 20 |
+
|
| 21 |
+
'& p': { m: 0, mb: 1.5, '&:last-child': { mb: 0 } },
|
| 22 |
+
|
| 23 |
+
'& h1, & h2, & h3, & h4': { mt: 2.5, mb: 1, fontWeight: 600, lineHeight: 1.3 },
|
| 24 |
+
'& h1': { fontSize: '1.35rem' },
|
| 25 |
+
'& h2': { fontSize: '1.15rem' },
|
| 26 |
+
'& h3': { fontSize: '1.05rem' },
|
| 27 |
+
|
| 28 |
+
'& pre': {
|
| 29 |
+
bgcolor: 'var(--code-bg)',
|
| 30 |
+
p: 2,
|
| 31 |
+
borderRadius: 2,
|
| 32 |
+
overflow: 'auto',
|
| 33 |
+
fontSize: '0.82rem',
|
| 34 |
+
lineHeight: 1.6,
|
| 35 |
+
border: '1px solid var(--tool-border)',
|
| 36 |
+
my: 2,
|
| 37 |
+
},
|
| 38 |
+
'& code': {
|
| 39 |
+
bgcolor: 'var(--hover-bg)',
|
| 40 |
+
px: 0.75,
|
| 41 |
+
py: 0.25,
|
| 42 |
+
borderRadius: 0.5,
|
| 43 |
+
fontSize: '0.84rem',
|
| 44 |
+
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
|
| 45 |
+
},
|
| 46 |
+
'& pre code': { bgcolor: 'transparent', p: 0 },
|
| 47 |
+
|
| 48 |
+
'& a': {
|
| 49 |
+
color: 'var(--accent-yellow)',
|
| 50 |
+
textDecoration: 'none',
|
| 51 |
+
fontWeight: 500,
|
| 52 |
+
'&:hover': { textDecoration: 'underline' },
|
| 53 |
+
},
|
| 54 |
+
|
| 55 |
+
'& ul, & ol': { pl: 3, my: 1 },
|
| 56 |
+
'& li': { mb: 0.5 },
|
| 57 |
+
'& li::marker': { color: 'var(--muted-text)' },
|
| 58 |
+
|
| 59 |
+
'& blockquote': {
|
| 60 |
+
borderLeft: '3px solid var(--accent-yellow)',
|
| 61 |
+
pl: 2,
|
| 62 |
+
ml: 0,
|
| 63 |
+
my: 1.5,
|
| 64 |
+
color: 'var(--muted-text)',
|
| 65 |
+
fontStyle: 'italic',
|
| 66 |
+
},
|
| 67 |
+
|
| 68 |
+
'& table': {
|
| 69 |
+
borderCollapse: 'collapse',
|
| 70 |
+
width: '100%',
|
| 71 |
+
my: 2,
|
| 72 |
+
fontSize: '0.85rem',
|
| 73 |
+
},
|
| 74 |
+
'& th': {
|
| 75 |
+
borderBottom: '2px solid var(--border-hover)',
|
| 76 |
+
textAlign: 'left',
|
| 77 |
+
p: 1,
|
| 78 |
+
fontWeight: 600,
|
| 79 |
+
},
|
| 80 |
+
'& td': {
|
| 81 |
+
borderBottom: '1px solid var(--tool-border)',
|
| 82 |
+
p: 1,
|
| 83 |
+
},
|
| 84 |
+
|
| 85 |
+
'& hr': {
|
| 86 |
+
border: 'none',
|
| 87 |
+
borderTop: '1px solid var(--border)',
|
| 88 |
+
my: 2,
|
| 89 |
+
},
|
| 90 |
+
|
| 91 |
+
'& img': {
|
| 92 |
+
maxWidth: '100%',
|
| 93 |
+
borderRadius: 2,
|
| 94 |
+
},
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
/** Blinking cursor shown at the end of streaming text. */
|
| 98 |
+
const StreamingCursor = () => (
|
| 99 |
+
<Box
|
| 100 |
+
component="span"
|
| 101 |
+
sx={{
|
| 102 |
+
display: 'inline-block',
|
| 103 |
+
width: '2px',
|
| 104 |
+
height: '1.1em',
|
| 105 |
+
bgcolor: 'var(--text)',
|
| 106 |
+
ml: '2px',
|
| 107 |
+
verticalAlign: 'text-bottom',
|
| 108 |
+
animation: 'cursorBlink 1s step-end infinite',
|
| 109 |
+
'@keyframes cursorBlink': {
|
| 110 |
+
'0%, 100%': { opacity: 1 },
|
| 111 |
+
'50%': { opacity: 0 },
|
| 112 |
+
},
|
| 113 |
+
}}
|
| 114 |
+
/>
|
| 115 |
+
);
|
| 116 |
+
|
| 117 |
+
/**
|
| 118 |
+
* Throttled content for streaming: render the full markdown through
|
| 119 |
+
* ReactMarkdown but only re-parse every ~80ms to avoid layout thrashing.
|
| 120 |
+
* This is the Claude approach — always render as markdown, never split
|
| 121 |
+
* into raw text. The parser handles incomplete tables gracefully.
|
| 122 |
+
*/
|
| 123 |
+
function useThrottledValue(value: string, isStreaming: boolean, intervalMs = 80): string {
|
| 124 |
+
const [throttled, setThrottled] = useState(value);
|
| 125 |
+
const lastUpdate = useRef(0);
|
| 126 |
+
const pending = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 127 |
+
const latestValue = useRef(value);
|
| 128 |
+
latestValue.current = value;
|
| 129 |
+
|
| 130 |
+
useEffect(() => {
|
| 131 |
+
if (!isStreaming) {
|
| 132 |
+
// Not streaming — always use latest value immediately
|
| 133 |
+
setThrottled(value);
|
| 134 |
+
return;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
const now = Date.now();
|
| 138 |
+
const elapsed = now - lastUpdate.current;
|
| 139 |
+
|
| 140 |
+
if (elapsed >= intervalMs) {
|
| 141 |
+
// Enough time passed — update immediately
|
| 142 |
+
setThrottled(value);
|
| 143 |
+
lastUpdate.current = now;
|
| 144 |
+
} else {
|
| 145 |
+
// Schedule an update for the remaining time
|
| 146 |
+
if (pending.current) clearTimeout(pending.current);
|
| 147 |
+
pending.current = setTimeout(() => {
|
| 148 |
+
setThrottled(latestValue.current);
|
| 149 |
+
lastUpdate.current = Date.now();
|
| 150 |
+
pending.current = null;
|
| 151 |
+
}, intervalMs - elapsed);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
return () => {
|
| 155 |
+
if (pending.current) clearTimeout(pending.current);
|
| 156 |
+
};
|
| 157 |
+
}, [value, isStreaming, intervalMs]);
|
| 158 |
+
|
| 159 |
+
// When streaming ends, flush immediately
|
| 160 |
+
useEffect(() => {
|
| 161 |
+
if (!isStreaming) {
|
| 162 |
+
setThrottled(latestValue.current);
|
| 163 |
+
}
|
| 164 |
+
}, [isStreaming]);
|
| 165 |
+
|
| 166 |
+
return throttled;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
export default function MarkdownContent({ content, sx, isStreaming = false }: MarkdownContentProps) {
|
| 170 |
+
// Throttle re-parses during streaming to ~12fps (every 80ms)
|
| 171 |
+
const displayContent = useThrottledValue(content, isStreaming);
|
| 172 |
+
|
| 173 |
+
const remarkPlugins = useMemo(() => [remarkGfm], []);
|
| 174 |
+
|
| 175 |
+
return (
|
| 176 |
+
<Box sx={[markdownSx, ...(Array.isArray(sx) ? sx : sx ? [sx] : [])]}>
|
| 177 |
+
<ReactMarkdown remarkPlugins={remarkPlugins}>{displayContent}</ReactMarkdown>
|
| 178 |
+
{isStreaming && <StreamingCursor />}
|
| 179 |
+
</Box>
|
| 180 |
+
);
|
| 181 |
+
}
|
|
@@ -1,215 +1,51 @@
|
|
| 1 |
-
import
|
| 2 |
-
import
|
| 3 |
-
import
|
| 4 |
-
import ApprovalFlow from './ApprovalFlow';
|
| 5 |
-
import type { Message, TraceLog } from '@/types/agent';
|
| 6 |
-
import { useAgentStore } from '@/store/agentStore';
|
| 7 |
-
import { useLayoutStore } from '@/store/layoutStore';
|
| 8 |
|
| 9 |
interface MessageBubbleProps {
|
| 10 |
message: Message;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
}
|
| 12 |
|
| 13 |
-
/
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
bgcolor: 'rgba(0,0,0,0.3)',
|
| 29 |
-
borderRadius: 1,
|
| 30 |
-
p: 1.5,
|
| 31 |
-
border: 1,
|
| 32 |
-
borderColor: 'rgba(255,255,255,0.05)',
|
| 33 |
-
my: 1.5,
|
| 34 |
-
}}
|
| 35 |
-
>
|
| 36 |
-
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
| 37 |
-
{tools.map((log) => {
|
| 38 |
-
const isClickable = log.completed && log.output;
|
| 39 |
-
return (
|
| 40 |
-
<Typography
|
| 41 |
-
key={log.id}
|
| 42 |
-
variant="caption"
|
| 43 |
-
component="div"
|
| 44 |
-
onClick={() => handleToolClick(log)}
|
| 45 |
-
sx={{
|
| 46 |
-
color: 'var(--muted-text)',
|
| 47 |
-
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
|
| 48 |
-
fontSize: '0.75rem',
|
| 49 |
-
display: 'flex',
|
| 50 |
-
alignItems: 'center',
|
| 51 |
-
gap: 0.5,
|
| 52 |
-
cursor: isClickable ? 'pointer' : 'default',
|
| 53 |
-
borderRadius: 0.5,
|
| 54 |
-
px: 0.5,
|
| 55 |
-
mx: -0.5,
|
| 56 |
-
transition: 'background-color 0.15s ease',
|
| 57 |
-
'&:hover': isClickable ? {
|
| 58 |
-
bgcolor: 'rgba(255,255,255,0.05)',
|
| 59 |
-
} : {},
|
| 60 |
-
}}
|
| 61 |
-
>
|
| 62 |
-
<span style={{
|
| 63 |
-
color: log.completed
|
| 64 |
-
? (log.success === false ? '#F87171' : '#FDB022')
|
| 65 |
-
: 'inherit',
|
| 66 |
-
fontSize: '0.85rem',
|
| 67 |
-
}}>
|
| 68 |
-
{log.completed ? (log.success === false ? '✗' : '✓') : '•'}
|
| 69 |
-
</span>
|
| 70 |
-
<span style={{
|
| 71 |
-
fontWeight: 600,
|
| 72 |
-
color: isClickable ? 'rgba(255, 255, 255, 0.9)' : 'inherit',
|
| 73 |
-
textDecoration: isClickable ? 'underline' : 'none',
|
| 74 |
-
textDecorationColor: 'rgba(255,255,255,0.3)',
|
| 75 |
-
textUnderlineOffset: '2px',
|
| 76 |
-
}}>
|
| 77 |
-
{log.tool}
|
| 78 |
-
</span>
|
| 79 |
-
{!log.completed && <span style={{ opacity: 0.6 }}>...</span>}
|
| 80 |
-
{isClickable && (
|
| 81 |
-
<span style={{
|
| 82 |
-
opacity: 0.4,
|
| 83 |
-
fontSize: '0.65rem',
|
| 84 |
-
marginLeft: 'auto',
|
| 85 |
-
}}>
|
| 86 |
-
click to view
|
| 87 |
-
</span>
|
| 88 |
-
)}
|
| 89 |
-
</Typography>
|
| 90 |
-
);
|
| 91 |
-
})}
|
| 92 |
-
</Box>
|
| 93 |
-
</Box>
|
| 94 |
-
);
|
| 95 |
-
}
|
| 96 |
-
|
| 97 |
-
// Markdown styles
|
| 98 |
-
const markdownStyles = {
|
| 99 |
-
'& p': { m: 0, mb: 1, '&:last-child': { mb: 0 } },
|
| 100 |
-
'& pre': {
|
| 101 |
-
bgcolor: 'rgba(0,0,0,0.5)',
|
| 102 |
-
p: 1.5,
|
| 103 |
-
borderRadius: 1,
|
| 104 |
-
overflow: 'auto',
|
| 105 |
-
fontSize: '0.85rem',
|
| 106 |
-
border: '1px solid rgba(255,255,255,0.05)',
|
| 107 |
-
},
|
| 108 |
-
'& code': {
|
| 109 |
-
bgcolor: 'rgba(255,255,255,0.05)',
|
| 110 |
-
px: 0.5,
|
| 111 |
-
py: 0.25,
|
| 112 |
-
borderRadius: 0.5,
|
| 113 |
-
fontSize: '0.85rem',
|
| 114 |
-
fontFamily: '"JetBrains Mono", monospace',
|
| 115 |
-
},
|
| 116 |
-
'& pre code': { bgcolor: 'transparent', p: 0 },
|
| 117 |
-
'& a': {
|
| 118 |
-
color: 'var(--accent-yellow)',
|
| 119 |
-
textDecoration: 'none',
|
| 120 |
-
'&:hover': { textDecoration: 'underline' },
|
| 121 |
-
},
|
| 122 |
-
'& ul, & ol': { pl: 2, my: 1 },
|
| 123 |
-
'& table': {
|
| 124 |
-
borderCollapse: 'collapse',
|
| 125 |
-
width: '100%',
|
| 126 |
-
my: 2,
|
| 127 |
-
fontSize: '0.875rem',
|
| 128 |
-
},
|
| 129 |
-
'& th': {
|
| 130 |
-
borderBottom: '1px solid rgba(255,255,255,0.1)',
|
| 131 |
-
textAlign: 'left',
|
| 132 |
-
p: 1,
|
| 133 |
-
bgcolor: 'rgba(255,255,255,0.02)',
|
| 134 |
-
},
|
| 135 |
-
'& td': {
|
| 136 |
-
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
| 137 |
-
p: 1,
|
| 138 |
-
},
|
| 139 |
-
};
|
| 140 |
-
|
| 141 |
-
export default function MessageBubble({ message }: MessageBubbleProps) {
|
| 142 |
-
const isUser = message.role === 'user';
|
| 143 |
-
const isAssistant = message.role === 'assistant';
|
| 144 |
-
|
| 145 |
-
if (message.approval) {
|
| 146 |
-
return (
|
| 147 |
-
<Box sx={{ width: '100%', maxWidth: '880px', mx: 'auto', my: 2 }}>
|
| 148 |
-
<ApprovalFlow message={message} />
|
| 149 |
-
</Box>
|
| 150 |
-
);
|
| 151 |
}
|
| 152 |
|
| 153 |
-
|
| 154 |
-
const renderContent = () => {
|
| 155 |
-
if (message.segments && message.segments.length > 0) {
|
| 156 |
-
return message.segments.map((segment, idx) => {
|
| 157 |
-
if (segment.type === 'text' && segment.content) {
|
| 158 |
-
return (
|
| 159 |
-
<Box key={idx} sx={markdownStyles}>
|
| 160 |
-
<ReactMarkdown remarkPlugins={[remarkGfm]}>{segment.content}</ReactMarkdown>
|
| 161 |
-
</Box>
|
| 162 |
-
);
|
| 163 |
-
}
|
| 164 |
-
if (segment.type === 'tools' && segment.tools && segment.tools.length > 0) {
|
| 165 |
-
return <ToolsSegment key={idx} tools={segment.tools} />;
|
| 166 |
-
}
|
| 167 |
-
return null;
|
| 168 |
-
});
|
| 169 |
-
}
|
| 170 |
-
// Fallback: just render content
|
| 171 |
return (
|
| 172 |
-
<
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
| 175 |
);
|
| 176 |
-
}
|
| 177 |
|
| 178 |
-
|
| 179 |
-
<
|
| 180 |
-
|
| 181 |
-
display: 'flex',
|
| 182 |
-
justifyContent: isUser ? 'flex-end' : 'flex-start',
|
| 183 |
-
width: '100%',
|
| 184 |
-
maxWidth: '880px',
|
| 185 |
-
mx: 'auto',
|
| 186 |
-
}}
|
| 187 |
-
>
|
| 188 |
-
<Paper
|
| 189 |
-
elevation={0}
|
| 190 |
-
className={`message ${isUser ? 'user' : isAssistant ? 'assistant' : ''}`}
|
| 191 |
-
sx={{
|
| 192 |
-
p: '14px 18px',
|
| 193 |
-
margin: '10px 0',
|
| 194 |
-
maxWidth: '100%',
|
| 195 |
-
borderRadius: 'var(--radius-lg)',
|
| 196 |
-
borderTopLeftRadius: isAssistant ? '6px' : undefined,
|
| 197 |
-
lineHeight: 1.45,
|
| 198 |
-
boxShadow: 'var(--shadow-1)',
|
| 199 |
-
border: '1px solid rgba(255,255,255,0.03)',
|
| 200 |
-
background: 'linear-gradient(180deg, rgba(255,255,255,0.015), transparent)',
|
| 201 |
-
}}
|
| 202 |
-
>
|
| 203 |
-
{renderContent()}
|
| 204 |
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
variant="caption"
|
| 208 |
-
sx={{ display: 'block', textAlign: 'right', mt: 1, fontSize: '11px', opacity: 0.5 }}
|
| 209 |
-
>
|
| 210 |
-
{new Date(message.timestamp).toLocaleTimeString()}
|
| 211 |
-
</Typography>
|
| 212 |
-
</Paper>
|
| 213 |
-
</Box>
|
| 214 |
-
);
|
| 215 |
}
|
|
|
|
| 1 |
+
import UserMessage from './UserMessage';
|
| 2 |
+
import AssistantMessage from './AssistantMessage';
|
| 3 |
+
import type { Message } from '@/types/agent';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
interface MessageBubbleProps {
|
| 6 |
message: Message;
|
| 7 |
+
/** True if this is the user message that starts the last turn. */
|
| 8 |
+
isLastTurn?: boolean;
|
| 9 |
+
/** Callback to undo (remove) the last turn. */
|
| 10 |
+
onUndoTurn?: () => void;
|
| 11 |
+
/** Whether the agent is currently processing. */
|
| 12 |
+
isProcessing?: boolean;
|
| 13 |
+
/** True when this message is actively receiving streaming chunks. */
|
| 14 |
+
isStreaming?: boolean;
|
| 15 |
}
|
| 16 |
|
| 17 |
+
/**
|
| 18 |
+
* Thin dispatcher — routes each message to the correct
|
| 19 |
+
* specialised component based on its role / content.
|
| 20 |
+
*/
|
| 21 |
+
export default function MessageBubble({
|
| 22 |
+
message,
|
| 23 |
+
isLastTurn = false,
|
| 24 |
+
onUndoTurn,
|
| 25 |
+
isProcessing = false,
|
| 26 |
+
isStreaming = false,
|
| 27 |
+
}: MessageBubbleProps) {
|
| 28 |
+
// Legacy approval-only messages (from old localStorage data) — skip them.
|
| 29 |
+
// Approvals are now rendered inline within ToolCallGroup.
|
| 30 |
+
if (message.approval && !message.content && !message.segments?.length) {
|
| 31 |
+
return null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
}
|
| 33 |
|
| 34 |
+
if (message.role === 'user') {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
return (
|
| 36 |
+
<UserMessage
|
| 37 |
+
message={message}
|
| 38 |
+
isLastTurn={isLastTurn}
|
| 39 |
+
onUndoTurn={onUndoTurn}
|
| 40 |
+
isProcessing={isProcessing}
|
| 41 |
+
/>
|
| 42 |
);
|
| 43 |
+
}
|
| 44 |
|
| 45 |
+
if (message.role === 'assistant') {
|
| 46 |
+
return <AssistantMessage message={message} isStreaming={isStreaming} />;
|
| 47 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
+
// Fallback (tool messages, etc.)
|
| 50 |
+
return null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
}
|
|
@@ -1,7 +1,13 @@
|
|
| 1 |
-
import { useEffect, useRef } from 'react';
|
| 2 |
-
import { Box, Typography } from '@mui/material';
|
| 3 |
-
import
|
| 4 |
import MessageBubble from './MessageBubble';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import type { Message } from '@/types/agent';
|
| 6 |
|
| 7 |
interface MessageListProps {
|
|
@@ -9,92 +15,183 @@ interface MessageListProps {
|
|
| 9 |
isProcessing: boolean;
|
| 10 |
}
|
| 11 |
|
| 12 |
-
const
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
export default function MessageList({ messages, isProcessing }: MessageListProps) {
|
| 41 |
-
const
|
|
|
|
| 42 |
const { activeSessionId } = useSessionStore();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
-
// Auto-scroll
|
|
|
|
|
|
|
| 45 |
useEffect(() => {
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
return (
|
| 50 |
<Box
|
|
|
|
| 51 |
sx={{
|
| 52 |
flex: 1,
|
| 53 |
overflow: 'auto',
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
flexDirection: 'column',
|
| 57 |
}}
|
| 58 |
>
|
| 59 |
-
<
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
<
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
| 77 |
))
|
| 78 |
)}
|
| 79 |
-
|
| 80 |
-
{isProcessing && (
|
| 81 |
-
<Box sx={{ width: '100%', mb: 2 }}>
|
| 82 |
-
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1, px: 0.5 }}>
|
| 83 |
-
<Typography variant="caption" color="text.secondary" sx={{ fontFamily: 'monospace', fontWeight: 600 }}>
|
| 84 |
-
Thinking
|
| 85 |
-
</Typography>
|
| 86 |
-
<TechnicalIndicator />
|
| 87 |
-
</Box>
|
| 88 |
-
</Box>
|
| 89 |
-
)}
|
| 90 |
|
| 91 |
-
{
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
</Box>
|
| 98 |
</Box>
|
| 99 |
);
|
| 100 |
-
}
|
|
|
|
| 1 |
+
import { useEffect, useRef, useMemo, useCallback } from 'react';
|
| 2 |
+
import { Box, Stack, Typography, Avatar } from '@mui/material';
|
| 3 |
+
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
|
| 4 |
import MessageBubble from './MessageBubble';
|
| 5 |
+
import ThinkingIndicator from './ThinkingIndicator';
|
| 6 |
+
import MarkdownContent from './MarkdownContent';
|
| 7 |
+
import { useAgentStore } from '@/store/agentStore';
|
| 8 |
+
import { useSessionStore } from '@/store/sessionStore';
|
| 9 |
+
import { apiFetch } from '@/utils/api';
|
| 10 |
+
import { logger } from '@/utils/logger';
|
| 11 |
import type { Message } from '@/types/agent';
|
| 12 |
|
| 13 |
interface MessageListProps {
|
|
|
|
| 15 |
isProcessing: boolean;
|
| 16 |
}
|
| 17 |
|
| 18 |
+
const WELCOME_MD = `I'm ready to help you with machine learning tasks using the Hugging Face ecosystem.
|
| 19 |
+
|
| 20 |
+
**Training & Fine-tuning** — SFT, DPO, GRPO, PPO with TRL · LoRA/PEFT · Submit and monitor jobs on cloud GPUs
|
| 21 |
+
|
| 22 |
+
**Data** — Find and explore datasets · Process, filter, transform · Push to the Hub
|
| 23 |
+
|
| 24 |
+
**Models** — Search and discover models · Get details and configs · Deploy for inference
|
| 25 |
+
|
| 26 |
+
**Research** — Find papers and documentation · Explore code examples · Check APIs and best practices
|
| 27 |
+
|
| 28 |
+
**Infrastructure** — Run jobs on CPU/GPU instances · Manage repos, branches, PRs · Monitor Spaces and endpoints
|
| 29 |
+
|
| 30 |
+
What would you like to do?`;
|
| 31 |
+
|
| 32 |
+
/** Static welcome message rendered when the conversation is empty. */
|
| 33 |
+
function WelcomeMessage() {
|
| 34 |
+
return (
|
| 35 |
+
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
| 36 |
+
<Avatar
|
| 37 |
+
sx={{
|
| 38 |
+
width: 28,
|
| 39 |
+
height: 28,
|
| 40 |
+
bgcolor: 'primary.main',
|
| 41 |
+
flexShrink: 0,
|
| 42 |
+
mt: 0.5,
|
| 43 |
+
}}
|
| 44 |
+
>
|
| 45 |
+
<SmartToyOutlinedIcon sx={{ fontSize: 16, color: '#fff' }} />
|
| 46 |
+
</Avatar>
|
| 47 |
+
|
| 48 |
+
<Box sx={{ flex: 1, minWidth: 0 }}>
|
| 49 |
+
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 0.5 }}>
|
| 50 |
+
<Typography
|
| 51 |
+
variant="caption"
|
| 52 |
+
sx={{
|
| 53 |
+
fontWeight: 700,
|
| 54 |
+
fontSize: '0.72rem',
|
| 55 |
+
color: 'var(--muted-text)',
|
| 56 |
+
textTransform: 'uppercase',
|
| 57 |
+
letterSpacing: '0.04em',
|
| 58 |
+
}}
|
| 59 |
+
>
|
| 60 |
+
Assistant
|
| 61 |
+
</Typography>
|
| 62 |
+
</Stack>
|
| 63 |
+
<Box
|
| 64 |
+
sx={{
|
| 65 |
+
maxWidth: { xs: '95%', md: '85%' },
|
| 66 |
+
bgcolor: 'var(--surface)',
|
| 67 |
+
borderRadius: 1.5,
|
| 68 |
+
borderTopLeftRadius: 4,
|
| 69 |
+
px: { xs: 1.5, md: 2.5 },
|
| 70 |
+
py: 1.5,
|
| 71 |
+
border: '1px solid var(--border)',
|
| 72 |
+
}}
|
| 73 |
+
>
|
| 74 |
+
<MarkdownContent content={WELCOME_MD} />
|
| 75 |
+
</Box>
|
| 76 |
+
</Box>
|
| 77 |
+
</Stack>
|
| 78 |
+
);
|
| 79 |
+
}
|
| 80 |
|
| 81 |
export default function MessageList({ messages, isProcessing }: MessageListProps) {
|
| 82 |
+
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
| 83 |
+
const stickToBottom = useRef(true);
|
| 84 |
const { activeSessionId } = useSessionStore();
|
| 85 |
+
const { removeLastTurn, currentTurnMessageId } = useAgentStore();
|
| 86 |
+
|
| 87 |
+
// ── Scroll-to-bottom helper ─────────────────────────────────────
|
| 88 |
+
const scrollToBottom = useCallback(() => {
|
| 89 |
+
const el = scrollContainerRef.current;
|
| 90 |
+
if (el) el.scrollTop = el.scrollHeight;
|
| 91 |
+
}, []);
|
| 92 |
+
|
| 93 |
+
// ── Track user scroll intent ────────────────────────────────────
|
| 94 |
+
// When user scrolls up (>80px from bottom), disable auto-scroll.
|
| 95 |
+
// When they scroll back to bottom, re-enable it.
|
| 96 |
+
useEffect(() => {
|
| 97 |
+
const el = scrollContainerRef.current;
|
| 98 |
+
if (!el) return;
|
| 99 |
+
|
| 100 |
+
const onScroll = () => {
|
| 101 |
+
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
| 102 |
+
stickToBottom.current = distFromBottom < 80;
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
el.addEventListener('scroll', onScroll, { passive: true });
|
| 106 |
+
return () => el.removeEventListener('scroll', onScroll);
|
| 107 |
+
}, []);
|
| 108 |
+
|
| 109 |
+
// ── Auto-scroll on new messages / state changes ─────────────────
|
| 110 |
+
useEffect(() => {
|
| 111 |
+
if (stickToBottom.current) scrollToBottom();
|
| 112 |
+
}, [messages, isProcessing, scrollToBottom]);
|
| 113 |
|
| 114 |
+
// ── Auto-scroll on DOM mutations (streaming content growth) ─────
|
| 115 |
+
// This catches token-by-token updates that don't change the messages
|
| 116 |
+
// array reference (appendToMessage mutates in place).
|
| 117 |
useEffect(() => {
|
| 118 |
+
const el = scrollContainerRef.current;
|
| 119 |
+
if (!el) return;
|
| 120 |
+
|
| 121 |
+
const observer = new MutationObserver(() => {
|
| 122 |
+
if (stickToBottom.current) {
|
| 123 |
+
el.scrollTop = el.scrollHeight;
|
| 124 |
+
}
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
observer.observe(el, {
|
| 128 |
+
childList: true,
|
| 129 |
+
subtree: true,
|
| 130 |
+
characterData: true,
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
return () => observer.disconnect();
|
| 134 |
+
}, []);
|
| 135 |
+
|
| 136 |
+
// Find the index of the last user message (start of the last turn)
|
| 137 |
+
const lastUserMsgId = useMemo(() => {
|
| 138 |
+
for (let i = messages.length - 1; i >= 0; i--) {
|
| 139 |
+
if (messages[i].role === 'user') return messages[i].id;
|
| 140 |
+
}
|
| 141 |
+
return null;
|
| 142 |
+
}, [messages]);
|
| 143 |
+
|
| 144 |
+
const handleUndoLastTurn = useCallback(async () => {
|
| 145 |
+
if (!activeSessionId) return;
|
| 146 |
+
try {
|
| 147 |
+
await apiFetch(`/api/undo/${activeSessionId}`, { method: 'POST' });
|
| 148 |
+
// Optimistic removal — backend will also confirm via undo_complete WS event
|
| 149 |
+
removeLastTurn(activeSessionId);
|
| 150 |
+
} catch (e) {
|
| 151 |
+
logger.error('Undo failed:', e);
|
| 152 |
+
}
|
| 153 |
+
}, [activeSessionId, removeLastTurn]);
|
| 154 |
|
| 155 |
return (
|
| 156 |
<Box
|
| 157 |
+
ref={scrollContainerRef}
|
| 158 |
sx={{
|
| 159 |
flex: 1,
|
| 160 |
overflow: 'auto',
|
| 161 |
+
px: { xs: 0.5, sm: 1, md: 2 },
|
| 162 |
+
py: { xs: 2, md: 3 },
|
|
|
|
| 163 |
}}
|
| 164 |
>
|
| 165 |
+
<Stack
|
| 166 |
+
spacing={3}
|
| 167 |
+
sx={{
|
| 168 |
+
maxWidth: 880,
|
| 169 |
+
mx: 'auto',
|
| 170 |
+
width: '100%',
|
| 171 |
+
}}
|
| 172 |
+
>
|
| 173 |
+
{/* Always show the welcome message at the top */}
|
| 174 |
+
<WelcomeMessage />
|
| 175 |
+
|
| 176 |
+
{messages.length > 0 && (
|
| 177 |
+
messages.map((msg) => (
|
| 178 |
+
<MessageBubble
|
| 179 |
+
key={msg.id}
|
| 180 |
+
message={msg}
|
| 181 |
+
isLastTurn={msg.id === lastUserMsgId}
|
| 182 |
+
onUndoTurn={handleUndoLastTurn}
|
| 183 |
+
isProcessing={isProcessing}
|
| 184 |
+
isStreaming={isProcessing && msg.id === currentTurnMessageId}
|
| 185 |
+
/>
|
| 186 |
))
|
| 187 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
+
{/* Show thinking dots only when processing but no streaming message yet */}
|
| 190 |
+
{isProcessing && !currentTurnMessageId && <ThinkingIndicator />}
|
| 191 |
+
|
| 192 |
+
{/* Sentinel — keeps scroll anchor at the bottom */}
|
| 193 |
+
<div />
|
| 194 |
+
</Stack>
|
|
|
|
| 195 |
</Box>
|
| 196 |
);
|
| 197 |
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Box, Stack, Avatar, Typography } from '@mui/material';
|
| 2 |
+
import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
|
| 3 |
+
|
| 4 |
+
/** Pulsing dots shown while the agent is processing. */
|
| 5 |
+
export default function ThinkingIndicator() {
|
| 6 |
+
return (
|
| 7 |
+
<Stack direction="row" spacing={1.5} alignItems="flex-start">
|
| 8 |
+
<Avatar
|
| 9 |
+
sx={{
|
| 10 |
+
width: 28,
|
| 11 |
+
height: 28,
|
| 12 |
+
bgcolor: 'primary.main',
|
| 13 |
+
flexShrink: 0,
|
| 14 |
+
mt: 0.5,
|
| 15 |
+
}}
|
| 16 |
+
>
|
| 17 |
+
<SmartToyOutlinedIcon sx={{ fontSize: 16, color: '#fff' }} />
|
| 18 |
+
</Avatar>
|
| 19 |
+
|
| 20 |
+
<Box sx={{ pt: 0.75 }}>
|
| 21 |
+
<Typography
|
| 22 |
+
variant="caption"
|
| 23 |
+
sx={{
|
| 24 |
+
fontWeight: 700,
|
| 25 |
+
fontSize: '0.72rem',
|
| 26 |
+
color: 'var(--muted-text)',
|
| 27 |
+
textTransform: 'uppercase',
|
| 28 |
+
letterSpacing: '0.04em',
|
| 29 |
+
display: 'flex',
|
| 30 |
+
alignItems: 'center',
|
| 31 |
+
gap: 0.75,
|
| 32 |
+
}}
|
| 33 |
+
>
|
| 34 |
+
Thinking
|
| 35 |
+
<Box
|
| 36 |
+
component="span"
|
| 37 |
+
sx={{
|
| 38 |
+
display: 'inline-flex',
|
| 39 |
+
gap: '3px',
|
| 40 |
+
'& span': {
|
| 41 |
+
width: 4,
|
| 42 |
+
height: 4,
|
| 43 |
+
borderRadius: '50%',
|
| 44 |
+
bgcolor: 'primary.main',
|
| 45 |
+
animation: 'dotPulse 1.4s ease-in-out infinite',
|
| 46 |
+
},
|
| 47 |
+
'& span:nth-of-type(2)': { animationDelay: '0.2s' },
|
| 48 |
+
'& span:nth-of-type(3)': { animationDelay: '0.4s' },
|
| 49 |
+
'@keyframes dotPulse': {
|
| 50 |
+
'0%, 80%, 100%': { opacity: 0.25, transform: 'scale(0.8)' },
|
| 51 |
+
'40%': { opacity: 1, transform: 'scale(1)' },
|
| 52 |
+
},
|
| 53 |
+
}}
|
| 54 |
+
>
|
| 55 |
+
<span />
|
| 56 |
+
<span />
|
| 57 |
+
<span />
|
| 58 |
+
</Box>
|
| 59 |
+
</Typography>
|
| 60 |
+
</Box>
|
| 61 |
+
</Stack>
|
| 62 |
+
);
|
| 63 |
+
}
|
|
@@ -0,0 +1,410 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useState } from 'react';
|
| 2 |
+
import { Box, Stack, Typography, Chip, Button, TextField, IconButton, Link } from '@mui/material';
|
| 3 |
+
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
| 4 |
+
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
| 5 |
+
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
|
| 6 |
+
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
| 7 |
+
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty';
|
| 8 |
+
import LaunchIcon from '@mui/icons-material/Launch';
|
| 9 |
+
import SendIcon from '@mui/icons-material/Send';
|
| 10 |
+
import { useAgentStore } from '@/store/agentStore';
|
| 11 |
+
import { useLayoutStore } from '@/store/layoutStore';
|
| 12 |
+
import { useSessionStore } from '@/store/sessionStore';
|
| 13 |
+
import { apiFetch } from '@/utils/api';
|
| 14 |
+
import { logger } from '@/utils/logger';
|
| 15 |
+
import type { TraceLog, ApprovalStatus } from '@/types/agent';
|
| 16 |
+
|
| 17 |
+
interface ToolCallGroupProps {
|
| 18 |
+
tools: TraceLog[];
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
// ── Status icon based on tool state ─────────────────────────────────
|
| 22 |
+
function StatusIcon({ log }: { log: TraceLog }) {
|
| 23 |
+
// Awaiting approval
|
| 24 |
+
if (log.approvalStatus === 'pending') {
|
| 25 |
+
return <HourglassEmptyIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />;
|
| 26 |
+
}
|
| 27 |
+
// Rejected
|
| 28 |
+
if (log.approvalStatus === 'rejected') {
|
| 29 |
+
return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'error.main' }} />;
|
| 30 |
+
}
|
| 31 |
+
// Running (not completed yet)
|
| 32 |
+
if (!log.completed) {
|
| 33 |
+
return (
|
| 34 |
+
<MoreHorizIcon
|
| 35 |
+
sx={{
|
| 36 |
+
fontSize: 16,
|
| 37 |
+
color: 'var(--muted-text)',
|
| 38 |
+
animation: 'pulse 1.5s ease-in-out infinite',
|
| 39 |
+
'@keyframes pulse': {
|
| 40 |
+
'0%, 100%': { opacity: 0.4 },
|
| 41 |
+
'50%': { opacity: 1 },
|
| 42 |
+
},
|
| 43 |
+
}}
|
| 44 |
+
/>
|
| 45 |
+
);
|
| 46 |
+
}
|
| 47 |
+
// Failed
|
| 48 |
+
if (log.success === false) {
|
| 49 |
+
return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'error.main' }} />;
|
| 50 |
+
}
|
| 51 |
+
// Completed successfully
|
| 52 |
+
return <CheckCircleOutlineIcon sx={{ fontSize: 16, color: 'success.main' }} />;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// ── Status chip label ───────────────────────────────────────────────
|
| 56 |
+
function statusLabel(log: TraceLog): string | null {
|
| 57 |
+
if (log.approvalStatus === 'pending') return 'awaiting approval';
|
| 58 |
+
if (log.approvalStatus === 'rejected') return 'rejected';
|
| 59 |
+
if (!log.completed) return 'running';
|
| 60 |
+
return null;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function statusColor(log: TraceLog): string {
|
| 64 |
+
if (log.approvalStatus === 'pending') return 'var(--accent-yellow)';
|
| 65 |
+
if (log.approvalStatus === 'rejected') return 'var(--accent-red)';
|
| 66 |
+
return 'var(--accent-yellow)';
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// ── Inline approval UI ──────────────────────────────────────────────
|
| 70 |
+
function InlineApproval({
|
| 71 |
+
log,
|
| 72 |
+
onResolve,
|
| 73 |
+
}: {
|
| 74 |
+
log: TraceLog;
|
| 75 |
+
onResolve: (toolCallId: string, approved: boolean, feedback?: string) => void;
|
| 76 |
+
}) {
|
| 77 |
+
const [feedback, setFeedback] = useState('');
|
| 78 |
+
|
| 79 |
+
return (
|
| 80 |
+
<Box sx={{ px: 1.5, py: 1.5, borderTop: '1px solid var(--tool-border)' }}>
|
| 81 |
+
{/* Tool description */}
|
| 82 |
+
{log.tool === 'hf_jobs' && log.args && (
|
| 83 |
+
<Typography variant="body2" sx={{ color: 'var(--muted-text)', fontSize: '0.75rem', mb: 1.5 }}>
|
| 84 |
+
Execute <Box component="span" sx={{ color: 'var(--accent-yellow)', fontWeight: 500 }}>{log.tool}</Box> on{' '}
|
| 85 |
+
<Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>
|
| 86 |
+
{String(log.args.hardware_flavor || 'default')}
|
| 87 |
+
</Box>
|
| 88 |
+
{log.args.timeout && (
|
| 89 |
+
<> with timeout <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>
|
| 90 |
+
{String(log.args.timeout)}
|
| 91 |
+
</Box></>
|
| 92 |
+
)}
|
| 93 |
+
</Typography>
|
| 94 |
+
)}
|
| 95 |
+
|
| 96 |
+
{/* Feedback + buttons */}
|
| 97 |
+
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
| 98 |
+
<TextField
|
| 99 |
+
fullWidth
|
| 100 |
+
size="small"
|
| 101 |
+
placeholder="Feedback (optional)"
|
| 102 |
+
value={feedback}
|
| 103 |
+
onChange={(e) => setFeedback(e.target.value)}
|
| 104 |
+
variant="outlined"
|
| 105 |
+
sx={{
|
| 106 |
+
'& .MuiOutlinedInput-root': {
|
| 107 |
+
bgcolor: 'rgba(0,0,0,0.15)',
|
| 108 |
+
fontFamily: 'inherit',
|
| 109 |
+
fontSize: '0.8rem',
|
| 110 |
+
},
|
| 111 |
+
}}
|
| 112 |
+
/>
|
| 113 |
+
<IconButton
|
| 114 |
+
onClick={() => onResolve(log.toolCallId || '', false, feedback || 'Rejected by user')}
|
| 115 |
+
disabled={!feedback}
|
| 116 |
+
size="small"
|
| 117 |
+
sx={{
|
| 118 |
+
color: 'var(--accent-red)',
|
| 119 |
+
border: '1px solid rgba(255,255,255,0.05)',
|
| 120 |
+
borderRadius: '6px',
|
| 121 |
+
'&:hover': { bgcolor: 'rgba(224,90,79,0.1)', borderColor: 'var(--accent-red)' },
|
| 122 |
+
'&.Mui-disabled': { color: 'rgba(255,255,255,0.1)' },
|
| 123 |
+
}}
|
| 124 |
+
>
|
| 125 |
+
<SendIcon sx={{ fontSize: 14 }} />
|
| 126 |
+
</IconButton>
|
| 127 |
+
</Box>
|
| 128 |
+
|
| 129 |
+
<Box sx={{ display: 'flex', gap: 1 }}>
|
| 130 |
+
<Button
|
| 131 |
+
size="small"
|
| 132 |
+
onClick={() => onResolve(log.toolCallId || '', false, feedback || 'Rejected by user')}
|
| 133 |
+
sx={{
|
| 134 |
+
flex: 1,
|
| 135 |
+
textTransform: 'none',
|
| 136 |
+
border: '1px solid rgba(255,255,255,0.05)',
|
| 137 |
+
color: 'var(--accent-red)',
|
| 138 |
+
fontSize: '0.75rem',
|
| 139 |
+
py: 0.75,
|
| 140 |
+
borderRadius: '8px',
|
| 141 |
+
'&:hover': { bgcolor: 'rgba(224,90,79,0.05)', borderColor: 'var(--accent-red)' },
|
| 142 |
+
}}
|
| 143 |
+
>
|
| 144 |
+
Reject
|
| 145 |
+
</Button>
|
| 146 |
+
<Button
|
| 147 |
+
size="small"
|
| 148 |
+
onClick={() => onResolve(log.toolCallId || '', true)}
|
| 149 |
+
sx={{
|
| 150 |
+
flex: 1,
|
| 151 |
+
textTransform: 'none',
|
| 152 |
+
border: '1px solid rgba(255,255,255,0.05)',
|
| 153 |
+
color: 'var(--accent-green)',
|
| 154 |
+
fontSize: '0.75rem',
|
| 155 |
+
py: 0.75,
|
| 156 |
+
borderRadius: '8px',
|
| 157 |
+
'&:hover': { bgcolor: 'rgba(47,204,113,0.05)', borderColor: 'var(--accent-green)' },
|
| 158 |
+
}}
|
| 159 |
+
>
|
| 160 |
+
Approve
|
| 161 |
+
</Button>
|
| 162 |
+
</Box>
|
| 163 |
+
</Box>
|
| 164 |
+
);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
// ── Main component ──────────────────────────────────────────────────
|
| 168 |
+
export default function ToolCallGroup({ tools }: ToolCallGroupProps) {
|
| 169 |
+
const { showToolOutput, setPanelTab, setActivePanelTab, clearPanelTabs } = useAgentStore();
|
| 170 |
+
const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
|
| 171 |
+
const { activeSessionId } = useSessionStore();
|
| 172 |
+
|
| 173 |
+
const handleClick = useCallback(
|
| 174 |
+
(log: TraceLog) => {
|
| 175 |
+
// For hf_jobs with scripts, use tab system
|
| 176 |
+
if (log.tool === 'hf_jobs' && log.args?.script) {
|
| 177 |
+
clearPanelTabs();
|
| 178 |
+
setPanelTab({
|
| 179 |
+
id: 'script',
|
| 180 |
+
title: 'Script',
|
| 181 |
+
content: String(log.args.script),
|
| 182 |
+
language: 'python',
|
| 183 |
+
});
|
| 184 |
+
if (log.jobLogs) {
|
| 185 |
+
setPanelTab({
|
| 186 |
+
id: 'logs',
|
| 187 |
+
title: 'Logs',
|
| 188 |
+
content: log.jobLogs,
|
| 189 |
+
language: 'text',
|
| 190 |
+
});
|
| 191 |
+
}
|
| 192 |
+
setActivePanelTab('script');
|
| 193 |
+
setRightPanelOpen(true);
|
| 194 |
+
setLeftSidebarOpen(false);
|
| 195 |
+
return;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
// Show output if completed, or args if still running
|
| 199 |
+
if (log.completed && log.output) {
|
| 200 |
+
showToolOutput(log);
|
| 201 |
+
} else if (log.args) {
|
| 202 |
+
const content = JSON.stringify(log.args, null, 2);
|
| 203 |
+
showToolOutput({ ...log, output: content });
|
| 204 |
+
} else {
|
| 205 |
+
return;
|
| 206 |
+
}
|
| 207 |
+
setRightPanelOpen(true);
|
| 208 |
+
},
|
| 209 |
+
[showToolOutput, setRightPanelOpen, setLeftSidebarOpen, clearPanelTabs, setPanelTab, setActivePanelTab],
|
| 210 |
+
);
|
| 211 |
+
|
| 212 |
+
const handleApprovalResolve = useCallback(
|
| 213 |
+
async (toolCallId: string, approved: boolean, feedback?: string) => {
|
| 214 |
+
if (!activeSessionId) return;
|
| 215 |
+
try {
|
| 216 |
+
await apiFetch('/api/approve', {
|
| 217 |
+
method: 'POST',
|
| 218 |
+
body: JSON.stringify({
|
| 219 |
+
session_id: activeSessionId,
|
| 220 |
+
approvals: [{
|
| 221 |
+
tool_call_id: toolCallId,
|
| 222 |
+
approved,
|
| 223 |
+
feedback: approved ? null : feedback || 'Rejected by user',
|
| 224 |
+
}],
|
| 225 |
+
}),
|
| 226 |
+
});
|
| 227 |
+
// The WebSocket will send back tool_output events which will update the trace
|
| 228 |
+
} catch (e) {
|
| 229 |
+
logger.error('Approval failed:', e);
|
| 230 |
+
}
|
| 231 |
+
},
|
| 232 |
+
[activeSessionId],
|
| 233 |
+
);
|
| 234 |
+
|
| 235 |
+
return (
|
| 236 |
+
<Box
|
| 237 |
+
sx={{
|
| 238 |
+
borderRadius: 2,
|
| 239 |
+
border: '1px solid var(--tool-border)',
|
| 240 |
+
bgcolor: 'var(--tool-bg)',
|
| 241 |
+
overflow: 'hidden',
|
| 242 |
+
my: 1,
|
| 243 |
+
}}
|
| 244 |
+
>
|
| 245 |
+
<Stack divider={<Box sx={{ borderBottom: '1px solid var(--tool-border)' }} />}>
|
| 246 |
+
{tools.map((log) => {
|
| 247 |
+
const clickable = (log.completed && !!log.output) || !!log.args;
|
| 248 |
+
const label = statusLabel(log);
|
| 249 |
+
const isPendingApproval = log.approvalStatus === 'pending';
|
| 250 |
+
|
| 251 |
+
return (
|
| 252 |
+
<Box key={log.id}>
|
| 253 |
+
{/* Main tool row */}
|
| 254 |
+
<Stack
|
| 255 |
+
direction="row"
|
| 256 |
+
alignItems="center"
|
| 257 |
+
spacing={1}
|
| 258 |
+
onClick={() => !isPendingApproval && handleClick(log)}
|
| 259 |
+
sx={{
|
| 260 |
+
px: 1.5,
|
| 261 |
+
py: 1,
|
| 262 |
+
cursor: isPendingApproval ? 'default' : clickable ? 'pointer' : 'default',
|
| 263 |
+
transition: 'background-color 0.15s',
|
| 264 |
+
'&:hover': clickable && !isPendingApproval ? { bgcolor: 'var(--hover-bg)' } : {},
|
| 265 |
+
}}
|
| 266 |
+
>
|
| 267 |
+
<StatusIcon log={log} />
|
| 268 |
+
|
| 269 |
+
<Typography
|
| 270 |
+
variant="body2"
|
| 271 |
+
sx={{
|
| 272 |
+
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, monospace',
|
| 273 |
+
fontWeight: 600,
|
| 274 |
+
fontSize: '0.78rem',
|
| 275 |
+
color: 'var(--text)',
|
| 276 |
+
flex: 1,
|
| 277 |
+
minWidth: 0,
|
| 278 |
+
overflow: 'hidden',
|
| 279 |
+
textOverflow: 'ellipsis',
|
| 280 |
+
whiteSpace: 'nowrap',
|
| 281 |
+
}}
|
| 282 |
+
>
|
| 283 |
+
{log.tool}
|
| 284 |
+
</Typography>
|
| 285 |
+
|
| 286 |
+
{/* Quick action links for completed jobs */}
|
| 287 |
+
{log.completed && log.tool === 'hf_jobs' && log.args?.script && (
|
| 288 |
+
<Box sx={{ display: 'flex', gap: 0.5 }} onClick={(e) => e.stopPropagation()}>
|
| 289 |
+
<Typography
|
| 290 |
+
component="span"
|
| 291 |
+
onClick={() => handleClick(log)}
|
| 292 |
+
sx={{
|
| 293 |
+
fontSize: '0.68rem',
|
| 294 |
+
color: 'var(--muted-text)',
|
| 295 |
+
cursor: 'pointer',
|
| 296 |
+
px: 0.75,
|
| 297 |
+
py: 0.25,
|
| 298 |
+
borderRadius: 0.5,
|
| 299 |
+
'&:hover': { color: 'var(--accent-yellow)', bgcolor: 'var(--hover-bg)' },
|
| 300 |
+
}}
|
| 301 |
+
>
|
| 302 |
+
Script
|
| 303 |
+
</Typography>
|
| 304 |
+
{log.jobLogs && (
|
| 305 |
+
<Typography
|
| 306 |
+
component="span"
|
| 307 |
+
onClick={() => {
|
| 308 |
+
clearPanelTabs();
|
| 309 |
+
if (log.args?.script) {
|
| 310 |
+
setPanelTab({ id: 'script', title: 'Script', content: String(log.args.script), language: 'python' });
|
| 311 |
+
}
|
| 312 |
+
setPanelTab({ id: 'logs', title: 'Logs', content: log.jobLogs!, language: 'text' });
|
| 313 |
+
setActivePanelTab('logs');
|
| 314 |
+
setRightPanelOpen(true);
|
| 315 |
+
setLeftSidebarOpen(false);
|
| 316 |
+
}}
|
| 317 |
+
sx={{
|
| 318 |
+
fontSize: '0.68rem',
|
| 319 |
+
color: 'var(--accent-yellow)',
|
| 320 |
+
cursor: 'pointer',
|
| 321 |
+
px: 0.75,
|
| 322 |
+
py: 0.25,
|
| 323 |
+
borderRadius: 0.5,
|
| 324 |
+
'&:hover': { bgcolor: 'var(--hover-bg)' },
|
| 325 |
+
}}
|
| 326 |
+
>
|
| 327 |
+
Logs
|
| 328 |
+
</Typography>
|
| 329 |
+
)}
|
| 330 |
+
</Box>
|
| 331 |
+
)}
|
| 332 |
+
|
| 333 |
+
{label && (
|
| 334 |
+
<Chip
|
| 335 |
+
label={label}
|
| 336 |
+
size="small"
|
| 337 |
+
sx={{
|
| 338 |
+
height: 20,
|
| 339 |
+
fontSize: '0.65rem',
|
| 340 |
+
fontWeight: 600,
|
| 341 |
+
bgcolor: 'var(--accent-yellow-weak)',
|
| 342 |
+
color: statusColor(log),
|
| 343 |
+
letterSpacing: '0.03em',
|
| 344 |
+
}}
|
| 345 |
+
/>
|
| 346 |
+
)}
|
| 347 |
+
|
| 348 |
+
{clickable && !isPendingApproval && (
|
| 349 |
+
<OpenInNewIcon sx={{ fontSize: 14, color: 'var(--muted-text)', opacity: 0.6 }} />
|
| 350 |
+
)}
|
| 351 |
+
</Stack>
|
| 352 |
+
|
| 353 |
+
{/* Job status + link row */}
|
| 354 |
+
{(log.jobUrl || log.jobStatus) && (
|
| 355 |
+
<Box
|
| 356 |
+
sx={{
|
| 357 |
+
display: 'flex',
|
| 358 |
+
alignItems: 'center',
|
| 359 |
+
gap: 1.5,
|
| 360 |
+
px: 1.5,
|
| 361 |
+
py: 0.75,
|
| 362 |
+
borderTop: '1px solid var(--tool-border)',
|
| 363 |
+
}}
|
| 364 |
+
>
|
| 365 |
+
{log.jobStatus && (
|
| 366 |
+
<Typography
|
| 367 |
+
variant="caption"
|
| 368 |
+
sx={{
|
| 369 |
+
color: log.success === false ? 'var(--accent-red)' : 'var(--accent-green)',
|
| 370 |
+
fontSize: '0.7rem',
|
| 371 |
+
fontWeight: 600,
|
| 372 |
+
}}
|
| 373 |
+
>
|
| 374 |
+
{log.jobStatus}
|
| 375 |
+
</Typography>
|
| 376 |
+
)}
|
| 377 |
+
{log.jobUrl && (
|
| 378 |
+
<Link
|
| 379 |
+
href={log.jobUrl}
|
| 380 |
+
target="_blank"
|
| 381 |
+
rel="noopener noreferrer"
|
| 382 |
+
onClick={(e) => e.stopPropagation()}
|
| 383 |
+
sx={{
|
| 384 |
+
display: 'inline-flex',
|
| 385 |
+
alignItems: 'center',
|
| 386 |
+
gap: 0.5,
|
| 387 |
+
color: 'var(--accent-yellow)',
|
| 388 |
+
fontSize: '0.68rem',
|
| 389 |
+
textDecoration: 'none',
|
| 390 |
+
'&:hover': { textDecoration: 'underline' },
|
| 391 |
+
}}
|
| 392 |
+
>
|
| 393 |
+
<LaunchIcon sx={{ fontSize: 12 }} />
|
| 394 |
+
View on HF
|
| 395 |
+
</Link>
|
| 396 |
+
)}
|
| 397 |
+
</Box>
|
| 398 |
+
)}
|
| 399 |
+
|
| 400 |
+
{/* Inline approval UI (only when pending) */}
|
| 401 |
+
{isPendingApproval && (
|
| 402 |
+
<InlineApproval log={log} onResolve={handleApprovalResolve} />
|
| 403 |
+
)}
|
| 404 |
+
</Box>
|
| 405 |
+
);
|
| 406 |
+
})}
|
| 407 |
+
</Stack>
|
| 408 |
+
</Box>
|
| 409 |
+
);
|
| 410 |
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Box, Stack, Typography, Avatar, IconButton, Tooltip } from '@mui/material';
|
| 2 |
+
import PersonOutlineIcon from '@mui/icons-material/PersonOutline';
|
| 3 |
+
import CloseIcon from '@mui/icons-material/Close';
|
| 4 |
+
import type { Message } from '@/types/agent';
|
| 5 |
+
|
| 6 |
+
interface UserMessageProps {
|
| 7 |
+
message: Message;
|
| 8 |
+
/** True if this message starts the last turn. */
|
| 9 |
+
isLastTurn?: boolean;
|
| 10 |
+
/** Callback to remove the last turn. */
|
| 11 |
+
onUndoTurn?: () => void;
|
| 12 |
+
/** Whether the agent is currently processing (disables undo). */
|
| 13 |
+
isProcessing?: boolean;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export default function UserMessage({
|
| 17 |
+
message,
|
| 18 |
+
isLastTurn = false,
|
| 19 |
+
onUndoTurn,
|
| 20 |
+
isProcessing = false,
|
| 21 |
+
}: UserMessageProps) {
|
| 22 |
+
const showUndo = isLastTurn && !isProcessing && !!onUndoTurn;
|
| 23 |
+
|
| 24 |
+
return (
|
| 25 |
+
<Stack
|
| 26 |
+
direction="row"
|
| 27 |
+
spacing={1.5}
|
| 28 |
+
justifyContent="flex-end"
|
| 29 |
+
alignItems="flex-start"
|
| 30 |
+
sx={{
|
| 31 |
+
// Show the undo button when hovering the entire row
|
| 32 |
+
'& .undo-btn': {
|
| 33 |
+
opacity: 0,
|
| 34 |
+
transition: 'opacity 0.15s ease',
|
| 35 |
+
},
|
| 36 |
+
'&:hover .undo-btn': {
|
| 37 |
+
opacity: 1,
|
| 38 |
+
},
|
| 39 |
+
}}
|
| 40 |
+
>
|
| 41 |
+
{/* Undo button — visible on hover, left of the bubble */}
|
| 42 |
+
{showUndo && (
|
| 43 |
+
<Box className="undo-btn" sx={{ display: 'flex', alignItems: 'center', mt: 0.75 }}>
|
| 44 |
+
<Tooltip title="Remove this turn" placement="left">
|
| 45 |
+
<IconButton
|
| 46 |
+
onClick={onUndoTurn}
|
| 47 |
+
size="small"
|
| 48 |
+
sx={{
|
| 49 |
+
width: 24,
|
| 50 |
+
height: 24,
|
| 51 |
+
color: 'var(--muted-text)',
|
| 52 |
+
'&:hover': {
|
| 53 |
+
color: 'var(--accent-red)',
|
| 54 |
+
bgcolor: 'rgba(244,67,54,0.08)',
|
| 55 |
+
},
|
| 56 |
+
}}
|
| 57 |
+
>
|
| 58 |
+
<CloseIcon sx={{ fontSize: 14 }} />
|
| 59 |
+
</IconButton>
|
| 60 |
+
</Tooltip>
|
| 61 |
+
</Box>
|
| 62 |
+
)}
|
| 63 |
+
|
| 64 |
+
<Box
|
| 65 |
+
sx={{
|
| 66 |
+
maxWidth: { xs: '88%', md: '72%' },
|
| 67 |
+
bgcolor: 'var(--surface)',
|
| 68 |
+
borderRadius: 1.5,
|
| 69 |
+
borderTopRightRadius: 4,
|
| 70 |
+
px: { xs: 1.5, md: 2.5 },
|
| 71 |
+
py: 1.5,
|
| 72 |
+
border: '1px solid var(--border)',
|
| 73 |
+
}}
|
| 74 |
+
>
|
| 75 |
+
<Typography
|
| 76 |
+
variant="body1"
|
| 77 |
+
sx={{
|
| 78 |
+
fontSize: '0.925rem',
|
| 79 |
+
lineHeight: 1.65,
|
| 80 |
+
color: 'var(--text)',
|
| 81 |
+
whiteSpace: 'pre-wrap',
|
| 82 |
+
wordBreak: 'break-word',
|
| 83 |
+
}}
|
| 84 |
+
>
|
| 85 |
+
{message.content}
|
| 86 |
+
</Typography>
|
| 87 |
+
|
| 88 |
+
<Typography
|
| 89 |
+
variant="caption"
|
| 90 |
+
sx={{
|
| 91 |
+
display: 'block',
|
| 92 |
+
textAlign: 'right',
|
| 93 |
+
mt: 1,
|
| 94 |
+
fontSize: '0.68rem',
|
| 95 |
+
color: 'var(--muted-text)',
|
| 96 |
+
opacity: 0.7,
|
| 97 |
+
}}
|
| 98 |
+
>
|
| 99 |
+
{new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
| 100 |
+
</Typography>
|
| 101 |
+
</Box>
|
| 102 |
+
|
| 103 |
+
<Avatar
|
| 104 |
+
sx={{
|
| 105 |
+
width: 28,
|
| 106 |
+
height: 28,
|
| 107 |
+
bgcolor: 'var(--hover-bg)',
|
| 108 |
+
border: '1px solid var(--border)',
|
| 109 |
+
flexShrink: 0,
|
| 110 |
+
mt: 0.5,
|
| 111 |
+
}}
|
| 112 |
+
>
|
| 113 |
+
<PersonOutlineIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />
|
| 114 |
+
</Avatar>
|
| 115 |
+
</Stack>
|
| 116 |
+
);
|
| 117 |
+
}
|
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { useRef, useEffect, useMemo } from 'react';
|
| 2 |
-
import { Box, Typography, IconButton } from '@mui/material';
|
| 3 |
import CloseIcon from '@mui/icons-material/Close';
|
| 4 |
import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
|
| 5 |
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
@@ -8,25 +8,104 @@ import CodeIcon from '@mui/icons-material/Code';
|
|
| 8 |
import TerminalIcon from '@mui/icons-material/Terminal';
|
| 9 |
import ArticleIcon from '@mui/icons-material/Article';
|
| 10 |
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
| 11 |
-
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
| 12 |
import ReactMarkdown from 'react-markdown';
|
| 13 |
import remarkGfm from 'remark-gfm';
|
| 14 |
import { useAgentStore } from '@/store/agentStore';
|
| 15 |
import { useLayoutStore } from '@/store/layoutStore';
|
| 16 |
import { processLogs } from '@/utils/logProcessor';
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
export default function CodePanel() {
|
| 19 |
-
const { panelContent, panelTabs, activePanelTab, setActivePanelTab, removePanelTab, plan } =
|
| 20 |
-
|
|
|
|
| 21 |
const scrollRef = useRef<HTMLDivElement>(null);
|
| 22 |
|
| 23 |
-
|
| 24 |
-
const activeTab = panelTabs.find(t => t.id === activePanelTab);
|
| 25 |
const currentContent = activeTab || panelContent;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
const displayContent = useMemo(() => {
|
| 28 |
if (!currentContent?.content) return '';
|
| 29 |
-
// Apply log processing only for text/logs, not for code/json
|
| 30 |
if (!currentContent.language || currentContent.language === 'text') {
|
| 31 |
return processLogs(currentContent.content);
|
| 32 |
}
|
|
@@ -34,36 +113,80 @@ export default function CodePanel() {
|
|
| 34 |
}, [currentContent?.content, currentContent?.language]);
|
| 35 |
|
| 36 |
useEffect(() => {
|
| 37 |
-
// Auto-scroll only for logs tab
|
| 38 |
if (scrollRef.current && activePanelTab === 'logs') {
|
| 39 |
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
| 40 |
}
|
| 41 |
}, [displayContent, activePanelTab]);
|
| 42 |
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
return (
|
| 46 |
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', bgcolor: 'var(--panel)' }}>
|
| 47 |
-
{/* Header
|
| 48 |
-
<Box
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
| 56 |
{hasTabs ? (
|
| 57 |
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
| 58 |
{panelTabs.map((tab) => {
|
| 59 |
const isActive = activePanelTab === tab.id;
|
| 60 |
-
// Choose icon based on tab type
|
| 61 |
-
let icon = <TerminalIcon sx={{ fontSize: 14 }} />;
|
| 62 |
-
if (tab.id === 'script' || tab.language === 'python') {
|
| 63 |
-
icon = <CodeIcon sx={{ fontSize: 14 }} />;
|
| 64 |
-
} else if (tab.id === 'tool_output' || tab.language === 'markdown' || tab.language === 'json') {
|
| 65 |
-
icon = <ArticleIcon sx={{ fontSize: 14 }} />;
|
| 66 |
-
}
|
| 67 |
return (
|
| 68 |
<Box
|
| 69 |
key={tab.id}
|
|
@@ -81,16 +204,14 @@ export default function CodePanel() {
|
|
| 81 |
textTransform: 'uppercase',
|
| 82 |
letterSpacing: '0.05em',
|
| 83 |
color: isActive ? 'var(--text)' : 'var(--muted-text)',
|
| 84 |
-
bgcolor: isActive ? '
|
| 85 |
border: '1px solid',
|
| 86 |
-
borderColor: isActive ? '
|
| 87 |
transition: 'all 0.15s ease',
|
| 88 |
-
'&:hover': {
|
| 89 |
-
bgcolor: 'rgba(255,255,255,0.05)',
|
| 90 |
-
},
|
| 91 |
}}
|
| 92 |
>
|
| 93 |
-
{
|
| 94 |
<span>{tab.title}</span>
|
| 95 |
<Box
|
| 96 |
component="span"
|
|
@@ -108,10 +229,7 @@ export default function CodePanel() {
|
|
| 108 |
borderRadius: '50%',
|
| 109 |
fontSize: '0.65rem',
|
| 110 |
opacity: 0.5,
|
| 111 |
-
'&:hover': {
|
| 112 |
-
opacity: 1,
|
| 113 |
-
bgcolor: 'rgba(255,255,255,0.1)',
|
| 114 |
-
},
|
| 115 |
}}
|
| 116 |
>
|
| 117 |
✕
|
|
@@ -121,16 +239,20 @@ export default function CodePanel() {
|
|
| 121 |
})}
|
| 122 |
</Box>
|
| 123 |
) : (
|
| 124 |
-
<Typography
|
|
|
|
|
|
|
|
|
|
| 125 |
{currentContent?.title || 'Code Panel'}
|
| 126 |
</Typography>
|
| 127 |
)}
|
|
|
|
| 128 |
<IconButton size="small" onClick={() => setRightPanelOpen(false)} sx={{ color: 'var(--muted-text)' }}>
|
| 129 |
<CloseIcon fontSize="small" />
|
| 130 |
</IconButton>
|
| 131 |
</Box>
|
| 132 |
|
| 133 |
-
{/* Main
|
| 134 |
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
| 135 |
{!currentContent ? (
|
| 136 |
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', p: 4 }}>
|
|
@@ -144,174 +266,72 @@ export default function CodePanel() {
|
|
| 144 |
ref={scrollRef}
|
| 145 |
className="code-panel"
|
| 146 |
sx={{
|
| 147 |
-
|
| 148 |
borderRadius: 'var(--radius-md)',
|
| 149 |
-
|
| 150 |
-
border: '1px solid
|
| 151 |
-
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco,
|
| 152 |
fontSize: '13px',
|
| 153 |
lineHeight: 1.55,
|
| 154 |
height: '100%',
|
| 155 |
overflow: 'auto',
|
| 156 |
}}
|
| 157 |
>
|
| 158 |
-
{
|
| 159 |
-
currentContent.language === 'python' ? (
|
| 160 |
-
<SyntaxHighlighter
|
| 161 |
-
language="python"
|
| 162 |
-
style={vscDarkPlus}
|
| 163 |
-
customStyle={{
|
| 164 |
-
margin: 0,
|
| 165 |
-
padding: 0,
|
| 166 |
-
background: 'transparent',
|
| 167 |
-
fontSize: '13px',
|
| 168 |
-
fontFamily: 'inherit',
|
| 169 |
-
}}
|
| 170 |
-
wrapLines={true}
|
| 171 |
-
wrapLongLines={true}
|
| 172 |
-
>
|
| 173 |
-
{displayContent}
|
| 174 |
-
</SyntaxHighlighter>
|
| 175 |
-
) : currentContent.language === 'json' ? (
|
| 176 |
-
<SyntaxHighlighter
|
| 177 |
-
language="json"
|
| 178 |
-
style={vscDarkPlus}
|
| 179 |
-
customStyle={{
|
| 180 |
-
margin: 0,
|
| 181 |
-
padding: 0,
|
| 182 |
-
background: 'transparent',
|
| 183 |
-
fontSize: '13px',
|
| 184 |
-
fontFamily: 'inherit',
|
| 185 |
-
}}
|
| 186 |
-
wrapLines={true}
|
| 187 |
-
wrapLongLines={true}
|
| 188 |
-
>
|
| 189 |
-
{displayContent}
|
| 190 |
-
</SyntaxHighlighter>
|
| 191 |
-
) : currentContent.language === 'markdown' ? (
|
| 192 |
-
<Box sx={{
|
| 193 |
-
color: 'var(--text)',
|
| 194 |
-
fontSize: '13px',
|
| 195 |
-
lineHeight: 1.6,
|
| 196 |
-
'& p': { m: 0, mb: 1.5, '&:last-child': { mb: 0 } },
|
| 197 |
-
'& pre': {
|
| 198 |
-
bgcolor: 'rgba(0,0,0,0.4)',
|
| 199 |
-
p: 1.5,
|
| 200 |
-
borderRadius: 1,
|
| 201 |
-
overflow: 'auto',
|
| 202 |
-
fontSize: '12px',
|
| 203 |
-
border: '1px solid rgba(255,255,255,0.05)',
|
| 204 |
-
},
|
| 205 |
-
'& code': {
|
| 206 |
-
bgcolor: 'rgba(255,255,255,0.05)',
|
| 207 |
-
px: 0.5,
|
| 208 |
-
py: 0.25,
|
| 209 |
-
borderRadius: 0.5,
|
| 210 |
-
fontSize: '12px',
|
| 211 |
-
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
|
| 212 |
-
},
|
| 213 |
-
'& pre code': { bgcolor: 'transparent', p: 0 },
|
| 214 |
-
'& a': {
|
| 215 |
-
color: 'var(--accent-yellow)',
|
| 216 |
-
textDecoration: 'none',
|
| 217 |
-
'&:hover': { textDecoration: 'underline' },
|
| 218 |
-
},
|
| 219 |
-
'& ul, & ol': { pl: 2.5, my: 1 },
|
| 220 |
-
'& li': { mb: 0.5 },
|
| 221 |
-
'& table': {
|
| 222 |
-
borderCollapse: 'collapse',
|
| 223 |
-
width: '100%',
|
| 224 |
-
my: 2,
|
| 225 |
-
fontSize: '12px',
|
| 226 |
-
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
|
| 227 |
-
},
|
| 228 |
-
'& th': {
|
| 229 |
-
borderBottom: '2px solid rgba(255,255,255,0.15)',
|
| 230 |
-
textAlign: 'left',
|
| 231 |
-
p: 1,
|
| 232 |
-
fontWeight: 600,
|
| 233 |
-
},
|
| 234 |
-
'& td': {
|
| 235 |
-
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
| 236 |
-
p: 1,
|
| 237 |
-
},
|
| 238 |
-
'& h1, & h2, & h3, & h4': {
|
| 239 |
-
mt: 2,
|
| 240 |
-
mb: 1,
|
| 241 |
-
fontWeight: 600,
|
| 242 |
-
},
|
| 243 |
-
'& h1': { fontSize: '1.25rem' },
|
| 244 |
-
'& h2': { fontSize: '1.1rem' },
|
| 245 |
-
'& h3': { fontSize: '1rem' },
|
| 246 |
-
'& blockquote': {
|
| 247 |
-
borderLeft: '3px solid rgba(255,255,255,0.2)',
|
| 248 |
-
pl: 2,
|
| 249 |
-
ml: 0,
|
| 250 |
-
color: 'var(--muted-text)',
|
| 251 |
-
},
|
| 252 |
-
}}>
|
| 253 |
-
<ReactMarkdown remarkPlugins={[remarkGfm]}>{displayContent}</ReactMarkdown>
|
| 254 |
-
</Box>
|
| 255 |
-
) : (
|
| 256 |
-
<Box component="pre" sx={{
|
| 257 |
-
m: 0,
|
| 258 |
-
fontFamily: 'inherit',
|
| 259 |
-
color: 'var(--text)',
|
| 260 |
-
whiteSpace: 'pre-wrap',
|
| 261 |
-
wordBreak: 'break-all'
|
| 262 |
-
}}>
|
| 263 |
-
<code>{displayContent}</code>
|
| 264 |
-
</Box>
|
| 265 |
-
)
|
| 266 |
-
) : (
|
| 267 |
-
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', opacity: 0.5 }}>
|
| 268 |
-
<Typography variant="caption">
|
| 269 |
-
NO CONTENT TO DISPLAY
|
| 270 |
-
</Typography>
|
| 271 |
-
</Box>
|
| 272 |
-
)}
|
| 273 |
</Box>
|
| 274 |
</Box>
|
| 275 |
)}
|
| 276 |
</Box>
|
| 277 |
|
| 278 |
-
{/* Plan
|
| 279 |
{plan && plan.length > 0 && (
|
| 280 |
-
<Box
|
| 281 |
-
|
| 282 |
-
|
|
|
|
| 283 |
maxHeight: '30%',
|
| 284 |
display: 'flex',
|
| 285 |
-
flexDirection: 'column'
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
</Typography>
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
<Box key={item.id} sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5 }}>
|
| 295 |
-
<Box sx={{ mt: 0.2 }}>
|
| 296 |
-
{item.status === 'completed' && <CheckCircleIcon sx={{ fontSize: 16, color: 'var(--accent-green)' }} />}
|
| 297 |
-
{item.status === 'in_progress' && <PlayCircleOutlineIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />}
|
| 298 |
-
{item.status === 'pending' && <RadioButtonUncheckedIcon sx={{ fontSize: 16, color: 'var(--muted-text)', opacity: 0.5 }} />}
|
| 299 |
-
</Box>
|
| 300 |
-
<Typography
|
| 301 |
-
variant="body2"
|
| 302 |
-
sx={{
|
| 303 |
-
fontSize: '13px',
|
| 304 |
-
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
|
| 305 |
-
color: item.status === 'completed' ? 'var(--muted-text)' : 'var(--text)',
|
| 306 |
-
textDecoration: item.status === 'completed' ? 'line-through' : 'none',
|
| 307 |
-
opacity: item.status === 'pending' ? 0.7 : 1
|
| 308 |
-
}}
|
| 309 |
-
>
|
| 310 |
-
{item.content}
|
| 311 |
-
</Typography>
|
| 312 |
-
</Box>
|
| 313 |
-
))}
|
| 314 |
-
</Box>
|
| 315 |
</Box>
|
| 316 |
)}
|
| 317 |
</Box>
|
|
|
|
| 1 |
import { useRef, useEffect, useMemo } from 'react';
|
| 2 |
+
import { Box, Stack, Typography, IconButton } from '@mui/material';
|
| 3 |
import CloseIcon from '@mui/icons-material/Close';
|
| 4 |
import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
|
| 5 |
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
|
|
|
| 8 |
import TerminalIcon from '@mui/icons-material/Terminal';
|
| 9 |
import ArticleIcon from '@mui/icons-material/Article';
|
| 10 |
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
| 11 |
+
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
| 12 |
import ReactMarkdown from 'react-markdown';
|
| 13 |
import remarkGfm from 'remark-gfm';
|
| 14 |
import { useAgentStore } from '@/store/agentStore';
|
| 15 |
import { useLayoutStore } from '@/store/layoutStore';
|
| 16 |
import { processLogs } from '@/utils/logProcessor';
|
| 17 |
|
| 18 |
+
// ── Helpers ──────────────────────────────────────────────────────
|
| 19 |
+
|
| 20 |
+
function tabIcon(id: string, language?: string) {
|
| 21 |
+
if (id === 'script' || language === 'python') return <CodeIcon sx={{ fontSize: 14 }} />;
|
| 22 |
+
if (id === 'tool_output' || language === 'markdown' || language === 'json')
|
| 23 |
+
return <ArticleIcon sx={{ fontSize: 14 }} />;
|
| 24 |
+
return <TerminalIcon sx={{ fontSize: 14 }} />;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function PlanStatusIcon({ status }: { status: string }) {
|
| 28 |
+
if (status === 'completed') return <CheckCircleIcon sx={{ fontSize: 16, color: 'var(--accent-green)' }} />;
|
| 29 |
+
if (status === 'in_progress') return <PlayCircleOutlineIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />;
|
| 30 |
+
return <RadioButtonUncheckedIcon sx={{ fontSize: 16, color: 'var(--muted-text)', opacity: 0.5 }} />;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// ── Markdown styles (adapts via CSS vars) ────────────────────────
|
| 34 |
+
const markdownSx = {
|
| 35 |
+
color: 'var(--text)',
|
| 36 |
+
fontSize: '13px',
|
| 37 |
+
lineHeight: 1.6,
|
| 38 |
+
'& p': { m: 0, mb: 1.5, '&:last-child': { mb: 0 } },
|
| 39 |
+
'& pre': {
|
| 40 |
+
bgcolor: 'var(--code-bg)',
|
| 41 |
+
p: 1.5,
|
| 42 |
+
borderRadius: 1,
|
| 43 |
+
overflow: 'auto',
|
| 44 |
+
fontSize: '12px',
|
| 45 |
+
border: '1px solid var(--tool-border)',
|
| 46 |
+
},
|
| 47 |
+
'& code': {
|
| 48 |
+
bgcolor: 'var(--hover-bg)',
|
| 49 |
+
px: 0.5,
|
| 50 |
+
py: 0.25,
|
| 51 |
+
borderRadius: 0.5,
|
| 52 |
+
fontSize: '12px',
|
| 53 |
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
|
| 54 |
+
},
|
| 55 |
+
'& pre code': { bgcolor: 'transparent', p: 0 },
|
| 56 |
+
'& a': {
|
| 57 |
+
color: 'var(--accent-yellow)',
|
| 58 |
+
textDecoration: 'none',
|
| 59 |
+
'&:hover': { textDecoration: 'underline' },
|
| 60 |
+
},
|
| 61 |
+
'& ul, & ol': { pl: 2.5, my: 1 },
|
| 62 |
+
'& li': { mb: 0.5 },
|
| 63 |
+
'& table': {
|
| 64 |
+
borderCollapse: 'collapse',
|
| 65 |
+
width: '100%',
|
| 66 |
+
my: 2,
|
| 67 |
+
fontSize: '12px',
|
| 68 |
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
|
| 69 |
+
},
|
| 70 |
+
'& th': {
|
| 71 |
+
borderBottom: '2px solid var(--border-hover)',
|
| 72 |
+
textAlign: 'left',
|
| 73 |
+
p: 1,
|
| 74 |
+
fontWeight: 600,
|
| 75 |
+
},
|
| 76 |
+
'& td': {
|
| 77 |
+
borderBottom: '1px solid var(--tool-border)',
|
| 78 |
+
p: 1,
|
| 79 |
+
},
|
| 80 |
+
'& h1, & h2, & h3, & h4': { mt: 2, mb: 1, fontWeight: 600 },
|
| 81 |
+
'& h1': { fontSize: '1.25rem' },
|
| 82 |
+
'& h2': { fontSize: '1.1rem' },
|
| 83 |
+
'& h3': { fontSize: '1rem' },
|
| 84 |
+
'& blockquote': {
|
| 85 |
+
borderLeft: '3px solid var(--accent-yellow)',
|
| 86 |
+
pl: 2,
|
| 87 |
+
ml: 0,
|
| 88 |
+
color: 'var(--muted-text)',
|
| 89 |
+
},
|
| 90 |
+
} as const;
|
| 91 |
+
|
| 92 |
+
// ── Component ────────────────────────────────────────────────────
|
| 93 |
+
|
| 94 |
export default function CodePanel() {
|
| 95 |
+
const { panelContent, panelTabs, activePanelTab, setActivePanelTab, removePanelTab, plan } =
|
| 96 |
+
useAgentStore();
|
| 97 |
+
const { setRightPanelOpen, themeMode } = useLayoutStore();
|
| 98 |
const scrollRef = useRef<HTMLDivElement>(null);
|
| 99 |
|
| 100 |
+
const activeTab = panelTabs.find((t) => t.id === activePanelTab);
|
|
|
|
| 101 |
const currentContent = activeTab || panelContent;
|
| 102 |
+
const hasTabs = panelTabs.length > 0;
|
| 103 |
+
|
| 104 |
+
const isDark = themeMode === 'dark';
|
| 105 |
+
const syntaxTheme = isDark ? vscDarkPlus : vs;
|
| 106 |
|
| 107 |
const displayContent = useMemo(() => {
|
| 108 |
if (!currentContent?.content) return '';
|
|
|
|
| 109 |
if (!currentContent.language || currentContent.language === 'text') {
|
| 110 |
return processLogs(currentContent.content);
|
| 111 |
}
|
|
|
|
| 113 |
}, [currentContent?.content, currentContent?.language]);
|
| 114 |
|
| 115 |
useEffect(() => {
|
|
|
|
| 116 |
if (scrollRef.current && activePanelTab === 'logs') {
|
| 117 |
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
| 118 |
}
|
| 119 |
}, [displayContent, activePanelTab]);
|
| 120 |
|
| 121 |
+
// ── Syntax-highlighted code block (DRY) ────────────────────────
|
| 122 |
+
const renderSyntaxBlock = (language: string) => (
|
| 123 |
+
<SyntaxHighlighter
|
| 124 |
+
language={language}
|
| 125 |
+
style={syntaxTheme}
|
| 126 |
+
customStyle={{
|
| 127 |
+
margin: 0,
|
| 128 |
+
padding: 0,
|
| 129 |
+
background: 'transparent',
|
| 130 |
+
fontSize: '13px',
|
| 131 |
+
fontFamily: 'inherit',
|
| 132 |
+
}}
|
| 133 |
+
wrapLines
|
| 134 |
+
wrapLongLines
|
| 135 |
+
>
|
| 136 |
+
{displayContent}
|
| 137 |
+
</SyntaxHighlighter>
|
| 138 |
+
);
|
| 139 |
+
|
| 140 |
+
// ── Content renderer ───────────────────────────────────────────
|
| 141 |
+
const renderContent = () => {
|
| 142 |
+
if (!currentContent?.content) {
|
| 143 |
+
return (
|
| 144 |
+
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', opacity: 0.5 }}>
|
| 145 |
+
<Typography variant="caption">NO CONTENT TO DISPLAY</Typography>
|
| 146 |
+
</Box>
|
| 147 |
+
);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
if (currentContent.language === 'python') return renderSyntaxBlock('python');
|
| 151 |
+
if (currentContent.language === 'json') return renderSyntaxBlock('json');
|
| 152 |
+
|
| 153 |
+
if (currentContent.language === 'markdown') {
|
| 154 |
+
return (
|
| 155 |
+
<Box sx={markdownSx}>
|
| 156 |
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>{displayContent}</ReactMarkdown>
|
| 157 |
+
</Box>
|
| 158 |
+
);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
// Plain text / logs
|
| 162 |
+
return (
|
| 163 |
+
<Box
|
| 164 |
+
component="pre"
|
| 165 |
+
sx={{ m: 0, fontFamily: 'inherit', color: 'var(--text)', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
| 166 |
+
>
|
| 167 |
+
<code>{displayContent}</code>
|
| 168 |
+
</Box>
|
| 169 |
+
);
|
| 170 |
+
};
|
| 171 |
|
| 172 |
return (
|
| 173 |
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', bgcolor: 'var(--panel)' }}>
|
| 174 |
+
{/* ── Header (60 px, aligned with top bar) ────────────────── */}
|
| 175 |
+
<Box
|
| 176 |
+
sx={{
|
| 177 |
+
height: 60,
|
| 178 |
+
display: 'flex',
|
| 179 |
+
alignItems: 'center',
|
| 180 |
+
justifyContent: 'space-between',
|
| 181 |
+
px: 2,
|
| 182 |
+
borderBottom: '1px solid var(--border)',
|
| 183 |
+
flexShrink: 0,
|
| 184 |
+
}}
|
| 185 |
+
>
|
| 186 |
{hasTabs ? (
|
| 187 |
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexWrap: 'wrap' }}>
|
| 188 |
{panelTabs.map((tab) => {
|
| 189 |
const isActive = activePanelTab === tab.id;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
return (
|
| 191 |
<Box
|
| 192 |
key={tab.id}
|
|
|
|
| 204 |
textTransform: 'uppercase',
|
| 205 |
letterSpacing: '0.05em',
|
| 206 |
color: isActive ? 'var(--text)' : 'var(--muted-text)',
|
| 207 |
+
bgcolor: isActive ? 'var(--tab-active-bg)' : 'transparent',
|
| 208 |
border: '1px solid',
|
| 209 |
+
borderColor: isActive ? 'var(--tab-active-border)' : 'transparent',
|
| 210 |
transition: 'all 0.15s ease',
|
| 211 |
+
'&:hover': { bgcolor: 'var(--tab-hover-bg)' },
|
|
|
|
|
|
|
| 212 |
}}
|
| 213 |
>
|
| 214 |
+
{tabIcon(tab.id, tab.language)}
|
| 215 |
<span>{tab.title}</span>
|
| 216 |
<Box
|
| 217 |
component="span"
|
|
|
|
| 229 |
borderRadius: '50%',
|
| 230 |
fontSize: '0.65rem',
|
| 231 |
opacity: 0.5,
|
| 232 |
+
'&:hover': { opacity: 1, bgcolor: 'var(--tab-close-hover)' },
|
|
|
|
|
|
|
|
|
|
| 233 |
}}
|
| 234 |
>
|
| 235 |
✕
|
|
|
|
| 239 |
})}
|
| 240 |
</Box>
|
| 241 |
) : (
|
| 242 |
+
<Typography
|
| 243 |
+
variant="caption"
|
| 244 |
+
sx={{ fontWeight: 600, color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
| 245 |
+
>
|
| 246 |
{currentContent?.title || 'Code Panel'}
|
| 247 |
</Typography>
|
| 248 |
)}
|
| 249 |
+
|
| 250 |
<IconButton size="small" onClick={() => setRightPanelOpen(false)} sx={{ color: 'var(--muted-text)' }}>
|
| 251 |
<CloseIcon fontSize="small" />
|
| 252 |
</IconButton>
|
| 253 |
</Box>
|
| 254 |
|
| 255 |
+
{/* ── Main content area ─────────────────────────────────── */}
|
| 256 |
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
| 257 |
{!currentContent ? (
|
| 258 |
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', p: 4 }}>
|
|
|
|
| 266 |
ref={scrollRef}
|
| 267 |
className="code-panel"
|
| 268 |
sx={{
|
| 269 |
+
bgcolor: 'var(--code-panel-bg)',
|
| 270 |
borderRadius: 'var(--radius-md)',
|
| 271 |
+
p: '18px',
|
| 272 |
+
border: '1px solid var(--border)',
|
| 273 |
+
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
|
| 274 |
fontSize: '13px',
|
| 275 |
lineHeight: 1.55,
|
| 276 |
height: '100%',
|
| 277 |
overflow: 'auto',
|
| 278 |
}}
|
| 279 |
>
|
| 280 |
+
{renderContent()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
</Box>
|
| 282 |
</Box>
|
| 283 |
)}
|
| 284 |
</Box>
|
| 285 |
|
| 286 |
+
{/* ── Plan display (bottom) ─────────────────────────────── */}
|
| 287 |
{plan && plan.length > 0 && (
|
| 288 |
+
<Box
|
| 289 |
+
sx={{
|
| 290 |
+
borderTop: '1px solid var(--border)',
|
| 291 |
+
bgcolor: 'var(--plan-bg)',
|
| 292 |
maxHeight: '30%',
|
| 293 |
display: 'flex',
|
| 294 |
+
flexDirection: 'column',
|
| 295 |
+
}}
|
| 296 |
+
>
|
| 297 |
+
<Box
|
| 298 |
+
sx={{
|
| 299 |
+
p: 1.5,
|
| 300 |
+
borderBottom: '1px solid var(--border)',
|
| 301 |
+
display: 'flex',
|
| 302 |
+
alignItems: 'center',
|
| 303 |
+
gap: 1,
|
| 304 |
+
}}
|
| 305 |
+
>
|
| 306 |
+
<Typography
|
| 307 |
+
variant="caption"
|
| 308 |
+
sx={{ fontWeight: 600, color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
| 309 |
+
>
|
| 310 |
+
CURRENT PLAN
|
| 311 |
+
</Typography>
|
| 312 |
+
</Box>
|
| 313 |
+
|
| 314 |
+
<Stack spacing={1} sx={{ p: 2, overflow: 'auto' }}>
|
| 315 |
+
{plan.map((item) => (
|
| 316 |
+
<Stack key={item.id} direction="row" alignItems="flex-start" spacing={1.5}>
|
| 317 |
+
<Box sx={{ mt: 0.2 }}>
|
| 318 |
+
<PlanStatusIcon status={item.status} />
|
| 319 |
+
</Box>
|
| 320 |
+
<Typography
|
| 321 |
+
variant="body2"
|
| 322 |
+
sx={{
|
| 323 |
+
fontSize: '13px',
|
| 324 |
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
|
| 325 |
+
color: item.status === 'completed' ? 'var(--muted-text)' : 'var(--text)',
|
| 326 |
+
textDecoration: item.status === 'completed' ? 'line-through' : 'none',
|
| 327 |
+
opacity: item.status === 'pending' ? 0.7 : 1,
|
| 328 |
+
}}
|
| 329 |
+
>
|
| 330 |
+
{item.content}
|
| 331 |
</Typography>
|
| 332 |
+
</Stack>
|
| 333 |
+
))}
|
| 334 |
+
</Stack>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
</Box>
|
| 336 |
)}
|
| 337 |
</Box>
|
|
@@ -4,10 +4,17 @@ import {
|
|
| 4 |
Drawer,
|
| 5 |
Typography,
|
| 6 |
IconButton,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
} from '@mui/material';
|
| 8 |
import MenuIcon from '@mui/icons-material/Menu';
|
| 9 |
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
| 10 |
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
import { useSessionStore } from '@/store/sessionStore';
|
| 13 |
import { useAgentStore } from '@/store/agentStore';
|
|
@@ -17,49 +24,56 @@ import SessionSidebar from '@/components/SessionSidebar/SessionSidebar';
|
|
| 17 |
import CodePanel from '@/components/CodePanel/CodePanel';
|
| 18 |
import ChatInput from '@/components/Chat/ChatInput';
|
| 19 |
import MessageList from '@/components/Chat/MessageList';
|
|
|
|
|
|
|
| 20 |
import type { Message } from '@/types/agent';
|
| 21 |
|
| 22 |
const DRAWER_WIDTH = 260;
|
| 23 |
|
| 24 |
export default function AppLayout() {
|
| 25 |
-
const { activeSessionId } = useSessionStore();
|
| 26 |
-
const { isConnected, isProcessing, getMessages, addMessage } = useAgentStore();
|
| 27 |
const {
|
| 28 |
isLeftSidebarOpen,
|
| 29 |
isRightPanelOpen,
|
| 30 |
rightPanelWidth,
|
|
|
|
| 31 |
setRightPanelWidth,
|
|
|
|
| 32 |
toggleLeftSidebar,
|
| 33 |
-
|
| 34 |
} = useLayoutStore();
|
| 35 |
|
| 36 |
-
const
|
| 37 |
-
|
| 38 |
-
const startResizing = useCallback((e: React.MouseEvent) => {
|
| 39 |
-
e.preventDefault();
|
| 40 |
-
isResizing.current = true;
|
| 41 |
-
document.addEventListener('mousemove', handleMouseMove);
|
| 42 |
-
document.addEventListener('mouseup', stopResizing);
|
| 43 |
-
document.body.style.cursor = 'col-resize';
|
| 44 |
-
}, []);
|
| 45 |
|
| 46 |
-
const
|
| 47 |
-
isResizing.current = false;
|
| 48 |
-
document.removeEventListener('mousemove', handleMouseMove);
|
| 49 |
-
document.removeEventListener('mouseup', stopResizing);
|
| 50 |
-
document.body.style.cursor = 'default';
|
| 51 |
-
}, []);
|
| 52 |
|
| 53 |
const handleMouseMove = useCallback((e: MouseEvent) => {
|
| 54 |
if (!isResizing.current) return;
|
| 55 |
const newWidth = window.innerWidth - e.clientX;
|
| 56 |
-
const maxWidth = window.innerWidth * 0.
|
| 57 |
const minWidth = 300;
|
| 58 |
if (newWidth > minWidth && newWidth < maxWidth) {
|
| 59 |
setRightPanelWidth(newWidth);
|
| 60 |
}
|
| 61 |
}, [setRightPanelWidth]);
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
useEffect(() => {
|
| 64 |
return () => {
|
| 65 |
document.removeEventListener('mousemove', handleMouseMove);
|
|
@@ -67,18 +81,49 @@ export default function AppLayout() {
|
|
| 67 |
};
|
| 68 |
}, [handleMouseMove, stopResizing]);
|
| 69 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
const messages = activeSessionId ? getMessages(activeSessionId) : [];
|
|
|
|
| 71 |
|
| 72 |
useAgentWebSocket({
|
| 73 |
sessionId: activeSessionId,
|
| 74 |
-
onReady: () =>
|
| 75 |
-
onError: (error) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
});
|
| 77 |
|
| 78 |
const handleSendMessage = useCallback(
|
| 79 |
async (text: string) => {
|
| 80 |
-
if (!activeSessionId || !text.trim()) return;
|
| 81 |
|
|
|
|
|
|
|
|
|
|
| 82 |
const userMsg: Message = {
|
| 83 |
id: `user_${Date.now()}`,
|
| 84 |
role: 'user',
|
|
@@ -87,55 +132,127 @@ export default function AppLayout() {
|
|
| 87 |
};
|
| 88 |
addMessage(activeSessionId, userMsg);
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
try {
|
| 91 |
-
await
|
| 92 |
method: 'POST',
|
| 93 |
-
headers: { 'Content-Type': 'application/json' },
|
| 94 |
body: JSON.stringify({
|
| 95 |
session_id: activeSessionId,
|
| 96 |
text: text.trim(),
|
| 97 |
}),
|
| 98 |
});
|
| 99 |
} catch (e) {
|
| 100 |
-
|
| 101 |
}
|
| 102 |
},
|
| 103 |
-
[activeSessionId, addMessage]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
);
|
| 105 |
|
|
|
|
| 106 |
return (
|
| 107 |
<Box sx={{ display: 'flex', width: '100%', height: '100%' }}>
|
| 108 |
-
{/* Left Sidebar
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
}}
|
| 117 |
-
>
|
| 118 |
-
<Drawer
|
| 119 |
-
variant="persistent"
|
| 120 |
sx={{
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
borderRight: '1px solid',
|
| 126 |
-
borderColor: 'divider',
|
| 127 |
-
top: 0,
|
| 128 |
-
height: '100%',
|
| 129 |
-
bgcolor: 'var(--panel)', // Ensure correct background matches sidebar
|
| 130 |
-
},
|
| 131 |
}}
|
| 132 |
-
open={isLeftSidebarOpen}
|
| 133 |
>
|
| 134 |
-
|
| 135 |
-
</
|
| 136 |
-
|
| 137 |
|
| 138 |
-
{/* Main Content
|
| 139 |
<Box
|
| 140 |
sx={{
|
| 141 |
flexGrow: 1,
|
|
@@ -143,142 +260,188 @@ export default function AppLayout() {
|
|
| 143 |
display: 'flex',
|
| 144 |
flexDirection: 'column',
|
| 145 |
transition: isResizing.current ? 'none' : 'width 0.2s',
|
| 146 |
-
position: 'relative',
|
| 147 |
overflow: 'hidden',
|
|
|
|
| 148 |
}}
|
| 149 |
>
|
| 150 |
-
{/* Top Header Bar
|
| 151 |
<Box sx={{
|
| 152 |
-
height:
|
| 153 |
-
px: 1,
|
| 154 |
display: 'flex',
|
| 155 |
alignItems: 'center',
|
| 156 |
borderBottom: 1,
|
| 157 |
borderColor: 'divider',
|
| 158 |
bgcolor: 'background.default',
|
| 159 |
zIndex: 1200,
|
|
|
|
| 160 |
}}>
|
| 161 |
<IconButton onClick={toggleLeftSidebar} size="small">
|
| 162 |
-
{isLeftSidebarOpen ? <ChevronLeftIcon /> : <MenuIcon />}
|
| 163 |
</IconButton>
|
| 164 |
|
| 165 |
-
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
|
| 166 |
-
<
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
| 170 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
</Box>
|
| 172 |
|
| 173 |
-
<IconButton
|
| 174 |
-
onClick={
|
| 175 |
-
size="small"
|
| 176 |
-
sx={{
|
|
|
|
|
|
|
|
|
|
| 177 |
>
|
| 178 |
-
<
|
| 179 |
</IconButton>
|
| 180 |
</Box>
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
<Box
|
| 183 |
-
component="main"
|
| 184 |
-
className="chat-pane"
|
| 185 |
sx={{
|
| 186 |
flexGrow: 1,
|
| 187 |
display: 'flex',
|
| 188 |
-
flexDirection: 'column',
|
| 189 |
overflow: 'hidden',
|
| 190 |
-
background: 'linear-gradient(180deg, var(--bg), var(--panel))',
|
| 191 |
-
padding: '24px',
|
| 192 |
}}
|
| 193 |
>
|
| 194 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
<>
|
| 196 |
-
<
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
</>
|
| 202 |
-
) : (
|
| 203 |
-
<Box
|
| 204 |
-
sx={{
|
| 205 |
-
flex: 1,
|
| 206 |
-
display: 'flex',
|
| 207 |
-
alignItems: 'center',
|
| 208 |
-
justifyContent: 'center',
|
| 209 |
-
flexDirection: 'column',
|
| 210 |
-
gap: 2,
|
| 211 |
-
}}
|
| 212 |
-
>
|
| 213 |
-
<Typography variant="h5" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
|
| 214 |
-
NO SESSION SELECTED
|
| 215 |
-
</Typography>
|
| 216 |
-
<Typography variant="body2" color="text.secondary" sx={{ fontFamily: 'monospace' }}>
|
| 217 |
-
Initialize a session via the sidebar
|
| 218 |
-
</Typography>
|
| 219 |
-
</Box>
|
| 220 |
)}
|
| 221 |
</Box>
|
| 222 |
</Box>
|
| 223 |
|
| 224 |
-
{/*
|
| 225 |
-
{
|
| 226 |
-
<Box
|
| 227 |
-
onMouseDown={startResizing}
|
| 228 |
-
sx={{
|
| 229 |
-
width: '4px',
|
| 230 |
-
cursor: 'col-resize',
|
| 231 |
-
bgcolor: 'divider',
|
| 232 |
-
display: 'flex',
|
| 233 |
-
alignItems: 'center',
|
| 234 |
-
justifyContent: 'center',
|
| 235 |
-
transition: 'background-color 0.2s',
|
| 236 |
-
zIndex: 1300,
|
| 237 |
-
overflow: 'hidden',
|
| 238 |
-
'&:hover': {
|
| 239 |
-
bgcolor: 'primary.main',
|
| 240 |
-
},
|
| 241 |
-
}}
|
| 242 |
-
>
|
| 243 |
-
<DragIndicatorIcon
|
| 244 |
-
sx={{
|
| 245 |
-
fontSize: '0.8rem',
|
| 246 |
-
color: 'text.secondary',
|
| 247 |
-
pointerEvents: 'none',
|
| 248 |
-
}}
|
| 249 |
-
/>
|
| 250 |
-
</Box>
|
| 251 |
-
)}
|
| 252 |
-
|
| 253 |
-
{/* Right Panel Drawer */}
|
| 254 |
-
<Box
|
| 255 |
-
component="nav"
|
| 256 |
-
sx={{
|
| 257 |
-
width: { md: isRightPanelOpen ? rightPanelWidth : 0 },
|
| 258 |
-
flexShrink: { md: 0 },
|
| 259 |
-
transition: isResizing.current ? 'none' : 'width 0.2s',
|
| 260 |
-
overflow: 'hidden',
|
| 261 |
-
}}
|
| 262 |
-
>
|
| 263 |
<Drawer
|
| 264 |
-
anchor="
|
| 265 |
-
|
|
|
|
| 266 |
sx={{
|
| 267 |
-
display: { xs: 'none', md: 'block' },
|
| 268 |
'& .MuiDrawer-paper': {
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
top: 0,
|
| 273 |
-
height: '100%',
|
| 274 |
bgcolor: 'var(--panel)',
|
| 275 |
},
|
| 276 |
}}
|
| 277 |
-
open={isRightPanelOpen}
|
| 278 |
>
|
| 279 |
<CodePanel />
|
| 280 |
</Drawer>
|
| 281 |
-
|
| 282 |
</Box>
|
| 283 |
);
|
| 284 |
}
|
|
|
|
| 4 |
Drawer,
|
| 5 |
Typography,
|
| 6 |
IconButton,
|
| 7 |
+
Alert,
|
| 8 |
+
AlertTitle,
|
| 9 |
+
useMediaQuery,
|
| 10 |
+
useTheme,
|
| 11 |
} from '@mui/material';
|
| 12 |
import MenuIcon from '@mui/icons-material/Menu';
|
| 13 |
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
| 14 |
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
| 15 |
+
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined';
|
| 16 |
+
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined';
|
| 17 |
+
import { logger } from '@/utils/logger';
|
| 18 |
|
| 19 |
import { useSessionStore } from '@/store/sessionStore';
|
| 20 |
import { useAgentStore } from '@/store/agentStore';
|
|
|
|
| 24 |
import CodePanel from '@/components/CodePanel/CodePanel';
|
| 25 |
import ChatInput from '@/components/Chat/ChatInput';
|
| 26 |
import MessageList from '@/components/Chat/MessageList';
|
| 27 |
+
import WelcomeScreen from '@/components/WelcomeScreen/WelcomeScreen';
|
| 28 |
+
import { apiFetch } from '@/utils/api';
|
| 29 |
import type { Message } from '@/types/agent';
|
| 30 |
|
| 31 |
const DRAWER_WIDTH = 260;
|
| 32 |
|
| 33 |
export default function AppLayout() {
|
| 34 |
+
const { sessions, activeSessionId, deleteSession, updateSessionTitle } = useSessionStore();
|
| 35 |
+
const { isConnected, isProcessing, getMessages, addMessage, setProcessing, llmHealthError, setLlmHealthError } = useAgentStore();
|
| 36 |
const {
|
| 37 |
isLeftSidebarOpen,
|
| 38 |
isRightPanelOpen,
|
| 39 |
rightPanelWidth,
|
| 40 |
+
themeMode,
|
| 41 |
setRightPanelWidth,
|
| 42 |
+
setLeftSidebarOpen,
|
| 43 |
toggleLeftSidebar,
|
| 44 |
+
toggleTheme,
|
| 45 |
} = useLayoutStore();
|
| 46 |
|
| 47 |
+
const theme = useTheme();
|
| 48 |
+
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
+
const isResizing = useRef(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
const handleMouseMove = useCallback((e: MouseEvent) => {
|
| 53 |
if (!isResizing.current) return;
|
| 54 |
const newWidth = window.innerWidth - e.clientX;
|
| 55 |
+
const maxWidth = window.innerWidth * 0.6;
|
| 56 |
const minWidth = 300;
|
| 57 |
if (newWidth > minWidth && newWidth < maxWidth) {
|
| 58 |
setRightPanelWidth(newWidth);
|
| 59 |
}
|
| 60 |
}, [setRightPanelWidth]);
|
| 61 |
|
| 62 |
+
const stopResizing = useCallback(() => {
|
| 63 |
+
isResizing.current = false;
|
| 64 |
+
document.removeEventListener('mousemove', handleMouseMove);
|
| 65 |
+
document.removeEventListener('mouseup', stopResizing);
|
| 66 |
+
document.body.style.cursor = 'default';
|
| 67 |
+
}, [handleMouseMove]);
|
| 68 |
+
|
| 69 |
+
const startResizing = useCallback((e: React.MouseEvent) => {
|
| 70 |
+
e.preventDefault();
|
| 71 |
+
isResizing.current = true;
|
| 72 |
+
document.addEventListener('mousemove', handleMouseMove);
|
| 73 |
+
document.addEventListener('mouseup', stopResizing);
|
| 74 |
+
document.body.style.cursor = 'col-resize';
|
| 75 |
+
}, [handleMouseMove, stopResizing]);
|
| 76 |
+
|
| 77 |
useEffect(() => {
|
| 78 |
return () => {
|
| 79 |
document.removeEventListener('mousemove', handleMouseMove);
|
|
|
|
| 81 |
};
|
| 82 |
}, [handleMouseMove, stopResizing]);
|
| 83 |
|
| 84 |
+
// ── LLM health check on mount ───────────────────────────────────
|
| 85 |
+
useEffect(() => {
|
| 86 |
+
let cancelled = false;
|
| 87 |
+
(async () => {
|
| 88 |
+
try {
|
| 89 |
+
const res = await apiFetch('/api/health/llm');
|
| 90 |
+
const data = await res.json();
|
| 91 |
+
if (!cancelled && data.status === 'error') {
|
| 92 |
+
setLlmHealthError({
|
| 93 |
+
error: data.error || 'Unknown LLM error',
|
| 94 |
+
errorType: data.error_type || 'unknown',
|
| 95 |
+
model: data.model,
|
| 96 |
+
});
|
| 97 |
+
} else if (!cancelled) {
|
| 98 |
+
setLlmHealthError(null);
|
| 99 |
+
}
|
| 100 |
+
} catch {
|
| 101 |
+
// Backend unreachable — not an LLM issue, ignore
|
| 102 |
+
}
|
| 103 |
+
})();
|
| 104 |
+
return () => { cancelled = true; };
|
| 105 |
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
| 106 |
+
|
| 107 |
const messages = activeSessionId ? getMessages(activeSessionId) : [];
|
| 108 |
+
const hasAnySessions = sessions.length > 0;
|
| 109 |
|
| 110 |
useAgentWebSocket({
|
| 111 |
sessionId: activeSessionId,
|
| 112 |
+
onReady: () => logger.log('Agent ready'),
|
| 113 |
+
onError: (error) => logger.error('Agent error:', error),
|
| 114 |
+
onSessionDead: (deadSessionId) => {
|
| 115 |
+
logger.log('Removing dead session:', deadSessionId);
|
| 116 |
+
deleteSession(deadSessionId);
|
| 117 |
+
},
|
| 118 |
});
|
| 119 |
|
| 120 |
const handleSendMessage = useCallback(
|
| 121 |
async (text: string) => {
|
| 122 |
+
if (!activeSessionId || !text.trim() || isProcessing) return;
|
| 123 |
|
| 124 |
+
// Lock input immediately to prevent double-sends
|
| 125 |
+
setProcessing(true);
|
| 126 |
+
|
| 127 |
const userMsg: Message = {
|
| 128 |
id: `user_${Date.now()}`,
|
| 129 |
role: 'user',
|
|
|
|
| 132 |
};
|
| 133 |
addMessage(activeSessionId, userMsg);
|
| 134 |
|
| 135 |
+
// Auto-title the session from the first user message (async, non-blocking)
|
| 136 |
+
const currentMessages = getMessages(activeSessionId);
|
| 137 |
+
const isFirstMessage = currentMessages.filter((m) => m.role === 'user').length <= 1;
|
| 138 |
+
if (isFirstMessage) {
|
| 139 |
+
const sessionId = activeSessionId;
|
| 140 |
+
apiFetch('/api/title', {
|
| 141 |
+
method: 'POST',
|
| 142 |
+
body: JSON.stringify({ session_id: sessionId, text: text.trim() }),
|
| 143 |
+
})
|
| 144 |
+
.then((res) => res.json())
|
| 145 |
+
.then((data) => {
|
| 146 |
+
if (data.title) updateSessionTitle(sessionId, data.title);
|
| 147 |
+
})
|
| 148 |
+
.catch(() => {
|
| 149 |
+
const raw = text.trim();
|
| 150 |
+
updateSessionTitle(sessionId, raw.length > 40 ? raw.slice(0, 40) + '…' : raw);
|
| 151 |
+
});
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
try {
|
| 155 |
+
await apiFetch('/api/submit', {
|
| 156 |
method: 'POST',
|
|
|
|
| 157 |
body: JSON.stringify({
|
| 158 |
session_id: activeSessionId,
|
| 159 |
text: text.trim(),
|
| 160 |
}),
|
| 161 |
});
|
| 162 |
} catch (e) {
|
| 163 |
+
logger.error('Send failed:', e);
|
| 164 |
}
|
| 165 |
},
|
| 166 |
+
[activeSessionId, addMessage, getMessages, updateSessionTitle, isProcessing, setProcessing]
|
| 167 |
+
);
|
| 168 |
+
|
| 169 |
+
// Close sidebar on mobile after selecting a session
|
| 170 |
+
const handleSidebarClose = useCallback(() => {
|
| 171 |
+
if (isMobile) setLeftSidebarOpen(false);
|
| 172 |
+
}, [isMobile, setLeftSidebarOpen]);
|
| 173 |
+
|
| 174 |
+
// ── LLM error banner (shared) ─────────────────────────────────────
|
| 175 |
+
const llmBanner = llmHealthError && (
|
| 176 |
+
<Alert
|
| 177 |
+
severity="error"
|
| 178 |
+
variant="filled"
|
| 179 |
+
onClose={() => setLlmHealthError(null)}
|
| 180 |
+
sx={{ borderRadius: 0, flexShrink: 0, '& .MuiAlert-message': { flex: 1 } }}
|
| 181 |
+
>
|
| 182 |
+
<AlertTitle sx={{ fontWeight: 700, fontSize: '0.85rem' }}>
|
| 183 |
+
{llmHealthError.errorType === 'credits'
|
| 184 |
+
? 'API Credits Exhausted'
|
| 185 |
+
: llmHealthError.errorType === 'auth'
|
| 186 |
+
? 'Invalid API Key'
|
| 187 |
+
: llmHealthError.errorType === 'rate_limit'
|
| 188 |
+
? 'Rate Limited'
|
| 189 |
+
: llmHealthError.errorType === 'network'
|
| 190 |
+
? 'LLM Provider Unreachable'
|
| 191 |
+
: 'LLM Error'}
|
| 192 |
+
</AlertTitle>
|
| 193 |
+
<Typography variant="body2" sx={{ fontSize: '0.8rem', opacity: 0.9 }}>
|
| 194 |
+
Model: <strong>{llmHealthError.model}</strong> — {llmHealthError.error.slice(0, 200)}
|
| 195 |
+
</Typography>
|
| 196 |
+
</Alert>
|
| 197 |
+
);
|
| 198 |
+
|
| 199 |
+
// ── Welcome screen: no sessions at all ────────────────────────────
|
| 200 |
+
if (!hasAnySessions) {
|
| 201 |
+
return (
|
| 202 |
+
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
| 203 |
+
{llmBanner}
|
| 204 |
+
<WelcomeScreen />
|
| 205 |
+
</Box>
|
| 206 |
+
);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// ── Sidebar drawer ────────────────────────────────────────────────
|
| 210 |
+
const sidebarDrawer = (
|
| 211 |
+
<Drawer
|
| 212 |
+
variant={isMobile ? 'temporary' : 'persistent'}
|
| 213 |
+
anchor="left"
|
| 214 |
+
open={isLeftSidebarOpen}
|
| 215 |
+
onClose={() => setLeftSidebarOpen(false)}
|
| 216 |
+
ModalProps={{ keepMounted: true }} // Better mobile perf
|
| 217 |
+
sx={{
|
| 218 |
+
'& .MuiDrawer-paper': {
|
| 219 |
+
boxSizing: 'border-box',
|
| 220 |
+
width: DRAWER_WIDTH,
|
| 221 |
+
borderRight: '1px solid',
|
| 222 |
+
borderColor: 'divider',
|
| 223 |
+
top: 0,
|
| 224 |
+
height: '100%',
|
| 225 |
+
bgcolor: 'var(--panel)',
|
| 226 |
+
},
|
| 227 |
+
}}
|
| 228 |
+
>
|
| 229 |
+
<SessionSidebar onClose={handleSidebarClose} />
|
| 230 |
+
</Drawer>
|
| 231 |
);
|
| 232 |
|
| 233 |
+
// ── Main chat interface ───────────────────────────────────────────
|
| 234 |
return (
|
| 235 |
<Box sx={{ display: 'flex', width: '100%', height: '100%' }}>
|
| 236 |
+
{/* ── Left Sidebar ─────────────────────────────────────────── */}
|
| 237 |
+
{isMobile ? (
|
| 238 |
+
// Mobile: temporary overlay drawer (no reserved width)
|
| 239 |
+
sidebarDrawer
|
| 240 |
+
) : (
|
| 241 |
+
// Desktop: persistent drawer with reserved width
|
| 242 |
+
<Box
|
| 243 |
+
component="nav"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
sx={{
|
| 245 |
+
width: isLeftSidebarOpen ? DRAWER_WIDTH : 0,
|
| 246 |
+
flexShrink: 0,
|
| 247 |
+
transition: isResizing.current ? 'none' : 'width 0.2s',
|
| 248 |
+
overflow: 'hidden',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
}}
|
|
|
|
| 250 |
>
|
| 251 |
+
{sidebarDrawer}
|
| 252 |
+
</Box>
|
| 253 |
+
)}
|
| 254 |
|
| 255 |
+
{/* ── Main Content (header + chat + code panel) ────────────── */}
|
| 256 |
<Box
|
| 257 |
sx={{
|
| 258 |
flexGrow: 1,
|
|
|
|
| 260 |
display: 'flex',
|
| 261 |
flexDirection: 'column',
|
| 262 |
transition: isResizing.current ? 'none' : 'width 0.2s',
|
|
|
|
| 263 |
overflow: 'hidden',
|
| 264 |
+
minWidth: 0,
|
| 265 |
}}
|
| 266 |
>
|
| 267 |
+
{/* ── Top Header Bar ─────────────────────────────────────── */}
|
| 268 |
<Box sx={{
|
| 269 |
+
height: { xs: 52, md: 60 },
|
| 270 |
+
px: { xs: 1, md: 2 },
|
| 271 |
display: 'flex',
|
| 272 |
alignItems: 'center',
|
| 273 |
borderBottom: 1,
|
| 274 |
borderColor: 'divider',
|
| 275 |
bgcolor: 'background.default',
|
| 276 |
zIndex: 1200,
|
| 277 |
+
flexShrink: 0,
|
| 278 |
}}>
|
| 279 |
<IconButton onClick={toggleLeftSidebar} size="small">
|
| 280 |
+
{isLeftSidebarOpen && !isMobile ? <ChevronLeftIcon /> : <MenuIcon />}
|
| 281 |
</IconButton>
|
| 282 |
|
| 283 |
+
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 0.75 }}>
|
| 284 |
+
<Box
|
| 285 |
+
component="img"
|
| 286 |
+
src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg"
|
| 287 |
+
alt="HF"
|
| 288 |
+
sx={{ width: { xs: 20, md: 22 }, height: { xs: 20, md: 22 } }}
|
| 289 |
/>
|
| 290 |
+
<Typography
|
| 291 |
+
variant="subtitle1"
|
| 292 |
+
sx={{
|
| 293 |
+
fontWeight: 700,
|
| 294 |
+
color: 'var(--text)',
|
| 295 |
+
letterSpacing: '-0.01em',
|
| 296 |
+
fontSize: { xs: '0.88rem', md: '0.95rem' },
|
| 297 |
+
}}
|
| 298 |
+
>
|
| 299 |
+
HF Agent
|
| 300 |
+
</Typography>
|
| 301 |
</Box>
|
| 302 |
|
| 303 |
+
<IconButton
|
| 304 |
+
onClick={toggleTheme}
|
| 305 |
+
size="small"
|
| 306 |
+
sx={{
|
| 307 |
+
color: 'text.secondary',
|
| 308 |
+
'&:hover': { color: 'primary.main' },
|
| 309 |
+
}}
|
| 310 |
>
|
| 311 |
+
{themeMode === 'dark' ? <LightModeOutlinedIcon fontSize="small" /> : <DarkModeOutlinedIcon fontSize="small" />}
|
| 312 |
</IconButton>
|
| 313 |
</Box>
|
| 314 |
|
| 315 |
+
{/* ── LLM Health Error Banner ────────────────────────────── */}
|
| 316 |
+
{llmBanner}
|
| 317 |
+
|
| 318 |
+
{/* ── Chat + Code Panel ──────────────────────────────────── */}
|
| 319 |
<Box
|
|
|
|
|
|
|
| 320 |
sx={{
|
| 321 |
flexGrow: 1,
|
| 322 |
display: 'flex',
|
|
|
|
| 323 |
overflow: 'hidden',
|
|
|
|
|
|
|
| 324 |
}}
|
| 325 |
>
|
| 326 |
+
{/* Chat area */}
|
| 327 |
+
<Box
|
| 328 |
+
component="main"
|
| 329 |
+
className="chat-pane"
|
| 330 |
+
sx={{
|
| 331 |
+
flexGrow: 1,
|
| 332 |
+
display: 'flex',
|
| 333 |
+
flexDirection: 'column',
|
| 334 |
+
overflow: 'hidden',
|
| 335 |
+
background: 'var(--body-gradient)',
|
| 336 |
+
p: { xs: 1.5, sm: 2, md: 3 },
|
| 337 |
+
minWidth: 0,
|
| 338 |
+
}}
|
| 339 |
+
>
|
| 340 |
+
{activeSessionId ? (
|
| 341 |
+
<>
|
| 342 |
+
<MessageList messages={messages} isProcessing={isProcessing} />
|
| 343 |
+
{!isConnected && messages.length > 0 && (
|
| 344 |
+
<Box sx={{
|
| 345 |
+
display: 'flex',
|
| 346 |
+
alignItems: 'center',
|
| 347 |
+
justifyContent: 'center',
|
| 348 |
+
gap: 1,
|
| 349 |
+
py: 1,
|
| 350 |
+
px: { xs: 1, md: 2 },
|
| 351 |
+
mb: 1,
|
| 352 |
+
borderRadius: 'var(--radius-md)',
|
| 353 |
+
bgcolor: 'rgba(255, 171, 0, 0.08)',
|
| 354 |
+
border: '1px solid rgba(255, 171, 0, 0.2)',
|
| 355 |
+
}}>
|
| 356 |
+
<Typography variant="body2" sx={{ color: 'var(--accent-yellow)', fontFamily: 'monospace', fontSize: { xs: '0.7rem', md: '0.8rem' } }}>
|
| 357 |
+
Session expired — create a new session to continue.
|
| 358 |
+
</Typography>
|
| 359 |
+
</Box>
|
| 360 |
+
)}
|
| 361 |
+
<ChatInput
|
| 362 |
+
onSend={handleSendMessage}
|
| 363 |
+
disabled={isProcessing || !isConnected}
|
| 364 |
+
/>
|
| 365 |
+
</>
|
| 366 |
+
) : (
|
| 367 |
+
<Box
|
| 368 |
+
sx={{
|
| 369 |
+
flex: 1,
|
| 370 |
+
display: 'flex',
|
| 371 |
+
alignItems: 'center',
|
| 372 |
+
justifyContent: 'center',
|
| 373 |
+
flexDirection: 'column',
|
| 374 |
+
gap: 2,
|
| 375 |
+
px: 2,
|
| 376 |
+
}}
|
| 377 |
+
>
|
| 378 |
+
<Typography variant="h5" color="text.secondary" sx={{ fontFamily: 'monospace', fontSize: { xs: '1rem', md: '1.5rem' } }}>
|
| 379 |
+
NO SESSION SELECTED
|
| 380 |
+
</Typography>
|
| 381 |
+
<Typography variant="body2" color="text.secondary" sx={{ fontFamily: 'monospace', fontSize: { xs: '0.75rem', md: '0.875rem' } }}>
|
| 382 |
+
Initialize a session via the sidebar
|
| 383 |
+
</Typography>
|
| 384 |
+
</Box>
|
| 385 |
+
)}
|
| 386 |
+
</Box>
|
| 387 |
+
|
| 388 |
+
{/* Code panel — inline on desktop, overlay drawer on mobile */}
|
| 389 |
+
{isRightPanelOpen && !isMobile && (
|
| 390 |
<>
|
| 391 |
+
<Box
|
| 392 |
+
onMouseDown={startResizing}
|
| 393 |
+
sx={{
|
| 394 |
+
width: '4px',
|
| 395 |
+
cursor: 'col-resize',
|
| 396 |
+
bgcolor: 'divider',
|
| 397 |
+
display: 'flex',
|
| 398 |
+
alignItems: 'center',
|
| 399 |
+
justifyContent: 'center',
|
| 400 |
+
transition: 'background-color 0.2s',
|
| 401 |
+
flexShrink: 0,
|
| 402 |
+
'&:hover': { bgcolor: 'primary.main' },
|
| 403 |
+
}}
|
| 404 |
+
>
|
| 405 |
+
<DragIndicatorIcon
|
| 406 |
+
sx={{ fontSize: '0.8rem', color: 'text.secondary', pointerEvents: 'none' }}
|
| 407 |
+
/>
|
| 408 |
+
</Box>
|
| 409 |
+
<Box
|
| 410 |
+
sx={{
|
| 411 |
+
width: rightPanelWidth,
|
| 412 |
+
flexShrink: 0,
|
| 413 |
+
height: '100%',
|
| 414 |
+
overflow: 'hidden',
|
| 415 |
+
borderLeft: '1px solid',
|
| 416 |
+
borderColor: 'divider',
|
| 417 |
+
bgcolor: 'var(--panel)',
|
| 418 |
+
}}
|
| 419 |
+
>
|
| 420 |
+
<CodePanel />
|
| 421 |
+
</Box>
|
| 422 |
</>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
)}
|
| 424 |
</Box>
|
| 425 |
</Box>
|
| 426 |
|
| 427 |
+
{/* Code panel — drawer overlay on mobile */}
|
| 428 |
+
{isMobile && (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
<Drawer
|
| 430 |
+
anchor="bottom"
|
| 431 |
+
open={isRightPanelOpen}
|
| 432 |
+
onClose={() => useLayoutStore.getState().setRightPanelOpen(false)}
|
| 433 |
sx={{
|
|
|
|
| 434 |
'& .MuiDrawer-paper': {
|
| 435 |
+
height: '75vh',
|
| 436 |
+
borderTopLeftRadius: 16,
|
| 437 |
+
borderTopRightRadius: 16,
|
|
|
|
|
|
|
| 438 |
bgcolor: 'var(--panel)',
|
| 439 |
},
|
| 440 |
}}
|
|
|
|
| 441 |
>
|
| 442 |
<CodePanel />
|
| 443 |
</Drawer>
|
| 444 |
+
)}
|
| 445 |
</Box>
|
| 446 |
);
|
| 447 |
}
|
|
@@ -1,246 +1,344 @@
|
|
| 1 |
-
import { useCallback } from 'react';
|
| 2 |
import {
|
|
|
|
| 3 |
Box,
|
| 4 |
-
List,
|
| 5 |
-
ListItem,
|
| 6 |
IconButton,
|
| 7 |
Typography,
|
| 8 |
-
|
| 9 |
-
|
| 10 |
} from '@mui/material';
|
| 11 |
-
import
|
| 12 |
-
import
|
|
|
|
| 13 |
import { useSessionStore } from '@/store/sessionStore';
|
| 14 |
import { useAgentStore } from '@/store/agentStore';
|
|
|
|
| 15 |
|
| 16 |
interface SessionSidebarProps {
|
| 17 |
onClose?: () => void;
|
| 18 |
}
|
| 19 |
|
| 20 |
-
|
|
|
|
| 21 |
<Box
|
| 22 |
sx={{
|
| 23 |
-
width:
|
| 24 |
-
height:
|
| 25 |
borderRadius: '50%',
|
| 26 |
-
bgcolor: connected ? 'var(--accent-green)' : 'var(--accent-red)',
|
| 27 |
-
boxShadow: connected ? '0 0
|
| 28 |
-
|
| 29 |
}}
|
| 30 |
/>
|
| 31 |
);
|
| 32 |
|
| 33 |
-
const RunningIndicator = () => (
|
| 34 |
-
<Box
|
| 35 |
-
className="running-indicator"
|
| 36 |
-
sx={{
|
| 37 |
-
width: 10,
|
| 38 |
-
height: 10,
|
| 39 |
-
borderRadius: '50%',
|
| 40 |
-
bgcolor: 'var(--accent-yellow)',
|
| 41 |
-
boxShadow: '0 0 6px rgba(199,165,0,0.18)',
|
| 42 |
-
}}
|
| 43 |
-
/>
|
| 44 |
-
);
|
| 45 |
-
|
| 46 |
export default function SessionSidebar({ onClose }: SessionSidebarProps) {
|
| 47 |
const { sessions, activeSessionId, createSession, deleteSession, switchSession } =
|
| 48 |
useSessionStore();
|
| 49 |
-
const {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
const handleNewSession = useCallback(async () => {
|
|
|
|
|
|
|
|
|
|
| 52 |
try {
|
| 53 |
-
const response = await
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
const data = await response.json();
|
| 55 |
createSession(data.session_id);
|
| 56 |
-
// Clear plan and code panel for new session
|
| 57 |
setPlan([]);
|
| 58 |
setPanelContent(null);
|
| 59 |
onClose?.();
|
| 60 |
-
} catch
|
| 61 |
-
|
|
|
|
|
|
|
| 62 |
}
|
| 63 |
-
}, [createSession, setPlan, setPanelContent, onClose]);
|
| 64 |
|
| 65 |
-
const
|
| 66 |
async (sessionId: string, e: React.MouseEvent) => {
|
| 67 |
e.stopPropagation();
|
| 68 |
try {
|
| 69 |
-
await
|
|
|
|
|
|
|
|
|
|
| 70 |
deleteSession(sessionId);
|
| 71 |
-
clearMessages(sessionId);
|
| 72 |
-
} catch (e) {
|
| 73 |
-
console.error('Failed to delete session:', e);
|
| 74 |
}
|
| 75 |
},
|
| 76 |
-
[deleteSession
|
| 77 |
);
|
| 78 |
|
| 79 |
-
const
|
| 80 |
(sessionId: string) => {
|
| 81 |
switchSession(sessionId);
|
| 82 |
-
// Clear plan and code panel when switching sessions
|
| 83 |
setPlan([]);
|
| 84 |
setPanelContent(null);
|
| 85 |
onClose?.();
|
| 86 |
},
|
| 87 |
-
[switchSession, setPlan, setPanelContent, onClose]
|
| 88 |
);
|
| 89 |
|
| 90 |
-
const
|
| 91 |
-
|
| 92 |
-
try {
|
| 93 |
-
await fetch(`/api/undo/${activeSessionId}`, { method: 'POST' });
|
| 94 |
-
} catch (e) {
|
| 95 |
-
console.error('Undo failed:', e);
|
| 96 |
-
}
|
| 97 |
-
}, [activeSessionId]);
|
| 98 |
|
| 99 |
-
|
| 100 |
-
return new Date(dateString).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
| 101 |
-
};
|
| 102 |
|
| 103 |
return (
|
| 104 |
-
<Box
|
| 105 |
-
{
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
</Box>
|
| 121 |
|
| 122 |
-
{/*
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
onClick={handleNewSession}
|
|
|
|
| 136 |
sx={{
|
| 137 |
display: 'inline-flex',
|
| 138 |
alignItems: 'center',
|
| 139 |
-
justifyContent: '
|
| 140 |
-
gap:
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
| 149 |
'&:hover': {
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
| 152 |
},
|
| 153 |
-
'&::before': {
|
| 154 |
-
content: '""',
|
| 155 |
-
width: '4px',
|
| 156 |
-
height: '20px',
|
| 157 |
-
background: 'linear-gradient(180deg, var(--accent-yellow), rgba(199,165,0,0.9))',
|
| 158 |
-
borderRadius: '4px',
|
| 159 |
-
}
|
| 160 |
}}
|
| 161 |
>
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
key={session.id}
|
| 174 |
-
disablePadding
|
| 175 |
-
className="session-item"
|
| 176 |
-
onClick={() => handleSelectSession(session.id)}
|
| 177 |
-
sx={{
|
| 178 |
-
display: 'flex',
|
| 179 |
-
alignItems: 'center',
|
| 180 |
-
gap: '12px',
|
| 181 |
-
padding: '10px',
|
| 182 |
-
borderRadius: 'var(--radius-md)',
|
| 183 |
-
bgcolor: isSelected ? 'rgba(255,255,255,0.05)' : 'transparent',
|
| 184 |
-
cursor: 'pointer',
|
| 185 |
-
transition: 'background 0.18s ease, transform 0.08s ease',
|
| 186 |
-
'&:hover': {
|
| 187 |
-
bgcolor: 'rgba(255,255,255,0.02)',
|
| 188 |
-
transform: 'translateY(-1px)',
|
| 189 |
-
},
|
| 190 |
-
'& .delete-btn': {
|
| 191 |
-
opacity: 0,
|
| 192 |
-
transition: 'opacity 0.2s',
|
| 193 |
-
},
|
| 194 |
-
'&:hover .delete-btn': {
|
| 195 |
-
opacity: 1,
|
| 196 |
-
}
|
| 197 |
-
}}
|
| 198 |
-
>
|
| 199 |
-
<Box sx={{ flex: 1, overflow: 'hidden' }}>
|
| 200 |
-
<Typography variant="body2" sx={{ fontWeight: 500, color: 'var(--text)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
| 201 |
-
Session {String(sessionNumber).padStart(2, '0')}
|
| 202 |
-
</Typography>
|
| 203 |
-
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
|
| 204 |
-
{session.isActive && <RunningIndicator />}
|
| 205 |
-
<Typography className="time" variant="caption" sx={{ fontSize: '12px', color: 'var(--muted-text)' }}>
|
| 206 |
-
{formatTime(session.createdAt)}
|
| 207 |
-
</Typography>
|
| 208 |
-
</Box>
|
| 209 |
-
</Box>
|
| 210 |
-
|
| 211 |
-
<IconButton
|
| 212 |
-
className="delete-btn"
|
| 213 |
-
size="small"
|
| 214 |
-
onClick={(e) => handleDeleteSession(session.id, e)}
|
| 215 |
-
sx={{ color: 'var(--muted-text)', '&:hover': { color: 'var(--accent-red)' } }}
|
| 216 |
-
>
|
| 217 |
-
<DeleteIcon fontSize="small" />
|
| 218 |
-
</IconButton>
|
| 219 |
-
</ListItem>
|
| 220 |
-
);
|
| 221 |
-
})}
|
| 222 |
-
</List>
|
| 223 |
</Box>
|
| 224 |
-
</Box>
|
| 225 |
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
</IconButton>
|
| 242 |
-
</span>
|
| 243 |
-
</Tooltip>
|
| 244 |
</Box>
|
| 245 |
</Box>
|
| 246 |
</Box>
|
|
|
|
| 1 |
+
import { useCallback, useState } from 'react';
|
| 2 |
import {
|
| 3 |
+
Alert,
|
| 4 |
Box,
|
|
|
|
|
|
|
| 5 |
IconButton,
|
| 6 |
Typography,
|
| 7 |
+
CircularProgress,
|
| 8 |
+
Divider,
|
| 9 |
} from '@mui/material';
|
| 10 |
+
import AddIcon from '@mui/icons-material/Add';
|
| 11 |
+
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
| 12 |
+
import ChatBubbleOutlineIcon from '@mui/icons-material/ChatBubbleOutline';
|
| 13 |
import { useSessionStore } from '@/store/sessionStore';
|
| 14 |
import { useAgentStore } from '@/store/agentStore';
|
| 15 |
+
import { apiFetch } from '@/utils/api';
|
| 16 |
|
| 17 |
interface SessionSidebarProps {
|
| 18 |
onClose?: () => void;
|
| 19 |
}
|
| 20 |
|
| 21 |
+
/** Small coloured dot for connection status */
|
| 22 |
+
const StatusDot = ({ connected }: { connected: boolean }) => (
|
| 23 |
<Box
|
| 24 |
sx={{
|
| 25 |
+
width: 6,
|
| 26 |
+
height: 6,
|
| 27 |
borderRadius: '50%',
|
| 28 |
+
bgcolor: connected ? 'var(--accent-green)' : 'var(--accent-red)',
|
| 29 |
+
boxShadow: connected ? '0 0 4px rgba(76,175,80,0.4)' : 'none',
|
| 30 |
+
flexShrink: 0,
|
| 31 |
}}
|
| 32 |
/>
|
| 33 |
);
|
| 34 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
export default function SessionSidebar({ onClose }: SessionSidebarProps) {
|
| 36 |
const { sessions, activeSessionId, createSession, deleteSession, switchSession } =
|
| 37 |
useSessionStore();
|
| 38 |
+
const { isConnected, setPlan, setPanelContent } =
|
| 39 |
+
useAgentStore();
|
| 40 |
+
const [isCreatingSession, setIsCreatingSession] = useState(false);
|
| 41 |
+
const [capacityError, setCapacityError] = useState<string | null>(null);
|
| 42 |
+
|
| 43 |
+
// ── Handlers ──────────────────────────────────────────────────────
|
| 44 |
|
| 45 |
const handleNewSession = useCallback(async () => {
|
| 46 |
+
if (isCreatingSession) return;
|
| 47 |
+
setIsCreatingSession(true);
|
| 48 |
+
setCapacityError(null);
|
| 49 |
try {
|
| 50 |
+
const response = await apiFetch('/api/session', { method: 'POST' });
|
| 51 |
+
if (response.status === 503) {
|
| 52 |
+
const data = await response.json();
|
| 53 |
+
setCapacityError(data.detail || 'Server is at capacity.');
|
| 54 |
+
return;
|
| 55 |
+
}
|
| 56 |
const data = await response.json();
|
| 57 |
createSession(data.session_id);
|
|
|
|
| 58 |
setPlan([]);
|
| 59 |
setPanelContent(null);
|
| 60 |
onClose?.();
|
| 61 |
+
} catch {
|
| 62 |
+
setCapacityError('Failed to create session.');
|
| 63 |
+
} finally {
|
| 64 |
+
setIsCreatingSession(false);
|
| 65 |
}
|
| 66 |
+
}, [isCreatingSession, createSession, setPlan, setPanelContent, onClose]);
|
| 67 |
|
| 68 |
+
const handleDelete = useCallback(
|
| 69 |
async (sessionId: string, e: React.MouseEvent) => {
|
| 70 |
e.stopPropagation();
|
| 71 |
try {
|
| 72 |
+
await apiFetch(`/api/session/${sessionId}`, { method: 'DELETE' });
|
| 73 |
+
deleteSession(sessionId);
|
| 74 |
+
} catch {
|
| 75 |
+
// Delete locally even if backend fails (session may already be gone)
|
| 76 |
deleteSession(sessionId);
|
|
|
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
},
|
| 79 |
+
[deleteSession],
|
| 80 |
);
|
| 81 |
|
| 82 |
+
const handleSelect = useCallback(
|
| 83 |
(sessionId: string) => {
|
| 84 |
switchSession(sessionId);
|
|
|
|
| 85 |
setPlan([]);
|
| 86 |
setPanelContent(null);
|
| 87 |
onClose?.();
|
| 88 |
},
|
| 89 |
+
[switchSession, setPlan, setPanelContent, onClose],
|
| 90 |
);
|
| 91 |
|
| 92 |
+
const formatTime = (d: string) =>
|
| 93 |
+
new Date(d).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
+
// ── Render ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
| 96 |
|
| 97 |
return (
|
| 98 |
+
<Box
|
| 99 |
+
sx={{
|
| 100 |
+
height: '100%',
|
| 101 |
+
display: 'flex',
|
| 102 |
+
flexDirection: 'column',
|
| 103 |
+
bgcolor: 'var(--panel)',
|
| 104 |
+
}}
|
| 105 |
+
>
|
| 106 |
+
{/* ── Header ─────────────────────────────────────────────────── */}
|
| 107 |
+
<Box sx={{ px: 1.75, pt: 2, pb: 0 }}>
|
| 108 |
+
<Typography
|
| 109 |
+
variant="caption"
|
| 110 |
+
sx={{
|
| 111 |
+
color: 'var(--muted-text)',
|
| 112 |
+
fontSize: '0.65rem',
|
| 113 |
+
fontWeight: 600,
|
| 114 |
+
textTransform: 'uppercase',
|
| 115 |
+
letterSpacing: '0.08em',
|
| 116 |
+
}}
|
| 117 |
+
>
|
| 118 |
+
Recent chats
|
| 119 |
+
</Typography>
|
| 120 |
</Box>
|
| 121 |
|
| 122 |
+
{/* ── Capacity error ─────────────────────────────────────────── */}
|
| 123 |
+
{capacityError && (
|
| 124 |
+
<Alert
|
| 125 |
+
severity="warning"
|
| 126 |
+
variant="outlined"
|
| 127 |
+
onClose={() => setCapacityError(null)}
|
| 128 |
+
sx={{
|
| 129 |
+
m: 1,
|
| 130 |
+
fontSize: '0.7rem',
|
| 131 |
+
py: 0.25,
|
| 132 |
+
'& .MuiAlert-message': { py: 0 },
|
| 133 |
+
borderColor: '#FF9D00',
|
| 134 |
+
color: 'var(--text)',
|
| 135 |
+
}}
|
| 136 |
+
>
|
| 137 |
+
{capacityError}
|
| 138 |
+
</Alert>
|
| 139 |
+
)}
|
| 140 |
+
|
| 141 |
+
{/* ── Session list ───────────────────────────────────────────── */}
|
| 142 |
+
<Box
|
| 143 |
+
sx={{
|
| 144 |
+
flex: 1,
|
| 145 |
+
overflow: 'auto',
|
| 146 |
+
py: 1,
|
| 147 |
+
// Thinner scrollbar
|
| 148 |
+
'&::-webkit-scrollbar': { width: 4 },
|
| 149 |
+
'&::-webkit-scrollbar-thumb': {
|
| 150 |
+
bgcolor: 'var(--scrollbar-thumb)',
|
| 151 |
+
borderRadius: 2,
|
| 152 |
+
},
|
| 153 |
+
}}
|
| 154 |
+
>
|
| 155 |
+
{sessions.length === 0 ? (
|
| 156 |
+
<Box
|
| 157 |
+
sx={{
|
| 158 |
+
display: 'flex',
|
| 159 |
+
flexDirection: 'column',
|
| 160 |
+
alignItems: 'center',
|
| 161 |
+
justifyContent: 'center',
|
| 162 |
+
py: 8,
|
| 163 |
+
px: 3,
|
| 164 |
+
gap: 1.5,
|
| 165 |
+
}}
|
| 166 |
+
>
|
| 167 |
+
<ChatBubbleOutlineIcon
|
| 168 |
+
sx={{ fontSize: 28, color: 'var(--muted-text)', opacity: 0.25 }}
|
| 169 |
+
/>
|
| 170 |
+
<Typography
|
| 171 |
+
variant="caption"
|
| 172 |
+
sx={{
|
| 173 |
+
color: 'var(--muted-text)',
|
| 174 |
+
opacity: 0.5,
|
| 175 |
+
textAlign: 'center',
|
| 176 |
+
lineHeight: 1.5,
|
| 177 |
+
fontSize: '0.72rem',
|
| 178 |
+
}}
|
| 179 |
+
>
|
| 180 |
+
No sessions yet
|
| 181 |
+
</Typography>
|
| 182 |
+
</Box>
|
| 183 |
+
) : (
|
| 184 |
+
[...sessions].reverse().map((session, index) => {
|
| 185 |
+
const num = sessions.length - index;
|
| 186 |
+
const isSelected = session.id === activeSessionId;
|
| 187 |
|
| 188 |
+
return (
|
| 189 |
+
<Box
|
| 190 |
+
key={session.id}
|
| 191 |
+
onClick={() => handleSelect(session.id)}
|
| 192 |
+
sx={{
|
| 193 |
+
display: 'flex',
|
| 194 |
+
alignItems: 'center',
|
| 195 |
+
gap: 1,
|
| 196 |
+
px: 1.5,
|
| 197 |
+
py: 0.875,
|
| 198 |
+
mx: 0.75,
|
| 199 |
+
borderRadius: '10px',
|
| 200 |
+
cursor: 'pointer',
|
| 201 |
+
transition: 'background-color 0.12s ease',
|
| 202 |
+
bgcolor: isSelected
|
| 203 |
+
? 'var(--hover-bg)'
|
| 204 |
+
: 'transparent',
|
| 205 |
+
'&:hover': {
|
| 206 |
+
bgcolor: 'var(--hover-bg)',
|
| 207 |
+
},
|
| 208 |
+
'& .delete-btn': {
|
| 209 |
+
opacity: 0,
|
| 210 |
+
transition: 'opacity 0.12s',
|
| 211 |
+
},
|
| 212 |
+
'&:hover .delete-btn': {
|
| 213 |
+
opacity: 1,
|
| 214 |
+
},
|
| 215 |
+
}}
|
| 216 |
+
>
|
| 217 |
+
<ChatBubbleOutlineIcon
|
| 218 |
+
sx={{
|
| 219 |
+
fontSize: 15,
|
| 220 |
+
color: isSelected ? 'var(--text)' : 'var(--muted-text)',
|
| 221 |
+
opacity: isSelected ? 0.8 : 0.4,
|
| 222 |
+
flexShrink: 0,
|
| 223 |
+
}}
|
| 224 |
+
/>
|
| 225 |
+
|
| 226 |
+
<Box sx={{ flex: 1, minWidth: 0 }}>
|
| 227 |
+
<Typography
|
| 228 |
+
variant="body2"
|
| 229 |
+
sx={{
|
| 230 |
+
fontWeight: isSelected ? 600 : 400,
|
| 231 |
+
color: 'var(--text)',
|
| 232 |
+
fontSize: '0.84rem',
|
| 233 |
+
lineHeight: 1.4,
|
| 234 |
+
whiteSpace: 'nowrap',
|
| 235 |
+
overflow: 'hidden',
|
| 236 |
+
textOverflow: 'ellipsis',
|
| 237 |
+
}}
|
| 238 |
+
>
|
| 239 |
+
{session.title.startsWith('Chat ') ? `Session ${String(num).padStart(2, '0')}` : session.title}
|
| 240 |
+
</Typography>
|
| 241 |
+
<Typography
|
| 242 |
+
variant="caption"
|
| 243 |
+
sx={{
|
| 244 |
+
color: 'var(--muted-text)',
|
| 245 |
+
fontSize: '0.65rem',
|
| 246 |
+
lineHeight: 1.2,
|
| 247 |
+
}}
|
| 248 |
+
>
|
| 249 |
+
{formatTime(session.createdAt)}
|
| 250 |
+
</Typography>
|
| 251 |
+
</Box>
|
| 252 |
+
|
| 253 |
+
<IconButton
|
| 254 |
+
className="delete-btn"
|
| 255 |
+
size="small"
|
| 256 |
+
onClick={(e) => handleDelete(session.id, e)}
|
| 257 |
+
sx={{
|
| 258 |
+
color: 'var(--muted-text)',
|
| 259 |
+
width: 26,
|
| 260 |
+
height: 26,
|
| 261 |
+
flexShrink: 0,
|
| 262 |
+
'&:hover': { color: 'var(--accent-red)', bgcolor: 'rgba(244,67,54,0.08)' },
|
| 263 |
+
}}
|
| 264 |
+
>
|
| 265 |
+
<DeleteOutlineIcon sx={{ fontSize: 15 }} />
|
| 266 |
+
</IconButton>
|
| 267 |
+
</Box>
|
| 268 |
+
);
|
| 269 |
+
})
|
| 270 |
+
)}
|
| 271 |
+
</Box>
|
| 272 |
+
|
| 273 |
+
{/* ── Footer: New Session + status ──────────────────────────── */}
|
| 274 |
+
<Divider sx={{ opacity: 0.5 }} />
|
| 275 |
+
<Box
|
| 276 |
+
sx={{
|
| 277 |
+
px: 1.5,
|
| 278 |
+
py: 1.5,
|
| 279 |
+
display: 'flex',
|
| 280 |
+
flexDirection: 'column',
|
| 281 |
+
gap: 1,
|
| 282 |
+
flexShrink: 0,
|
| 283 |
+
}}
|
| 284 |
+
>
|
| 285 |
+
<Box
|
| 286 |
+
component="button"
|
| 287 |
onClick={handleNewSession}
|
| 288 |
+
disabled={isCreatingSession}
|
| 289 |
sx={{
|
| 290 |
display: 'inline-flex',
|
| 291 |
alignItems: 'center',
|
| 292 |
+
justifyContent: 'center',
|
| 293 |
+
gap: 0.75,
|
| 294 |
+
width: '100%',
|
| 295 |
+
px: 1.5,
|
| 296 |
+
py: 1.25,
|
| 297 |
+
border: 'none',
|
| 298 |
+
borderRadius: '10px',
|
| 299 |
+
bgcolor: '#FF9D00',
|
| 300 |
+
color: '#000',
|
| 301 |
+
fontSize: '0.85rem',
|
| 302 |
+
fontWeight: 700,
|
| 303 |
+
cursor: 'pointer',
|
| 304 |
+
transition: 'all 0.12s ease',
|
| 305 |
'&:hover': {
|
| 306 |
+
bgcolor: '#FFB340',
|
| 307 |
+
},
|
| 308 |
+
'&:disabled': {
|
| 309 |
+
opacity: 0.5,
|
| 310 |
+
cursor: 'not-allowed',
|
| 311 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
}}
|
| 313 |
>
|
| 314 |
+
{isCreatingSession ? (
|
| 315 |
+
<>
|
| 316 |
+
<CircularProgress size={12} sx={{ color: '#000' }} />
|
| 317 |
+
Creating...
|
| 318 |
+
</>
|
| 319 |
+
) : (
|
| 320 |
+
<>
|
| 321 |
+
<AddIcon sx={{ fontSize: 16 }} />
|
| 322 |
+
New Session
|
| 323 |
+
</>
|
| 324 |
+
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
</Box>
|
|
|
|
| 326 |
|
| 327 |
+
<Box
|
| 328 |
+
sx={{
|
| 329 |
+
display: 'flex',
|
| 330 |
+
alignItems: 'center',
|
| 331 |
+
justifyContent: 'center',
|
| 332 |
+
gap: 0.5,
|
| 333 |
+
}}
|
| 334 |
+
>
|
| 335 |
+
<StatusDot connected={isConnected} />
|
| 336 |
+
<Typography
|
| 337 |
+
variant="caption"
|
| 338 |
+
sx={{ color: 'var(--muted-text)', fontSize: '0.62rem', letterSpacing: '0.02em' }}
|
| 339 |
+
>
|
| 340 |
+
{sessions.length} session{sessions.length !== 1 ? 's' : ''} · Backend {isConnected ? 'online' : 'offline'}
|
| 341 |
+
</Typography>
|
|
|
|
|
|
|
|
|
|
| 342 |
</Box>
|
| 343 |
</Box>
|
| 344 |
</Box>
|
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
Box,
|
| 4 |
+
Typography,
|
| 5 |
+
Button,
|
| 6 |
+
CircularProgress,
|
| 7 |
+
Alert,
|
| 8 |
+
} from '@mui/material';
|
| 9 |
+
import { useSessionStore } from '@/store/sessionStore';
|
| 10 |
+
import { useAgentStore } from '@/store/agentStore';
|
| 11 |
+
import { apiFetch } from '@/utils/api';
|
| 12 |
+
|
| 13 |
+
/** HF brand orange */
|
| 14 |
+
const HF_ORANGE = '#FF9D00';
|
| 15 |
+
|
| 16 |
+
export default function WelcomeScreen() {
|
| 17 |
+
const { createSession } = useSessionStore();
|
| 18 |
+
const { setPlan, setPanelContent } = useAgentStore();
|
| 19 |
+
const [isCreating, setIsCreating] = useState(false);
|
| 20 |
+
const [error, setError] = useState<string | null>(null);
|
| 21 |
+
|
| 22 |
+
const handleStart = useCallback(async () => {
|
| 23 |
+
if (isCreating) return;
|
| 24 |
+
setIsCreating(true);
|
| 25 |
+
setError(null);
|
| 26 |
+
|
| 27 |
+
try {
|
| 28 |
+
const response = await apiFetch('/api/session', { method: 'POST' });
|
| 29 |
+
if (response.status === 503) {
|
| 30 |
+
const data = await response.json();
|
| 31 |
+
setError(data.detail || 'Server is at capacity. Please try again later.');
|
| 32 |
+
return;
|
| 33 |
+
}
|
| 34 |
+
if (!response.ok) {
|
| 35 |
+
setError('Failed to create session. Please try again.');
|
| 36 |
+
return;
|
| 37 |
+
}
|
| 38 |
+
const data = await response.json();
|
| 39 |
+
createSession(data.session_id);
|
| 40 |
+
setPlan([]);
|
| 41 |
+
setPanelContent(null);
|
| 42 |
+
} catch {
|
| 43 |
+
setError('Could not reach the server. Please try again.');
|
| 44 |
+
} finally {
|
| 45 |
+
setIsCreating(false);
|
| 46 |
+
}
|
| 47 |
+
}, [isCreating, createSession, setPlan, setPanelContent]);
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<Box
|
| 51 |
+
sx={{
|
| 52 |
+
width: '100%',
|
| 53 |
+
height: '100%',
|
| 54 |
+
display: 'flex',
|
| 55 |
+
flexDirection: 'column',
|
| 56 |
+
alignItems: 'center',
|
| 57 |
+
justifyContent: 'center',
|
| 58 |
+
background: 'var(--body-gradient)',
|
| 59 |
+
py: 8,
|
| 60 |
+
}}
|
| 61 |
+
>
|
| 62 |
+
{/* HF Logo — large, centered */}
|
| 63 |
+
<Box
|
| 64 |
+
component="img"
|
| 65 |
+
src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg"
|
| 66 |
+
alt="Hugging Face"
|
| 67 |
+
sx={{
|
| 68 |
+
width: 96,
|
| 69 |
+
height: 96,
|
| 70 |
+
mb: 3,
|
| 71 |
+
display: 'block',
|
| 72 |
+
}}
|
| 73 |
+
/>
|
| 74 |
+
|
| 75 |
+
{/* Title */}
|
| 76 |
+
<Typography
|
| 77 |
+
variant="h2"
|
| 78 |
+
sx={{
|
| 79 |
+
fontWeight: 800,
|
| 80 |
+
color: 'var(--text)',
|
| 81 |
+
mb: 1.5,
|
| 82 |
+
letterSpacing: '-0.02em',
|
| 83 |
+
fontSize: { xs: '2rem', md: '2.8rem' },
|
| 84 |
+
}}
|
| 85 |
+
>
|
| 86 |
+
HF Agent
|
| 87 |
+
</Typography>
|
| 88 |
+
|
| 89 |
+
{/* Description */}
|
| 90 |
+
<Typography
|
| 91 |
+
variant="body1"
|
| 92 |
+
sx={{
|
| 93 |
+
color: 'var(--muted-text)',
|
| 94 |
+
maxWidth: 520,
|
| 95 |
+
mb: 5,
|
| 96 |
+
lineHeight: 1.8,
|
| 97 |
+
fontSize: '0.95rem',
|
| 98 |
+
textAlign: 'center',
|
| 99 |
+
px: 2,
|
| 100 |
+
'& strong': {
|
| 101 |
+
color: 'var(--text)',
|
| 102 |
+
fontWeight: 600,
|
| 103 |
+
},
|
| 104 |
+
}}
|
| 105 |
+
>
|
| 106 |
+
A general-purpose AI agent for <strong>machine learning engineering</strong>.
|
| 107 |
+
It browses <strong>Hugging Face documentation</strong>, manages{' '}
|
| 108 |
+
<strong>repositories</strong>, launches <strong>training jobs</strong>,
|
| 109 |
+
and explores <strong>datasets</strong> — all through natural conversation.
|
| 110 |
+
</Typography>
|
| 111 |
+
|
| 112 |
+
{/* Start Button */}
|
| 113 |
+
<Button
|
| 114 |
+
variant="contained"
|
| 115 |
+
size="large"
|
| 116 |
+
onClick={handleStart}
|
| 117 |
+
disabled={isCreating}
|
| 118 |
+
startIcon={
|
| 119 |
+
isCreating ? <CircularProgress size={20} color="inherit" /> : null
|
| 120 |
+
}
|
| 121 |
+
sx={{
|
| 122 |
+
px: 5,
|
| 123 |
+
py: 1.5,
|
| 124 |
+
fontSize: '1rem',
|
| 125 |
+
fontWeight: 700,
|
| 126 |
+
textTransform: 'none',
|
| 127 |
+
borderRadius: '12px',
|
| 128 |
+
bgcolor: HF_ORANGE,
|
| 129 |
+
color: '#000',
|
| 130 |
+
boxShadow: '0 4px 24px rgba(255, 157, 0, 0.3)',
|
| 131 |
+
transition: 'all 0.2s ease',
|
| 132 |
+
'&:hover': {
|
| 133 |
+
bgcolor: '#FFB340',
|
| 134 |
+
boxShadow: '0 6px 32px rgba(255, 157, 0, 0.45)',
|
| 135 |
+
},
|
| 136 |
+
'&.Mui-disabled': {
|
| 137 |
+
bgcolor: 'rgba(255, 157, 0, 0.35)',
|
| 138 |
+
color: 'rgba(0,0,0,0.45)',
|
| 139 |
+
},
|
| 140 |
+
}}
|
| 141 |
+
>
|
| 142 |
+
{isCreating ? 'Initializing...' : 'Start Session'}
|
| 143 |
+
</Button>
|
| 144 |
+
|
| 145 |
+
{/* Error */}
|
| 146 |
+
{error && (
|
| 147 |
+
<Alert
|
| 148 |
+
severity="warning"
|
| 149 |
+
variant="outlined"
|
| 150 |
+
onClose={() => setError(null)}
|
| 151 |
+
sx={{
|
| 152 |
+
mt: 3,
|
| 153 |
+
maxWidth: 400,
|
| 154 |
+
fontSize: '0.8rem',
|
| 155 |
+
borderColor: HF_ORANGE,
|
| 156 |
+
color: 'var(--text)',
|
| 157 |
+
}}
|
| 158 |
+
>
|
| 159 |
+
{error}
|
| 160 |
+
</Alert>
|
| 161 |
+
)}
|
| 162 |
+
|
| 163 |
+
{/* Footnote */}
|
| 164 |
+
<Typography
|
| 165 |
+
variant="caption"
|
| 166 |
+
sx={{
|
| 167 |
+
mt: 5,
|
| 168 |
+
color: 'var(--muted-text)',
|
| 169 |
+
opacity: 0.5,
|
| 170 |
+
fontSize: '0.7rem',
|
| 171 |
+
}}
|
| 172 |
+
>
|
| 173 |
+
Conversations are stored locally in your browser.
|
| 174 |
+
</Typography>
|
| 175 |
+
</Box>
|
| 176 |
+
);
|
| 177 |
+
}
|
|
@@ -1,34 +1,40 @@
|
|
| 1 |
import { useCallback, useEffect, useRef } from 'react';
|
| 2 |
-
import { useAgentStore } from '@/store/agentStore';
|
| 3 |
import { useSessionStore } from '@/store/sessionStore';
|
| 4 |
import { useLayoutStore } from '@/store/layoutStore';
|
|
|
|
|
|
|
| 5 |
import type { AgentEvent } from '@/types/events';
|
| 6 |
import type { Message, TraceLog } from '@/types/agent';
|
| 7 |
|
| 8 |
const WS_RECONNECT_DELAY = 1000;
|
| 9 |
const WS_MAX_RECONNECT_DELAY = 30000;
|
|
|
|
| 10 |
|
| 11 |
interface UseAgentWebSocketOptions {
|
| 12 |
sessionId: string | null;
|
| 13 |
onReady?: () => void;
|
| 14 |
onError?: (error: string) => void;
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
export function useAgentWebSocket({
|
| 18 |
sessionId,
|
| 19 |
onReady,
|
| 20 |
onError,
|
|
|
|
| 21 |
}: UseAgentWebSocketOptions) {
|
| 22 |
const wsRef = useRef<WebSocket | null>(null);
|
| 23 |
const reconnectTimeoutRef = useRef<number | null>(null);
|
| 24 |
const reconnectDelayRef = useRef(WS_RECONNECT_DELAY);
|
|
|
|
| 25 |
|
| 26 |
const {
|
| 27 |
addMessage,
|
| 28 |
updateMessage,
|
|
|
|
| 29 |
setProcessing,
|
| 30 |
setConnected,
|
| 31 |
-
setPendingApprovals,
|
| 32 |
setError,
|
| 33 |
addTraceLog,
|
| 34 |
updateTraceLog,
|
|
@@ -40,6 +46,7 @@ export function useAgentWebSocket({
|
|
| 40 |
setPlan,
|
| 41 |
setCurrentTurnMessageId,
|
| 42 |
updateCurrentTurnTrace,
|
|
|
|
| 43 |
} = useAgentStore();
|
| 44 |
|
| 45 |
const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
|
|
@@ -66,6 +73,48 @@ export function useAgentWebSocket({
|
|
| 66 |
setCurrentTurnMessageId(null); // Start a new turn
|
| 67 |
break;
|
| 68 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
case 'assistant_message': {
|
| 70 |
const content = (event.data?.content as string) || '';
|
| 71 |
const currentTrace = useAgentStore.getState().traceLogs;
|
|
@@ -126,23 +175,41 @@ export function useAgentWebSocket({
|
|
| 126 |
|
| 127 |
case 'tool_call': {
|
| 128 |
const toolName = (event.data?.tool as string) || 'unknown';
|
| 129 |
-
const
|
|
|
|
| 130 |
|
| 131 |
// Don't display plan_tool in trace logs (it shows up elsewhere in the UI)
|
| 132 |
if (toolName !== 'plan_tool') {
|
| 133 |
const log: TraceLog = {
|
| 134 |
-
id: `tool_${Date.now()}`,
|
|
|
|
| 135 |
type: 'call',
|
| 136 |
text: `Agent is executing ${toolName}...`,
|
| 137 |
tool: toolName,
|
| 138 |
timestamp: new Date().toISOString(),
|
| 139 |
completed: false,
|
| 140 |
-
|
| 141 |
-
args: toolName === 'hf_jobs' ? args : undefined,
|
| 142 |
};
|
| 143 |
addTraceLog(log);
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
}
|
| 147 |
|
| 148 |
// Auto-expand Right Panel for specific tools
|
|
@@ -171,68 +238,57 @@ export function useAgentWebSocket({
|
|
| 171 |
setLeftSidebarOpen(false);
|
| 172 |
}
|
| 173 |
|
| 174 |
-
|
| 175 |
break;
|
| 176 |
}
|
| 177 |
|
| 178 |
case 'tool_output': {
|
| 179 |
const toolName = (event.data?.tool as string) || 'unknown';
|
|
|
|
| 180 |
const output = (event.data?.output as string) || '';
|
| 181 |
const success = event.data?.success as boolean;
|
| 182 |
|
| 183 |
-
// Mark the corresponding trace log as completed and store the output
|
| 184 |
-
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
updateCurrentTurnTrace(sessionId);
|
| 187 |
|
| 188 |
-
//
|
| 189 |
-
if (toolName === 'hf_jobs') {
|
| 190 |
-
const
|
| 191 |
-
const traceLogs = useAgentStore.getState().traceLogs;
|
| 192 |
-
|
| 193 |
-
// Find existing approval message for this job
|
| 194 |
-
let jobMsg = [...messages].reverse().find(m => m.approval);
|
| 195 |
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
const jobTrace = [...traceLogs].reverse().find(t => t.tool === 'hf_jobs');
|
| 200 |
-
const args = jobTrace?.args || {};
|
| 201 |
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
content: '',
|
| 206 |
-
timestamp: new Date().toISOString(),
|
| 207 |
-
approval: {
|
| 208 |
-
status: 'approved', // Auto-approved (no user action needed)
|
| 209 |
-
batch: {
|
| 210 |
-
tools: [{
|
| 211 |
-
tool: toolName,
|
| 212 |
-
arguments: args,
|
| 213 |
-
tool_call_id: `auto_${Date.now()}`
|
| 214 |
-
}],
|
| 215 |
-
count: 1
|
| 216 |
-
}
|
| 217 |
-
},
|
| 218 |
-
toolOutput: output
|
| 219 |
-
};
|
| 220 |
-
addMessage(sessionId, autoExecMessage);
|
| 221 |
-
console.log('Created auto-exec message with tool output:', toolName);
|
| 222 |
-
} else {
|
| 223 |
-
// Update existing approval message
|
| 224 |
-
const currentOutput = jobMsg.toolOutput || '';
|
| 225 |
-
const newOutput = currentOutput ? currentOutput + '\n\n' + output : output;
|
| 226 |
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
|
|
|
|
|
|
|
|
|
| 231 |
}
|
|
|
|
|
|
|
|
|
|
| 232 |
}
|
| 233 |
|
| 234 |
// Don't create message bubbles for tool outputs - they only show in trace logs
|
| 235 |
-
|
| 236 |
break;
|
| 237 |
}
|
| 238 |
|
|
@@ -267,7 +323,7 @@ export function useAgentWebSocket({
|
|
| 267 |
}
|
| 268 |
|
| 269 |
case 'plan_update': {
|
| 270 |
-
const plan = (event.data?.plan as
|
| 271 |
setPlan(plan);
|
| 272 |
if (!useLayoutStore.getState().isRightPanelOpen) {
|
| 273 |
setRightPanelOpen(true);
|
|
@@ -281,25 +337,59 @@ export function useAgentWebSocket({
|
|
| 281 |
arguments: Record<string, unknown>;
|
| 282 |
tool_call_id: string;
|
| 283 |
}>;
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
//
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 295 |
}
|
| 296 |
-
}
|
| 297 |
-
addMessage(sessionId, message);
|
| 298 |
|
| 299 |
-
// Show the first tool's content in the panel
|
| 300 |
if (tools && tools.length > 0) {
|
| 301 |
const firstTool = tools[0];
|
| 302 |
-
const args = firstTool.arguments as Record<string,
|
| 303 |
|
| 304 |
clearPanelTabs();
|
| 305 |
|
|
@@ -324,7 +414,6 @@ export function useAgentWebSocket({
|
|
| 324 |
});
|
| 325 |
setActivePanelTab('content');
|
| 326 |
} else {
|
| 327 |
-
// For other tools, show args as JSON
|
| 328 |
setPanelTab({
|
| 329 |
id: 'args',
|
| 330 |
title: firstTool.tool,
|
|
@@ -339,11 +428,6 @@ export function useAgentWebSocket({
|
|
| 339 |
setLeftSidebarOpen(false);
|
| 340 |
}
|
| 341 |
|
| 342 |
-
// Clear currentTurnMessageId so subsequent assistant_message events create a new message below the approval
|
| 343 |
-
setCurrentTurnMessageId(null);
|
| 344 |
-
|
| 345 |
-
// We don't set pendingApprovals in the global store anymore as the message handles the UI
|
| 346 |
-
setPendingApprovals(null);
|
| 347 |
setProcessing(false);
|
| 348 |
break;
|
| 349 |
}
|
|
@@ -356,7 +440,7 @@ export function useAgentWebSocket({
|
|
| 356 |
case 'compacted': {
|
| 357 |
const oldTokens = event.data?.old_tokens as number;
|
| 358 |
const newTokens = event.data?.new_tokens as number;
|
| 359 |
-
|
| 360 |
break;
|
| 361 |
}
|
| 362 |
|
|
@@ -378,16 +462,19 @@ export function useAgentWebSocket({
|
|
| 378 |
break;
|
| 379 |
|
| 380 |
case 'undo_complete':
|
| 381 |
-
|
|
|
|
|
|
|
|
|
|
| 382 |
break;
|
| 383 |
|
| 384 |
default:
|
| 385 |
-
|
| 386 |
}
|
| 387 |
},
|
| 388 |
// Zustand setters are stable, so we don't need them in deps
|
| 389 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 390 |
-
[sessionId, onReady, onError]
|
| 391 |
);
|
| 392 |
|
| 393 |
const connect = useCallback(() => {
|
|
@@ -399,21 +486,17 @@ export function useAgentWebSocket({
|
|
| 399 |
return;
|
| 400 |
}
|
| 401 |
|
| 402 |
-
//
|
| 403 |
-
const
|
| 404 |
-
// In development, connect directly to backend port 7860
|
| 405 |
-
// In production, use the same host
|
| 406 |
-
const isDev = import.meta.env.DEV;
|
| 407 |
-
const host = isDev ? '127.0.0.1:7860' : window.location.host;
|
| 408 |
-
const wsUrl = `${protocol}//${host}/api/ws/${sessionId}`;
|
| 409 |
|
| 410 |
-
|
| 411 |
const ws = new WebSocket(wsUrl);
|
| 412 |
|
| 413 |
ws.onopen = () => {
|
| 414 |
-
|
| 415 |
setConnected(true);
|
| 416 |
reconnectDelayRef.current = WS_RECONNECT_DELAY;
|
|
|
|
| 417 |
};
|
| 418 |
|
| 419 |
ws.onmessage = (event) => {
|
|
@@ -421,20 +504,31 @@ export function useAgentWebSocket({
|
|
| 421 |
const data = JSON.parse(event.data) as AgentEvent;
|
| 422 |
handleEvent(data);
|
| 423 |
} catch (e) {
|
| 424 |
-
|
| 425 |
}
|
| 426 |
};
|
| 427 |
|
| 428 |
ws.onerror = (error) => {
|
| 429 |
-
|
| 430 |
};
|
| 431 |
|
| 432 |
ws.onclose = (event) => {
|
| 433 |
-
|
| 434 |
setConnected(false);
|
| 435 |
|
| 436 |
-
//
|
| 437 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
// Attempt to reconnect with exponential backoff
|
| 439 |
if (reconnectTimeoutRef.current) {
|
| 440 |
clearTimeout(reconnectTimeoutRef.current);
|
|
@@ -446,6 +540,12 @@ export function useAgentWebSocket({
|
|
| 446 |
);
|
| 447 |
connect();
|
| 448 |
}, reconnectDelayRef.current);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
}
|
| 450 |
};
|
| 451 |
|
|
@@ -477,6 +577,10 @@ export function useAgentWebSocket({
|
|
| 477 |
return;
|
| 478 |
}
|
| 479 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
// Small delay to ensure session is fully created on backend
|
| 481 |
const timeoutId = setTimeout(() => {
|
| 482 |
connect();
|
|
|
|
| 1 |
import { useCallback, useEffect, useRef } from 'react';
|
| 2 |
+
import { useAgentStore, type PlanItem } from '@/store/agentStore';
|
| 3 |
import { useSessionStore } from '@/store/sessionStore';
|
| 4 |
import { useLayoutStore } from '@/store/layoutStore';
|
| 5 |
+
import { getWebSocketUrl } from '@/utils/api';
|
| 6 |
+
import { logger } from '@/utils/logger';
|
| 7 |
import type { AgentEvent } from '@/types/events';
|
| 8 |
import type { Message, TraceLog } from '@/types/agent';
|
| 9 |
|
| 10 |
const WS_RECONNECT_DELAY = 1000;
|
| 11 |
const WS_MAX_RECONNECT_DELAY = 30000;
|
| 12 |
+
const WS_MAX_RETRIES = 5;
|
| 13 |
|
| 14 |
interface UseAgentWebSocketOptions {
|
| 15 |
sessionId: string | null;
|
| 16 |
onReady?: () => void;
|
| 17 |
onError?: (error: string) => void;
|
| 18 |
+
onSessionDead?: (sessionId: string) => void;
|
| 19 |
}
|
| 20 |
|
| 21 |
export function useAgentWebSocket({
|
| 22 |
sessionId,
|
| 23 |
onReady,
|
| 24 |
onError,
|
| 25 |
+
onSessionDead,
|
| 26 |
}: UseAgentWebSocketOptions) {
|
| 27 |
const wsRef = useRef<WebSocket | null>(null);
|
| 28 |
const reconnectTimeoutRef = useRef<number | null>(null);
|
| 29 |
const reconnectDelayRef = useRef(WS_RECONNECT_DELAY);
|
| 30 |
+
const retriesRef = useRef(0);
|
| 31 |
|
| 32 |
const {
|
| 33 |
addMessage,
|
| 34 |
updateMessage,
|
| 35 |
+
appendToMessage,
|
| 36 |
setProcessing,
|
| 37 |
setConnected,
|
|
|
|
| 38 |
setError,
|
| 39 |
addTraceLog,
|
| 40 |
updateTraceLog,
|
|
|
|
| 46 |
setPlan,
|
| 47 |
setCurrentTurnMessageId,
|
| 48 |
updateCurrentTurnTrace,
|
| 49 |
+
removeLastTurn,
|
| 50 |
} = useAgentStore();
|
| 51 |
|
| 52 |
const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
|
|
|
|
| 73 |
setCurrentTurnMessageId(null); // Start a new turn
|
| 74 |
break;
|
| 75 |
|
| 76 |
+
// ── Streaming: individual token chunks ──────────────────
|
| 77 |
+
case 'assistant_chunk': {
|
| 78 |
+
const delta = (event.data?.content as string) || '';
|
| 79 |
+
if (!delta) break;
|
| 80 |
+
|
| 81 |
+
const currentTurnMsgId = useAgentStore.getState().currentTurnMessageId;
|
| 82 |
+
|
| 83 |
+
if (currentTurnMsgId) {
|
| 84 |
+
// Append delta to the existing streaming message
|
| 85 |
+
appendToMessage(sessionId, currentTurnMsgId, delta);
|
| 86 |
+
} else {
|
| 87 |
+
// First chunk — create the message (with pending traces if any)
|
| 88 |
+
const currentTrace = useAgentStore.getState().traceLogs;
|
| 89 |
+
const messageId = `msg_${Date.now()}`;
|
| 90 |
+
const segments: Array<{ type: 'text' | 'tools'; content?: string; tools?: typeof currentTrace }> = [];
|
| 91 |
+
|
| 92 |
+
if (currentTrace.length > 0) {
|
| 93 |
+
segments.push({ type: 'tools', tools: [...currentTrace] });
|
| 94 |
+
clearTraceLogs();
|
| 95 |
+
}
|
| 96 |
+
segments.push({ type: 'text', content: delta });
|
| 97 |
+
|
| 98 |
+
const message: Message = {
|
| 99 |
+
id: messageId,
|
| 100 |
+
role: 'assistant',
|
| 101 |
+
content: delta,
|
| 102 |
+
timestamp: new Date().toISOString(),
|
| 103 |
+
segments,
|
| 104 |
+
};
|
| 105 |
+
addMessage(sessionId, message);
|
| 106 |
+
setCurrentTurnMessageId(messageId);
|
| 107 |
+
}
|
| 108 |
+
break;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// ── Streaming ended (text is already rendered via chunks) ─
|
| 112 |
+
case 'assistant_stream_end':
|
| 113 |
+
// Nothing to do — chunks already built the message.
|
| 114 |
+
// This event is just a signal that the stream is complete.
|
| 115 |
+
break;
|
| 116 |
+
|
| 117 |
+
// ── Legacy non-streaming full message (kept for backwards compat)
|
| 118 |
case 'assistant_message': {
|
| 119 |
const content = (event.data?.content as string) || '';
|
| 120 |
const currentTrace = useAgentStore.getState().traceLogs;
|
|
|
|
| 175 |
|
| 176 |
case 'tool_call': {
|
| 177 |
const toolName = (event.data?.tool as string) || 'unknown';
|
| 178 |
+
const toolCallId = (event.data?.tool_call_id as string) || '';
|
| 179 |
+
const args = (event.data?.arguments as Record<string, string | undefined>) || {};
|
| 180 |
|
| 181 |
// Don't display plan_tool in trace logs (it shows up elsewhere in the UI)
|
| 182 |
if (toolName !== 'plan_tool') {
|
| 183 |
const log: TraceLog = {
|
| 184 |
+
id: `tool_${Date.now()}_${toolCallId}`,
|
| 185 |
+
toolCallId,
|
| 186 |
type: 'call',
|
| 187 |
text: `Agent is executing ${toolName}...`,
|
| 188 |
tool: toolName,
|
| 189 |
timestamp: new Date().toISOString(),
|
| 190 |
completed: false,
|
| 191 |
+
args,
|
|
|
|
| 192 |
};
|
| 193 |
addTraceLog(log);
|
| 194 |
+
|
| 195 |
+
// If no assistant message exists for this turn, create one now
|
| 196 |
+
// so the ToolCallGroup renders immediately in the chat flow.
|
| 197 |
+
const currentTurnMsgId = useAgentStore.getState().currentTurnMessageId;
|
| 198 |
+
if (!currentTurnMsgId) {
|
| 199 |
+
const messageId = `msg_${Date.now()}`;
|
| 200 |
+
const currentTrace = useAgentStore.getState().traceLogs;
|
| 201 |
+
addMessage(sessionId, {
|
| 202 |
+
id: messageId,
|
| 203 |
+
role: 'assistant',
|
| 204 |
+
content: '',
|
| 205 |
+
timestamp: new Date().toISOString(),
|
| 206 |
+
segments: [{ type: 'tools', tools: [...currentTrace] }],
|
| 207 |
+
});
|
| 208 |
+
setCurrentTurnMessageId(messageId);
|
| 209 |
+
clearTraceLogs();
|
| 210 |
+
} else {
|
| 211 |
+
updateCurrentTurnTrace(sessionId);
|
| 212 |
+
}
|
| 213 |
}
|
| 214 |
|
| 215 |
// Auto-expand Right Panel for specific tools
|
|
|
|
| 238 |
setLeftSidebarOpen(false);
|
| 239 |
}
|
| 240 |
|
| 241 |
+
logger.log('Tool call:', toolName, args);
|
| 242 |
break;
|
| 243 |
}
|
| 244 |
|
| 245 |
case 'tool_output': {
|
| 246 |
const toolName = (event.data?.tool as string) || 'unknown';
|
| 247 |
+
const toolCallId = (event.data?.tool_call_id as string) || '';
|
| 248 |
const output = (event.data?.output as string) || '';
|
| 249 |
const success = event.data?.success as boolean;
|
| 250 |
|
| 251 |
+
// Mark the corresponding trace log as completed and store the output.
|
| 252 |
+
// If it had a pending approval, mark it as approved (tool_output means it ran).
|
| 253 |
+
const prevLog = useAgentStore.getState().traceLogs.find(
|
| 254 |
+
(l) => l.toolCallId === toolCallId
|
| 255 |
+
);
|
| 256 |
+
const wasApproval = prevLog?.approvalStatus === 'pending';
|
| 257 |
+
updateTraceLog(toolCallId, toolName, {
|
| 258 |
+
completed: true,
|
| 259 |
+
output,
|
| 260 |
+
success,
|
| 261 |
+
...(wasApproval ? { approvalStatus: 'approved' as const } : {}),
|
| 262 |
+
});
|
| 263 |
updateCurrentTurnTrace(sessionId);
|
| 264 |
|
| 265 |
+
// For hf_jobs: parse job output and enrich the TraceLog with job info
|
| 266 |
+
if (toolName === 'hf_jobs' && output) {
|
| 267 |
+
const updates: Partial<TraceLog> = { approvalStatus: 'approved' as const };
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
+
// Parse job URL
|
| 270 |
+
const urlMatch = output.match(/\*\*View at:\*\*\s*(https:\/\/[^\s\n]+)/);
|
| 271 |
+
if (urlMatch) updates.jobUrl = urlMatch[1];
|
|
|
|
|
|
|
| 272 |
|
| 273 |
+
// Parse job status
|
| 274 |
+
const statusMatch = output.match(/\*\*Final Status:\*\*\s*([^\n]+)/);
|
| 275 |
+
if (statusMatch) updates.jobStatus = statusMatch[1].trim();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
|
| 277 |
+
// Parse logs
|
| 278 |
+
if (output.includes('**Logs:**')) {
|
| 279 |
+
const parts = output.split('**Logs:**');
|
| 280 |
+
if (parts.length > 1) {
|
| 281 |
+
const codeBlockMatch = parts[1].trim().match(/```([\s\S]*?)```/);
|
| 282 |
+
if (codeBlockMatch) updates.jobLogs = codeBlockMatch[1].trim();
|
| 283 |
+
}
|
| 284 |
}
|
| 285 |
+
|
| 286 |
+
updateTraceLog(toolCallId, toolName, updates);
|
| 287 |
+
updateCurrentTurnTrace(sessionId);
|
| 288 |
}
|
| 289 |
|
| 290 |
// Don't create message bubbles for tool outputs - they only show in trace logs
|
| 291 |
+
logger.log('Tool output:', toolName, success);
|
| 292 |
break;
|
| 293 |
}
|
| 294 |
|
|
|
|
| 323 |
}
|
| 324 |
|
| 325 |
case 'plan_update': {
|
| 326 |
+
const plan = (event.data?.plan as PlanItem[]) || [];
|
| 327 |
setPlan(plan);
|
| 328 |
if (!useLayoutStore.getState().isRightPanelOpen) {
|
| 329 |
setRightPanelOpen(true);
|
|
|
|
| 337 |
arguments: Record<string, unknown>;
|
| 338 |
tool_call_id: string;
|
| 339 |
}>;
|
| 340 |
+
|
| 341 |
+
// Create or update trace logs for approval tools.
|
| 342 |
+
// The backend only sends tool_call events for non-approval tools,
|
| 343 |
+
// so we must create TraceLogs here for approval-requiring tools.
|
| 344 |
+
if (tools) {
|
| 345 |
+
for (const t of tools) {
|
| 346 |
+
// Check if a TraceLog already exists (shouldn't, but be safe)
|
| 347 |
+
const existing = useAgentStore.getState().traceLogs.find(
|
| 348 |
+
(log) => log.toolCallId === t.tool_call_id
|
| 349 |
+
);
|
| 350 |
+
if (!existing) {
|
| 351 |
+
addTraceLog({
|
| 352 |
+
id: `tool_${Date.now()}_${t.tool_call_id}`,
|
| 353 |
+
toolCallId: t.tool_call_id,
|
| 354 |
+
type: 'call',
|
| 355 |
+
text: `Approval required for ${t.tool}`,
|
| 356 |
+
tool: t.tool,
|
| 357 |
+
timestamp: new Date().toISOString(),
|
| 358 |
+
completed: false,
|
| 359 |
+
args: t.arguments as Record<string, unknown>,
|
| 360 |
+
approvalStatus: 'pending',
|
| 361 |
+
});
|
| 362 |
+
} else {
|
| 363 |
+
updateTraceLog(t.tool_call_id, t.tool, {
|
| 364 |
+
approvalStatus: 'pending',
|
| 365 |
+
args: t.arguments as Record<string, unknown>,
|
| 366 |
+
});
|
| 367 |
+
}
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
// Ensure there's a message to render the approval UI in
|
| 371 |
+
const currentTurnMsgId = useAgentStore.getState().currentTurnMessageId;
|
| 372 |
+
if (!currentTurnMsgId) {
|
| 373 |
+
const messageId = `msg_${Date.now()}`;
|
| 374 |
+
const currentTrace = useAgentStore.getState().traceLogs;
|
| 375 |
+
addMessage(sessionId, {
|
| 376 |
+
id: messageId,
|
| 377 |
+
role: 'assistant',
|
| 378 |
+
content: '',
|
| 379 |
+
timestamp: new Date().toISOString(),
|
| 380 |
+
segments: [{ type: 'tools', tools: [...currentTrace] }],
|
| 381 |
+
});
|
| 382 |
+
setCurrentTurnMessageId(messageId);
|
| 383 |
+
clearTraceLogs();
|
| 384 |
+
} else {
|
| 385 |
+
updateCurrentTurnTrace(sessionId);
|
| 386 |
}
|
| 387 |
+
}
|
|
|
|
| 388 |
|
| 389 |
+
// Show the first tool's content in the panel
|
| 390 |
if (tools && tools.length > 0) {
|
| 391 |
const firstTool = tools[0];
|
| 392 |
+
const args = firstTool.arguments as Record<string, string | undefined>;
|
| 393 |
|
| 394 |
clearPanelTabs();
|
| 395 |
|
|
|
|
| 414 |
});
|
| 415 |
setActivePanelTab('content');
|
| 416 |
} else {
|
|
|
|
| 417 |
setPanelTab({
|
| 418 |
id: 'args',
|
| 419 |
title: firstTool.tool,
|
|
|
|
| 428 |
setLeftSidebarOpen(false);
|
| 429 |
}
|
| 430 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
setProcessing(false);
|
| 432 |
break;
|
| 433 |
}
|
|
|
|
| 440 |
case 'compacted': {
|
| 441 |
const oldTokens = event.data?.old_tokens as number;
|
| 442 |
const newTokens = event.data?.new_tokens as number;
|
| 443 |
+
logger.log(`Context compacted: ${oldTokens} -> ${newTokens} tokens`);
|
| 444 |
break;
|
| 445 |
}
|
| 446 |
|
|
|
|
| 462 |
break;
|
| 463 |
|
| 464 |
case 'undo_complete':
|
| 465 |
+
if (sessionId) {
|
| 466 |
+
removeLastTurn(sessionId);
|
| 467 |
+
}
|
| 468 |
+
setProcessing(false);
|
| 469 |
break;
|
| 470 |
|
| 471 |
default:
|
| 472 |
+
logger.log('Unknown event:', event);
|
| 473 |
}
|
| 474 |
},
|
| 475 |
// Zustand setters are stable, so we don't need them in deps
|
| 476 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 477 |
+
[sessionId, onReady, onError, onSessionDead]
|
| 478 |
);
|
| 479 |
|
| 480 |
const connect = useCallback(() => {
|
|
|
|
| 486 |
return;
|
| 487 |
}
|
| 488 |
|
| 489 |
+
// Build WebSocket URL (centralized in utils/api.ts)
|
| 490 |
+
const wsUrl = getWebSocketUrl(sessionId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 491 |
|
| 492 |
+
logger.log('Connecting to WebSocket:', wsUrl);
|
| 493 |
const ws = new WebSocket(wsUrl);
|
| 494 |
|
| 495 |
ws.onopen = () => {
|
| 496 |
+
logger.log('WebSocket connected');
|
| 497 |
setConnected(true);
|
| 498 |
reconnectDelayRef.current = WS_RECONNECT_DELAY;
|
| 499 |
+
retriesRef.current = 0; // Reset retry counter on successful connect
|
| 500 |
};
|
| 501 |
|
| 502 |
ws.onmessage = (event) => {
|
|
|
|
| 504 |
const data = JSON.parse(event.data) as AgentEvent;
|
| 505 |
handleEvent(data);
|
| 506 |
} catch (e) {
|
| 507 |
+
logger.error('Failed to parse WebSocket message:', e);
|
| 508 |
}
|
| 509 |
};
|
| 510 |
|
| 511 |
ws.onerror = (error) => {
|
| 512 |
+
logger.error('WebSocket error:', error);
|
| 513 |
};
|
| 514 |
|
| 515 |
ws.onclose = (event) => {
|
| 516 |
+
logger.log('WebSocket closed', event.code, event.reason);
|
| 517 |
setConnected(false);
|
| 518 |
|
| 519 |
+
// Don't reconnect if:
|
| 520 |
+
// - Normal closure (1000)
|
| 521 |
+
// - Session not found (4004) — session was deleted or backend restarted
|
| 522 |
+
// - Auth failed (4001) or access denied (4003) — won't succeed on retry
|
| 523 |
+
// - No session ID
|
| 524 |
+
const noRetryCodes = [1000, 4001, 4003, 4004];
|
| 525 |
+
if (!noRetryCodes.includes(event.code) && sessionId) {
|
| 526 |
+
retriesRef.current += 1;
|
| 527 |
+
if (retriesRef.current > WS_MAX_RETRIES) {
|
| 528 |
+
logger.warn(`WebSocket: max retries (${WS_MAX_RETRIES}) reached, giving up.`);
|
| 529 |
+
onSessionDead?.(sessionId);
|
| 530 |
+
return;
|
| 531 |
+
}
|
| 532 |
// Attempt to reconnect with exponential backoff
|
| 533 |
if (reconnectTimeoutRef.current) {
|
| 534 |
clearTimeout(reconnectTimeoutRef.current);
|
|
|
|
| 540 |
);
|
| 541 |
connect();
|
| 542 |
}, reconnectDelayRef.current);
|
| 543 |
+
} else if (event.code === 4004 && sessionId) {
|
| 544 |
+
// Session not found — remove it from the store (lazy cleanup)
|
| 545 |
+
logger.warn(`Session ${sessionId} no longer exists on backend, removing.`);
|
| 546 |
+
onSessionDead?.(sessionId);
|
| 547 |
+
} else if (noRetryCodes.includes(event.code) && event.code !== 1000) {
|
| 548 |
+
logger.warn(`WebSocket permanently closed: ${event.code} ${event.reason}`);
|
| 549 |
}
|
| 550 |
};
|
| 551 |
|
|
|
|
| 577 |
return;
|
| 578 |
}
|
| 579 |
|
| 580 |
+
// Reset retry state for new session
|
| 581 |
+
retriesRef.current = 0;
|
| 582 |
+
reconnectDelayRef.current = WS_RECONNECT_DELAY;
|
| 583 |
+
|
| 584 |
// Small delay to ensure session is fully created on backend
|
| 585 |
const timeoutId = setTimeout(() => {
|
| 586 |
connect();
|
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Authentication hook — non-blocking.
|
| 3 |
+
*
|
| 4 |
+
* The app renders immediately. This hook fires a background check to /auth/me
|
| 5 |
+
* and updates the agent store with user info when it resolves.
|
| 6 |
+
* If an API call later returns 401, apiFetch handles the redirect to /auth/login.
|
| 7 |
+
*
|
| 8 |
+
* This avoids blocking the entire UI on an auth check that depends on backend
|
| 9 |
+
* availability (which can be slow during session/MCP initialization).
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
import { useEffect } from 'react';
|
| 13 |
+
import { useAgentStore } from '@/store/agentStore';
|
| 14 |
+
|
| 15 |
+
export function useAuth() {
|
| 16 |
+
const setUser = useAgentStore((s) => s.setUser);
|
| 17 |
+
|
| 18 |
+
useEffect(() => {
|
| 19 |
+
async function checkAuth() {
|
| 20 |
+
try {
|
| 21 |
+
const response = await fetch('/auth/me', { credentials: 'include' });
|
| 22 |
+
if (response.ok) {
|
| 23 |
+
const data = await response.json();
|
| 24 |
+
if (data.authenticated) {
|
| 25 |
+
setUser({
|
| 26 |
+
authenticated: true,
|
| 27 |
+
username: data.username,
|
| 28 |
+
name: data.name,
|
| 29 |
+
picture: data.picture,
|
| 30 |
+
});
|
| 31 |
+
return;
|
| 32 |
+
}
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// Not authenticated — check if auth is required
|
| 36 |
+
const statusRes = await fetch('/auth/status', { credentials: 'include' });
|
| 37 |
+
const statusData = await statusRes.json();
|
| 38 |
+
if (statusData.auth_enabled) {
|
| 39 |
+
window.location.href = '/auth/login';
|
| 40 |
+
return;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
// Dev mode — set dev user
|
| 44 |
+
setUser({ authenticated: true, username: 'dev' });
|
| 45 |
+
} catch {
|
| 46 |
+
// Backend not ready — set dev user so the app is usable
|
| 47 |
+
setUser({ authenticated: true, username: 'dev' });
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
checkAuth();
|
| 52 |
+
}, [setUser]);
|
| 53 |
+
}
|
|
@@ -3,13 +3,23 @@ import { createRoot } from 'react-dom/client';
|
|
| 3 |
import { ThemeProvider } from '@mui/material/styles';
|
| 4 |
import CssBaseline from '@mui/material/CssBaseline';
|
| 5 |
import App from './App';
|
| 6 |
-
import
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
| 10 |
<ThemeProvider theme={theme}>
|
| 11 |
<CssBaseline />
|
| 12 |
<App />
|
| 13 |
</ThemeProvider>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
</StrictMode>
|
| 15 |
);
|
|
|
|
| 3 |
import { ThemeProvider } from '@mui/material/styles';
|
| 4 |
import CssBaseline from '@mui/material/CssBaseline';
|
| 5 |
import App from './App';
|
| 6 |
+
import { darkTheme, lightTheme } from './theme';
|
| 7 |
+
import { useLayoutStore } from './store/layoutStore';
|
| 8 |
|
| 9 |
+
function Root() {
|
| 10 |
+
const themeMode = useLayoutStore((s) => s.themeMode);
|
| 11 |
+
const theme = themeMode === 'light' ? lightTheme : darkTheme;
|
| 12 |
+
|
| 13 |
+
return (
|
| 14 |
<ThemeProvider theme={theme}>
|
| 15 |
<CssBaseline />
|
| 16 |
<App />
|
| 17 |
</ThemeProvider>
|
| 18 |
+
);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
createRoot(document.getElementById('root')!).render(
|
| 22 |
+
<StrictMode>
|
| 23 |
+
<Root />
|
| 24 |
</StrictMode>
|
| 25 |
);
|
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import { create } from 'zustand';
|
| 2 |
-
import
|
|
|
|
| 3 |
|
| 4 |
export interface PlanItem {
|
| 5 |
id: string;
|
|
@@ -12,7 +13,13 @@ interface PanelTab {
|
|
| 12 |
title: string;
|
| 13 |
content: string;
|
| 14 |
language?: string;
|
| 15 |
-
parameters?:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
interface AgentStore {
|
|
@@ -20,11 +27,11 @@ interface AgentStore {
|
|
| 20 |
messagesBySession: Record<string, Message[]>;
|
| 21 |
isProcessing: boolean;
|
| 22 |
isConnected: boolean;
|
| 23 |
-
pendingApprovals: ApprovalBatch | null;
|
| 24 |
user: User | null;
|
| 25 |
error: string | null;
|
|
|
|
| 26 |
traceLogs: TraceLog[];
|
| 27 |
-
panelContent: { title: string; content: string; language?: string; parameters?:
|
| 28 |
panelTabs: PanelTab[];
|
| 29 |
activePanelTab: string | null;
|
| 30 |
plan: PlanItem[];
|
|
@@ -36,14 +43,13 @@ interface AgentStore {
|
|
| 36 |
clearMessages: (sessionId: string) => void;
|
| 37 |
setProcessing: (isProcessing: boolean) => void;
|
| 38 |
setConnected: (isConnected: boolean) => void;
|
| 39 |
-
setPendingApprovals: (approvals: ApprovalBatch | null) => void;
|
| 40 |
setUser: (user: User | null) => void;
|
| 41 |
setError: (error: string | null) => void;
|
| 42 |
getMessages: (sessionId: string) => Message[];
|
| 43 |
addTraceLog: (log: TraceLog) => void;
|
| 44 |
-
updateTraceLog: (toolName: string, updates: Partial<TraceLog>) => void;
|
| 45 |
clearTraceLogs: () => void;
|
| 46 |
-
setPanelContent: (content: { title: string; content: string; language?: string; parameters?:
|
| 47 |
setPanelTab: (tab: PanelTab) => void;
|
| 48 |
setActivePanelTab: (tabId: string) => void;
|
| 49 |
clearPanelTabs: () => void;
|
|
@@ -52,15 +58,24 @@ interface AgentStore {
|
|
| 52 |
setCurrentTurnMessageId: (id: string | null) => void;
|
| 53 |
updateCurrentTurnTrace: (sessionId: string) => void;
|
| 54 |
showToolOutput: (log: TraceLog) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
-
export const useAgentStore = create<AgentStore>(
|
|
|
|
|
|
|
| 58 |
messagesBySession: {},
|
| 59 |
isProcessing: false,
|
| 60 |
isConnected: false,
|
| 61 |
-
pendingApprovals: null,
|
| 62 |
user: null,
|
| 63 |
error: null,
|
|
|
|
| 64 |
traceLogs: [],
|
| 65 |
panelContent: null,
|
| 66 |
panelTabs: [],
|
|
@@ -112,10 +127,6 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
|
| 112 |
set({ isConnected });
|
| 113 |
},
|
| 114 |
|
| 115 |
-
setPendingApprovals: (approvals: ApprovalBatch | null) => {
|
| 116 |
-
set({ pendingApprovals: approvals });
|
| 117 |
-
},
|
| 118 |
-
|
| 119 |
setUser: (user: User | null) => {
|
| 120 |
set({ user });
|
| 121 |
},
|
|
@@ -134,14 +145,27 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
|
| 134 |
}));
|
| 135 |
},
|
| 136 |
|
| 137 |
-
updateTraceLog: (toolName: string, updates: Partial<TraceLog>) => {
|
| 138 |
set((state) => {
|
| 139 |
-
// Find the last trace log with this tool name and update it
|
| 140 |
const traceLogs = [...state.traceLogs];
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
}
|
| 146 |
}
|
| 147 |
return { traceLogs };
|
|
@@ -208,20 +232,39 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
|
| 208 |
|
| 209 |
updateCurrentTurnTrace: (sessionId: string) => {
|
| 210 |
const state = get();
|
| 211 |
-
if (state.currentTurnMessageId)
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
},
|
| 226 |
|
| 227 |
showToolOutput: (log: TraceLog) => {
|
|
@@ -257,4 +300,80 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
|
|
| 257 |
activePanelTab: 'tool_output',
|
| 258 |
});
|
| 259 |
},
|
| 260 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { create } from 'zustand';
|
| 2 |
+
import { persist } from 'zustand/middleware';
|
| 3 |
+
import type { Message, User, TraceLog } from '@/types/agent';
|
| 4 |
|
| 5 |
export interface PlanItem {
|
| 6 |
id: string;
|
|
|
|
| 13 |
title: string;
|
| 14 |
content: string;
|
| 15 |
language?: string;
|
| 16 |
+
parameters?: Record<string, unknown>;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export interface LLMHealthError {
|
| 20 |
+
error: string;
|
| 21 |
+
errorType: 'auth' | 'credits' | 'rate_limit' | 'network' | 'unknown';
|
| 22 |
+
model: string;
|
| 23 |
}
|
| 24 |
|
| 25 |
interface AgentStore {
|
|
|
|
| 27 |
messagesBySession: Record<string, Message[]>;
|
| 28 |
isProcessing: boolean;
|
| 29 |
isConnected: boolean;
|
|
|
|
| 30 |
user: User | null;
|
| 31 |
error: string | null;
|
| 32 |
+
llmHealthError: LLMHealthError | null;
|
| 33 |
traceLogs: TraceLog[];
|
| 34 |
+
panelContent: { title: string; content: string; language?: string; parameters?: Record<string, unknown> } | null;
|
| 35 |
panelTabs: PanelTab[];
|
| 36 |
activePanelTab: string | null;
|
| 37 |
plan: PlanItem[];
|
|
|
|
| 43 |
clearMessages: (sessionId: string) => void;
|
| 44 |
setProcessing: (isProcessing: boolean) => void;
|
| 45 |
setConnected: (isConnected: boolean) => void;
|
|
|
|
| 46 |
setUser: (user: User | null) => void;
|
| 47 |
setError: (error: string | null) => void;
|
| 48 |
getMessages: (sessionId: string) => Message[];
|
| 49 |
addTraceLog: (log: TraceLog) => void;
|
| 50 |
+
updateTraceLog: (toolCallId: string, toolName: string, updates: Partial<TraceLog>) => void;
|
| 51 |
clearTraceLogs: () => void;
|
| 52 |
+
setPanelContent: (content: { title: string; content: string; language?: string; parameters?: Record<string, unknown> } | null) => void;
|
| 53 |
setPanelTab: (tab: PanelTab) => void;
|
| 54 |
setActivePanelTab: (tabId: string) => void;
|
| 55 |
clearPanelTabs: () => void;
|
|
|
|
| 58 |
setCurrentTurnMessageId: (id: string | null) => void;
|
| 59 |
updateCurrentTurnTrace: (sessionId: string) => void;
|
| 60 |
showToolOutput: (log: TraceLog) => void;
|
| 61 |
+
/** Append a streaming delta to an existing message. */
|
| 62 |
+
appendToMessage: (sessionId: string, messageId: string, delta: string) => void;
|
| 63 |
+
/** Remove all messages for a session (also clears from localStorage). */
|
| 64 |
+
deleteSessionMessages: (sessionId: string) => void;
|
| 65 |
+
/** Remove the last turn (last user msg + all following assistant/tool msgs). */
|
| 66 |
+
removeLastTurn: (sessionId: string) => void;
|
| 67 |
+
setLlmHealthError: (error: LLMHealthError | null) => void;
|
| 68 |
}
|
| 69 |
|
| 70 |
+
export const useAgentStore = create<AgentStore>()(
|
| 71 |
+
persist(
|
| 72 |
+
(set, get) => ({
|
| 73 |
messagesBySession: {},
|
| 74 |
isProcessing: false,
|
| 75 |
isConnected: false,
|
|
|
|
| 76 |
user: null,
|
| 77 |
error: null,
|
| 78 |
+
llmHealthError: null,
|
| 79 |
traceLogs: [],
|
| 80 |
panelContent: null,
|
| 81 |
panelTabs: [],
|
|
|
|
| 127 |
set({ isConnected });
|
| 128 |
},
|
| 129 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
setUser: (user: User | null) => {
|
| 131 |
set({ user });
|
| 132 |
},
|
|
|
|
| 145 |
}));
|
| 146 |
},
|
| 147 |
|
| 148 |
+
updateTraceLog: (toolCallId: string, toolName: string, updates: Partial<TraceLog>) => {
|
| 149 |
set((state) => {
|
|
|
|
| 150 |
const traceLogs = [...state.traceLogs];
|
| 151 |
+
// Prefer matching by tool_call_id (reliable), fall back to tool name (legacy)
|
| 152 |
+
let matched = false;
|
| 153 |
+
if (toolCallId) {
|
| 154 |
+
for (let i = traceLogs.length - 1; i >= 0; i--) {
|
| 155 |
+
if (traceLogs[i].toolCallId === toolCallId) {
|
| 156 |
+
traceLogs[i] = { ...traceLogs[i], ...updates };
|
| 157 |
+
matched = true;
|
| 158 |
+
break;
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
if (!matched) {
|
| 163 |
+
// Fallback: match by tool name (last uncompleted call)
|
| 164 |
+
for (let i = traceLogs.length - 1; i >= 0; i--) {
|
| 165 |
+
if (traceLogs[i].tool === toolName && traceLogs[i].type === 'call' && !traceLogs[i].completed) {
|
| 166 |
+
traceLogs[i] = { ...traceLogs[i], ...updates };
|
| 167 |
+
break;
|
| 168 |
+
}
|
| 169 |
}
|
| 170 |
}
|
| 171 |
return { traceLogs };
|
|
|
|
| 232 |
|
| 233 |
updateCurrentTurnTrace: (sessionId: string) => {
|
| 234 |
const state = get();
|
| 235 |
+
if (!state.currentTurnMessageId) return;
|
| 236 |
+
|
| 237 |
+
const currentMessages = state.messagesBySession[sessionId] || [];
|
| 238 |
+
const latestTools = state.traceLogs.length > 0 ? [...state.traceLogs] : undefined;
|
| 239 |
+
if (!latestTools) return;
|
| 240 |
+
|
| 241 |
+
const updatedMessages = currentMessages.map((msg) => {
|
| 242 |
+
if (msg.id !== state.currentTurnMessageId) return msg;
|
| 243 |
+
|
| 244 |
+
const segments = msg.segments ? [...msg.segments] : [];
|
| 245 |
+
const lastToolsIdx = segments.map((s) => s.type).lastIndexOf('tools');
|
| 246 |
+
|
| 247 |
+
if (lastToolsIdx >= 0 && lastToolsIdx === segments.length - 1) {
|
| 248 |
+
// Last segment IS a tools segment — update it in place
|
| 249 |
+
segments[lastToolsIdx] = { type: 'tools', tools: latestTools };
|
| 250 |
+
} else if (lastToolsIdx >= 0) {
|
| 251 |
+
// A tools segment exists but is NOT last (text came after it).
|
| 252 |
+
// Append a NEW tools segment at the end.
|
| 253 |
+
segments.push({ type: 'tools', tools: latestTools });
|
| 254 |
+
} else {
|
| 255 |
+
// No tools segment at all — create one at the end.
|
| 256 |
+
segments.push({ type: 'tools', tools: latestTools });
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
return { ...msg, segments };
|
| 260 |
+
});
|
| 261 |
+
|
| 262 |
+
set({
|
| 263 |
+
messagesBySession: {
|
| 264 |
+
...state.messagesBySession,
|
| 265 |
+
[sessionId]: updatedMessages,
|
| 266 |
+
},
|
| 267 |
+
});
|
| 268 |
},
|
| 269 |
|
| 270 |
showToolOutput: (log: TraceLog) => {
|
|
|
|
| 300 |
activePanelTab: 'tool_output',
|
| 301 |
});
|
| 302 |
},
|
| 303 |
+
|
| 304 |
+
appendToMessage: (sessionId: string, messageId: string, delta: string) => {
|
| 305 |
+
set((state) => {
|
| 306 |
+
const messages = state.messagesBySession[sessionId] || [];
|
| 307 |
+
return {
|
| 308 |
+
messagesBySession: {
|
| 309 |
+
...state.messagesBySession,
|
| 310 |
+
[sessionId]: messages.map((msg) => {
|
| 311 |
+
if (msg.id !== messageId) return msg;
|
| 312 |
+
const newContent = msg.content + delta;
|
| 313 |
+
const segments = msg.segments ? [...msg.segments] : [];
|
| 314 |
+
const lastSeg = segments[segments.length - 1];
|
| 315 |
+
|
| 316 |
+
if (lastSeg && lastSeg.type === 'text') {
|
| 317 |
+
// Append to the existing text segment
|
| 318 |
+
segments[segments.length - 1] = {
|
| 319 |
+
...lastSeg,
|
| 320 |
+
content: (lastSeg.content || '') + delta,
|
| 321 |
+
};
|
| 322 |
+
} else {
|
| 323 |
+
// Last segment is 'tools' (or empty) — start a NEW text segment
|
| 324 |
+
// so that tools and text remain visually separated.
|
| 325 |
+
segments.push({ type: 'text', content: delta });
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
return { ...msg, content: newContent, segments };
|
| 329 |
+
}),
|
| 330 |
+
},
|
| 331 |
+
};
|
| 332 |
+
});
|
| 333 |
+
},
|
| 334 |
+
|
| 335 |
+
deleteSessionMessages: (sessionId: string) => {
|
| 336 |
+
set((state) => {
|
| 337 |
+
const { [sessionId]: _, ...rest } = state.messagesBySession;
|
| 338 |
+
return { messagesBySession: rest };
|
| 339 |
+
});
|
| 340 |
+
},
|
| 341 |
+
|
| 342 |
+
removeLastTurn: (sessionId: string) => {
|
| 343 |
+
set((state) => {
|
| 344 |
+
const msgs = state.messagesBySession[sessionId];
|
| 345 |
+
if (!msgs || msgs.length === 0) return state;
|
| 346 |
+
|
| 347 |
+
// Find the index of the last user message
|
| 348 |
+
let lastUserIdx = -1;
|
| 349 |
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
| 350 |
+
if (msgs[i].role === 'user') {
|
| 351 |
+
lastUserIdx = i;
|
| 352 |
+
break;
|
| 353 |
+
}
|
| 354 |
+
}
|
| 355 |
+
if (lastUserIdx === -1) return state; // no user message to remove
|
| 356 |
+
|
| 357 |
+
// Remove everything from that user message onward
|
| 358 |
+
return {
|
| 359 |
+
messagesBySession: {
|
| 360 |
+
...state.messagesBySession,
|
| 361 |
+
[sessionId]: msgs.slice(0, lastUserIdx),
|
| 362 |
+
},
|
| 363 |
+
};
|
| 364 |
+
});
|
| 365 |
+
},
|
| 366 |
+
|
| 367 |
+
setLlmHealthError: (error: LLMHealthError | null) => {
|
| 368 |
+
set({ llmHealthError: error });
|
| 369 |
+
},
|
| 370 |
+
}),
|
| 371 |
+
{
|
| 372 |
+
name: 'hf-agent-messages',
|
| 373 |
+
// Only persist messages — all transient UI state stays in-memory
|
| 374 |
+
partialize: (state) => ({
|
| 375 |
+
messagesBySession: state.messagesBySession,
|
| 376 |
+
}),
|
| 377 |
+
}
|
| 378 |
+
)
|
| 379 |
+
);
|
|
@@ -1,23 +1,41 @@
|
|
| 1 |
import { create } from 'zustand';
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
interface LayoutStore {
|
| 4 |
isLeftSidebarOpen: boolean;
|
| 5 |
isRightPanelOpen: boolean;
|
| 6 |
rightPanelWidth: number;
|
|
|
|
| 7 |
setLeftSidebarOpen: (open: boolean) => void;
|
| 8 |
setRightPanelOpen: (open: boolean) => void;
|
| 9 |
setRightPanelWidth: (width: number) => void;
|
| 10 |
toggleLeftSidebar: () => void;
|
| 11 |
toggleRightPanel: () => void;
|
|
|
|
| 12 |
}
|
| 13 |
|
| 14 |
-
export const useLayoutStore = create<LayoutStore>(
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { create } from 'zustand';
|
| 2 |
+
import { persist } from 'zustand/middleware';
|
| 3 |
+
|
| 4 |
+
export type ThemeMode = 'dark' | 'light';
|
| 5 |
|
| 6 |
interface LayoutStore {
|
| 7 |
isLeftSidebarOpen: boolean;
|
| 8 |
isRightPanelOpen: boolean;
|
| 9 |
rightPanelWidth: number;
|
| 10 |
+
themeMode: ThemeMode;
|
| 11 |
setLeftSidebarOpen: (open: boolean) => void;
|
| 12 |
setRightPanelOpen: (open: boolean) => void;
|
| 13 |
setRightPanelWidth: (width: number) => void;
|
| 14 |
toggleLeftSidebar: () => void;
|
| 15 |
toggleRightPanel: () => void;
|
| 16 |
+
toggleTheme: () => void;
|
| 17 |
}
|
| 18 |
|
| 19 |
+
export const useLayoutStore = create<LayoutStore>()(
|
| 20 |
+
persist(
|
| 21 |
+
(set) => ({
|
| 22 |
+
isLeftSidebarOpen: true,
|
| 23 |
+
isRightPanelOpen: false,
|
| 24 |
+
rightPanelWidth: 450,
|
| 25 |
+
themeMode: 'dark' as ThemeMode,
|
| 26 |
+
setLeftSidebarOpen: (open) => set({ isLeftSidebarOpen: open }),
|
| 27 |
+
setRightPanelOpen: (open) => set({ isRightPanelOpen: open }),
|
| 28 |
+
setRightPanelWidth: (width) => set({ rightPanelWidth: width }),
|
| 29 |
+
toggleLeftSidebar: () => set((state) => ({ isLeftSidebarOpen: !state.isLeftSidebarOpen })),
|
| 30 |
+
toggleRightPanel: () => set((state) => ({ isRightPanelOpen: !state.isRightPanelOpen })),
|
| 31 |
+
toggleTheme: () =>
|
| 32 |
+
set((state) => ({
|
| 33 |
+
themeMode: state.themeMode === 'dark' ? 'light' : 'dark',
|
| 34 |
+
})),
|
| 35 |
+
}),
|
| 36 |
+
{
|
| 37 |
+
name: 'hf-agent-layout',
|
| 38 |
+
partialize: (state) => ({ themeMode: state.themeMode }),
|
| 39 |
+
}
|
| 40 |
+
)
|
| 41 |
+
);
|
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import { create } from 'zustand';
|
| 2 |
import { persist } from 'zustand/middleware';
|
| 3 |
import type { SessionMeta } from '@/types/agent';
|
|
|
|
| 4 |
|
| 5 |
interface SessionStore {
|
| 6 |
sessions: SessionMeta[];
|
|
@@ -10,8 +11,8 @@ interface SessionStore {
|
|
| 10 |
createSession: (id: string) => void;
|
| 11 |
deleteSession: (id: string) => void;
|
| 12 |
switchSession: (id: string) => void;
|
| 13 |
-
updateSessionTitle: (id: string, title: string) => void;
|
| 14 |
setSessionActive: (id: string, isActive: boolean) => void;
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
export const useSessionStore = create<SessionStore>()(
|
|
@@ -34,6 +35,9 @@ export const useSessionStore = create<SessionStore>()(
|
|
| 34 |
},
|
| 35 |
|
| 36 |
deleteSession: (id: string) => {
|
|
|
|
|
|
|
|
|
|
| 37 |
set((state) => {
|
| 38 |
const newSessions = state.sessions.filter((s) => s.id !== id);
|
| 39 |
const newActiveId =
|
|
@@ -51,18 +55,18 @@ export const useSessionStore = create<SessionStore>()(
|
|
| 51 |
set({ activeSessionId: id });
|
| 52 |
},
|
| 53 |
|
| 54 |
-
|
| 55 |
set((state) => ({
|
| 56 |
sessions: state.sessions.map((s) =>
|
| 57 |
-
s.id === id ? { ...s,
|
| 58 |
),
|
| 59 |
}));
|
| 60 |
},
|
| 61 |
|
| 62 |
-
|
| 63 |
set((state) => ({
|
| 64 |
sessions: state.sessions.map((s) =>
|
| 65 |
-
s.id === id ? { ...s,
|
| 66 |
),
|
| 67 |
}));
|
| 68 |
},
|
|
|
|
| 1 |
import { create } from 'zustand';
|
| 2 |
import { persist } from 'zustand/middleware';
|
| 3 |
import type { SessionMeta } from '@/types/agent';
|
| 4 |
+
import { useAgentStore } from './agentStore';
|
| 5 |
|
| 6 |
interface SessionStore {
|
| 7 |
sessions: SessionMeta[];
|
|
|
|
| 11 |
createSession: (id: string) => void;
|
| 12 |
deleteSession: (id: string) => void;
|
| 13 |
switchSession: (id: string) => void;
|
|
|
|
| 14 |
setSessionActive: (id: string, isActive: boolean) => void;
|
| 15 |
+
updateSessionTitle: (id: string, title: string) => void;
|
| 16 |
}
|
| 17 |
|
| 18 |
export const useSessionStore = create<SessionStore>()(
|
|
|
|
| 35 |
},
|
| 36 |
|
| 37 |
deleteSession: (id: string) => {
|
| 38 |
+
// Clean up persisted messages for this session
|
| 39 |
+
useAgentStore.getState().deleteSessionMessages(id);
|
| 40 |
+
|
| 41 |
set((state) => {
|
| 42 |
const newSessions = state.sessions.filter((s) => s.id !== id);
|
| 43 |
const newActiveId =
|
|
|
|
| 55 |
set({ activeSessionId: id });
|
| 56 |
},
|
| 57 |
|
| 58 |
+
setSessionActive: (id: string, isActive: boolean) => {
|
| 59 |
set((state) => ({
|
| 60 |
sessions: state.sessions.map((s) =>
|
| 61 |
+
s.id === id ? { ...s, isActive } : s
|
| 62 |
),
|
| 63 |
}));
|
| 64 |
},
|
| 65 |
|
| 66 |
+
updateSessionTitle: (id: string, title: string) => {
|
| 67 |
set((state) => ({
|
| 68 |
sessions: state.sessions.map((s) =>
|
| 69 |
+
s.id === id ? { ...s, title } : s
|
| 70 |
),
|
| 71 |
}));
|
| 72 |
},
|
|
@@ -1,158 +1,223 @@
|
|
| 1 |
-
import { createTheme } from '@mui/material/styles';
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
},
|
| 24 |
-
error: {
|
| 25 |
-
main: '#E05A4F', // --accent-red
|
| 26 |
-
},
|
| 27 |
-
warning: {
|
| 28 |
-
main: '#C7A500',
|
| 29 |
-
},
|
| 30 |
-
info: {
|
| 31 |
-
main: '#58A6FF',
|
| 32 |
},
|
| 33 |
},
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
h1: { fontWeight: 600, color: '#E6EEF8' },
|
| 38 |
-
h2: { fontWeight: 600, color: '#E6EEF8' },
|
| 39 |
-
h3: { fontWeight: 600, color: '#E6EEF8' },
|
| 40 |
-
h4: { fontWeight: 600, color: '#E6EEF8' },
|
| 41 |
-
h5: { fontWeight: 600, color: '#E6EEF8' },
|
| 42 |
-
h6: { fontWeight: 600, color: '#E6EEF8' },
|
| 43 |
-
body1: { fontSize: '15px', color: '#E6EEF8' },
|
| 44 |
-
body2: { fontSize: '0.875rem', color: '#98A0AA' },
|
| 45 |
-
button: {
|
| 46 |
-
fontFamily: 'Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif',
|
| 47 |
-
textTransform: 'none',
|
| 48 |
-
fontWeight: 600,
|
| 49 |
},
|
| 50 |
},
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
},
|
|
|
|
| 102 |
},
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
styleOverrides: {
|
| 106 |
-
root: {
|
| 107 |
-
borderRadius: '10px',
|
| 108 |
-
fontWeight: 600,
|
| 109 |
-
transition: 'transform 0.06s ease, background 0.12s ease, box-shadow 0.12s ease',
|
| 110 |
-
'&:hover': {
|
| 111 |
-
transform: 'translateY(-1px)',
|
| 112 |
-
},
|
| 113 |
-
},
|
| 114 |
},
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
},
|
| 122 |
},
|
| 123 |
},
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
| 130 |
},
|
| 131 |
},
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
},
|
| 148 |
},
|
| 149 |
},
|
| 150 |
},
|
| 151 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
},
|
| 153 |
-
shape:
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
},
|
|
|
|
| 156 |
});
|
| 157 |
|
| 158 |
-
|
|
|
|
|
|
| 1 |
+
import { createTheme, type ThemeOptions } from '@mui/material/styles';
|
| 2 |
|
| 3 |
+
// ── Shared tokens ────────────────────────────────────────────────
|
| 4 |
+
const sharedTypography: ThemeOptions['typography'] = {
|
| 5 |
+
fontFamily: 'Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif',
|
| 6 |
+
fontSize: 15,
|
| 7 |
+
button: {
|
| 8 |
+
fontFamily: 'Inter, system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif',
|
| 9 |
+
textTransform: 'none' as const,
|
| 10 |
+
fontWeight: 600,
|
| 11 |
+
},
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
const sharedComponents: ThemeOptions['components'] = {
|
| 15 |
+
MuiButton: {
|
| 16 |
+
styleOverrides: {
|
| 17 |
+
root: {
|
| 18 |
+
borderRadius: '10px',
|
| 19 |
+
fontWeight: 600,
|
| 20 |
+
transition: 'transform 0.06s ease, background 0.12s ease, box-shadow 0.12s ease',
|
| 21 |
+
'&:hover': { transform: 'translateY(-1px)' },
|
| 22 |
+
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
},
|
| 24 |
},
|
| 25 |
+
MuiPaper: {
|
| 26 |
+
styleOverrides: {
|
| 27 |
+
root: { backgroundImage: 'none', backgroundColor: 'transparent' },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
},
|
| 29 |
},
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
const sharedShape: ThemeOptions['shape'] = { borderRadius: 12 };
|
| 33 |
+
|
| 34 |
+
// ── Dark palette ─────────────────────────────────────────────────
|
| 35 |
+
const darkVars = {
|
| 36 |
+
'--bg': '#0B0D10',
|
| 37 |
+
'--panel': '#0F1316',
|
| 38 |
+
'--surface': '#121416',
|
| 39 |
+
'--text': '#E6EEF8',
|
| 40 |
+
'--muted-text': '#98A0AA',
|
| 41 |
+
'--accent-yellow': '#FF9D00',
|
| 42 |
+
'--accent-yellow-weak': 'rgba(255,157,0,0.08)',
|
| 43 |
+
'--accent-green': '#2FCC71',
|
| 44 |
+
'--accent-red': '#E05A4F',
|
| 45 |
+
'--shadow-1': '0 6px 18px rgba(2,6,12,0.55)',
|
| 46 |
+
'--radius-lg': '20px',
|
| 47 |
+
'--radius-md': '12px',
|
| 48 |
+
'--focus': '0 0 0 3px rgba(255,157,0,0.12)',
|
| 49 |
+
'--border': 'rgba(255,255,255,0.03)',
|
| 50 |
+
'--border-hover': 'rgba(255,255,255,0.1)',
|
| 51 |
+
'--code-bg': 'rgba(0,0,0,0.5)',
|
| 52 |
+
'--tool-bg': 'rgba(0,0,0,0.3)',
|
| 53 |
+
'--tool-border': 'rgba(255,255,255,0.05)',
|
| 54 |
+
'--hover-bg': 'rgba(255,255,255,0.05)',
|
| 55 |
+
'--composer-bg': 'rgba(255,255,255,0.01)',
|
| 56 |
+
'--msg-gradient': 'linear-gradient(180deg, rgba(255,255,255,0.015), transparent)',
|
| 57 |
+
'--body-gradient': 'linear-gradient(180deg, #0B0D10, #090B0D)',
|
| 58 |
+
'--scrollbar-thumb': '#30363D',
|
| 59 |
+
'--success-icon': '#FDB022',
|
| 60 |
+
'--error-icon': '#F87171',
|
| 61 |
+
'--clickable-text': 'rgba(255, 255, 255, 0.9)',
|
| 62 |
+
'--clickable-underline': 'rgba(255,255,255,0.3)',
|
| 63 |
+
'--code-panel-bg': '#0A0B0C',
|
| 64 |
+
'--tab-active-bg': 'rgba(255,255,255,0.08)',
|
| 65 |
+
'--tab-active-border': 'rgba(255,255,255,0.1)',
|
| 66 |
+
'--tab-hover-bg': 'rgba(255,255,255,0.05)',
|
| 67 |
+
'--tab-close-hover': 'rgba(255,255,255,0.1)',
|
| 68 |
+
'--plan-bg': 'rgba(0,0,0,0.2)',
|
| 69 |
+
} as const;
|
| 70 |
+
|
| 71 |
+
// ── Light palette ────────────────────────────────────────────────
|
| 72 |
+
const lightVars = {
|
| 73 |
+
'--bg': '#FFFFFF',
|
| 74 |
+
'--panel': '#F7F8FA',
|
| 75 |
+
'--surface': '#F0F1F3',
|
| 76 |
+
'--text': '#1A1A2E',
|
| 77 |
+
'--muted-text': '#6B7280',
|
| 78 |
+
'--accent-yellow': '#FF9D00',
|
| 79 |
+
'--accent-yellow-weak': 'rgba(255,157,0,0.08)',
|
| 80 |
+
'--accent-green': '#16A34A',
|
| 81 |
+
'--accent-red': '#DC2626',
|
| 82 |
+
'--shadow-1': '0 4px 12px rgba(0,0,0,0.08)',
|
| 83 |
+
'--radius-lg': '20px',
|
| 84 |
+
'--radius-md': '12px',
|
| 85 |
+
'--focus': '0 0 0 3px rgba(255,157,0,0.15)',
|
| 86 |
+
'--border': 'rgba(0,0,0,0.08)',
|
| 87 |
+
'--border-hover': 'rgba(0,0,0,0.15)',
|
| 88 |
+
'--code-bg': 'rgba(0,0,0,0.04)',
|
| 89 |
+
'--tool-bg': 'rgba(0,0,0,0.03)',
|
| 90 |
+
'--tool-border': 'rgba(0,0,0,0.08)',
|
| 91 |
+
'--hover-bg': 'rgba(0,0,0,0.04)',
|
| 92 |
+
'--composer-bg': 'rgba(0,0,0,0.02)',
|
| 93 |
+
'--msg-gradient': 'linear-gradient(180deg, rgba(0,0,0,0.01), transparent)',
|
| 94 |
+
'--body-gradient': 'linear-gradient(180deg, #FFFFFF, #F7F8FA)',
|
| 95 |
+
'--scrollbar-thumb': '#C4C8CC',
|
| 96 |
+
'--success-icon': '#FF9D00',
|
| 97 |
+
'--error-icon': '#DC2626',
|
| 98 |
+
'--clickable-text': 'rgba(0, 0, 0, 0.85)',
|
| 99 |
+
'--clickable-underline': 'rgba(0,0,0,0.25)',
|
| 100 |
+
'--code-panel-bg': '#F5F6F8',
|
| 101 |
+
'--tab-active-bg': 'rgba(0,0,0,0.06)',
|
| 102 |
+
'--tab-active-border': 'rgba(0,0,0,0.1)',
|
| 103 |
+
'--tab-hover-bg': 'rgba(0,0,0,0.04)',
|
| 104 |
+
'--tab-close-hover': 'rgba(0,0,0,0.08)',
|
| 105 |
+
'--plan-bg': 'rgba(0,0,0,0.03)',
|
| 106 |
+
} as const;
|
| 107 |
+
|
| 108 |
+
// ── Shared CSS baseline (scrollbar, code, brand-logo) ────────────
|
| 109 |
+
function makeCssBaseline(vars: Record<string, string>) {
|
| 110 |
+
return {
|
| 111 |
+
styleOverrides: {
|
| 112 |
+
':root': vars,
|
| 113 |
+
body: {
|
| 114 |
+
background: 'var(--body-gradient)',
|
| 115 |
+
color: 'var(--text)',
|
| 116 |
+
scrollbarWidth: 'thin' as const,
|
| 117 |
+
'&::-webkit-scrollbar': { width: '8px', height: '8px' },
|
| 118 |
+
'&::-webkit-scrollbar-thumb': {
|
| 119 |
+
backgroundColor: 'var(--scrollbar-thumb)',
|
| 120 |
+
borderRadius: '2px',
|
| 121 |
},
|
| 122 |
+
'&::-webkit-scrollbar-track': { backgroundColor: 'transparent' },
|
| 123 |
},
|
| 124 |
+
'code, pre': {
|
| 125 |
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", monospace',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
},
|
| 127 |
+
'.brand-logo': {
|
| 128 |
+
position: 'relative' as const,
|
| 129 |
+
padding: '6px',
|
| 130 |
+
borderRadius: '8px',
|
| 131 |
+
'&::after': {
|
| 132 |
+
content: '""',
|
| 133 |
+
position: 'absolute' as const,
|
| 134 |
+
inset: '-6px',
|
| 135 |
+
borderRadius: '10px',
|
| 136 |
+
background: 'var(--accent-yellow-weak)',
|
| 137 |
+
zIndex: -1,
|
| 138 |
+
pointerEvents: 'none' as const,
|
| 139 |
},
|
| 140 |
},
|
| 141 |
},
|
| 142 |
+
};
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
function makeDrawer() {
|
| 146 |
+
return {
|
| 147 |
+
styleOverrides: {
|
| 148 |
+
paper: {
|
| 149 |
+
backgroundColor: 'var(--panel)',
|
| 150 |
+
borderRight: '1px solid var(--border)',
|
| 151 |
},
|
| 152 |
},
|
| 153 |
+
};
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
function makeTextField() {
|
| 157 |
+
return {
|
| 158 |
+
styleOverrides: {
|
| 159 |
+
root: {
|
| 160 |
+
'& .MuiOutlinedInput-root': {
|
| 161 |
+
borderRadius: 'var(--radius-md)',
|
| 162 |
+
'& fieldset': { borderColor: 'var(--border)' },
|
| 163 |
+
'&:hover fieldset': { borderColor: 'var(--border-hover)' },
|
| 164 |
+
'&.Mui-focused fieldset': {
|
| 165 |
+
borderColor: 'var(--accent-yellow)',
|
| 166 |
+
borderWidth: '1px',
|
| 167 |
+
boxShadow: 'var(--focus)',
|
|
|
|
| 168 |
},
|
| 169 |
},
|
| 170 |
},
|
| 171 |
},
|
| 172 |
+
};
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// ── Theme builders ───────────────────────────────────────────────
|
| 176 |
+
export const darkTheme = createTheme({
|
| 177 |
+
palette: {
|
| 178 |
+
mode: 'dark',
|
| 179 |
+
primary: { main: '#FF9D00' },
|
| 180 |
+
secondary: { main: '#C7A500' },
|
| 181 |
+
background: { default: '#0B0D10', paper: '#0F1316' },
|
| 182 |
+
text: { primary: '#E6EEF8', secondary: '#98A0AA' },
|
| 183 |
+
divider: 'rgba(255,255,255,0.03)',
|
| 184 |
+
success: { main: '#2FCC71' },
|
| 185 |
+
error: { main: '#E05A4F' },
|
| 186 |
+
warning: { main: '#FF9D00' },
|
| 187 |
+
info: { main: '#58A6FF' },
|
| 188 |
+
},
|
| 189 |
+
typography: sharedTypography,
|
| 190 |
+
components: {
|
| 191 |
+
...sharedComponents,
|
| 192 |
+
MuiCssBaseline: makeCssBaseline(darkVars),
|
| 193 |
+
MuiDrawer: makeDrawer(),
|
| 194 |
+
MuiTextField: makeTextField(),
|
| 195 |
},
|
| 196 |
+
shape: sharedShape,
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
export const lightTheme = createTheme({
|
| 200 |
+
palette: {
|
| 201 |
+
mode: 'light',
|
| 202 |
+
primary: { main: '#FF9D00' },
|
| 203 |
+
secondary: { main: '#B8960A' },
|
| 204 |
+
background: { default: '#FFFFFF', paper: '#F7F8FA' },
|
| 205 |
+
text: { primary: '#1A1A2E', secondary: '#6B7280' },
|
| 206 |
+
divider: 'rgba(0,0,0,0.08)',
|
| 207 |
+
success: { main: '#16A34A' },
|
| 208 |
+
error: { main: '#DC2626' },
|
| 209 |
+
warning: { main: '#FF9D00' },
|
| 210 |
+
info: { main: '#2563EB' },
|
| 211 |
+
},
|
| 212 |
+
typography: sharedTypography,
|
| 213 |
+
components: {
|
| 214 |
+
...sharedComponents,
|
| 215 |
+
MuiCssBaseline: makeCssBaseline(lightVars),
|
| 216 |
+
MuiDrawer: makeDrawer(),
|
| 217 |
+
MuiTextField: makeTextField(),
|
| 218 |
},
|
| 219 |
+
shape: sharedShape,
|
| 220 |
});
|
| 221 |
|
| 222 |
+
// Keep default export for backwards compat
|
| 223 |
+
export default darkTheme;
|
|
@@ -52,8 +52,11 @@ export interface ApprovalBatch {
|
|
| 52 |
count: number;
|
| 53 |
}
|
| 54 |
|
|
|
|
|
|
|
| 55 |
export interface TraceLog {
|
| 56 |
id: string;
|
|
|
|
| 57 |
type: 'call' | 'output';
|
| 58 |
text: string;
|
| 59 |
tool: string;
|
|
@@ -62,6 +65,12 @@ export interface TraceLog {
|
|
| 62 |
args?: Record<string, unknown>; // Store args for auto-exec jobs
|
| 63 |
output?: string; // Store tool output for display
|
| 64 |
success?: boolean; // Whether the tool call succeeded
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
}
|
| 66 |
|
| 67 |
export interface User {
|
|
|
|
| 52 |
count: number;
|
| 53 |
}
|
| 54 |
|
| 55 |
+
export type ApprovalStatus = 'none' | 'pending' | 'approved' | 'rejected';
|
| 56 |
+
|
| 57 |
export interface TraceLog {
|
| 58 |
id: string;
|
| 59 |
+
toolCallId?: string; // Backend tool_call_id for reliable matching
|
| 60 |
type: 'call' | 'output';
|
| 61 |
text: string;
|
| 62 |
tool: string;
|
|
|
|
| 65 |
args?: Record<string, unknown>; // Store args for auto-exec jobs
|
| 66 |
output?: string; // Store tool output for display
|
| 67 |
success?: boolean; // Whether the tool call succeeded
|
| 68 |
+
/** Approval state for tools that need user confirmation */
|
| 69 |
+
approvalStatus?: ApprovalStatus;
|
| 70 |
+
/** Parsed job info (URL, status, logs) for hf_jobs */
|
| 71 |
+
jobUrl?: string;
|
| 72 |
+
jobStatus?: string;
|
| 73 |
+
jobLogs?: string;
|
| 74 |
}
|
| 75 |
|
| 76 |
export interface User {
|
|
@@ -6,6 +6,8 @@ export type EventType =
|
|
| 6 |
| 'ready'
|
| 7 |
| 'processing'
|
| 8 |
| 'assistant_message'
|
|
|
|
|
|
|
| 9 |
| 'tool_call'
|
| 10 |
| 'tool_output'
|
| 11 |
| 'tool_log'
|
|
|
|
| 6 |
| 'ready'
|
| 7 |
| 'processing'
|
| 8 |
| 'assistant_message'
|
| 9 |
+
| 'assistant_chunk'
|
| 10 |
+
| 'assistant_stream_end'
|
| 11 |
| 'tool_call'
|
| 12 |
| 'tool_output'
|
| 13 |
| 'tool_log'
|
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Centralized API utilities with automatic auth header injection.
|
| 3 |
+
*
|
| 4 |
+
* In production (OAuth enabled):
|
| 5 |
+
* - REST calls include the HttpOnly cookie automatically (same-origin)
|
| 6 |
+
* - WebSocket passes token via query parameter
|
| 7 |
+
*
|
| 8 |
+
* In development (no OAuth):
|
| 9 |
+
* - Auth is bypassed on the backend, no token needed
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
/** Get the base URL for API calls (handles dev proxy vs production) */
|
| 13 |
+
function getApiBase(): string {
|
| 14 |
+
// In development, Vite proxies /api and /auth to the backend
|
| 15 |
+
// In production, same origin
|
| 16 |
+
return '';
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/** Wrapper around fetch that includes credentials (cookies) and common headers. */
|
| 20 |
+
export async function apiFetch(
|
| 21 |
+
path: string,
|
| 22 |
+
options: RequestInit = {}
|
| 23 |
+
): Promise<Response> {
|
| 24 |
+
const url = `${getApiBase()}${path}`;
|
| 25 |
+
|
| 26 |
+
const headers: Record<string, string> = {
|
| 27 |
+
'Content-Type': 'application/json',
|
| 28 |
+
...(options.headers as Record<string, string>),
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
const response = await fetch(url, {
|
| 32 |
+
...options,
|
| 33 |
+
headers,
|
| 34 |
+
credentials: 'include', // Send cookies (hf_access_token) with every request
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
// Handle 401 - redirect to login if auth is required
|
| 38 |
+
if (response.status === 401) {
|
| 39 |
+
const authStatus = await fetch(`${getApiBase()}/auth/status`, {
|
| 40 |
+
credentials: 'include',
|
| 41 |
+
});
|
| 42 |
+
const data = await authStatus.json();
|
| 43 |
+
if (data.auth_enabled) {
|
| 44 |
+
window.location.href = '/auth/login';
|
| 45 |
+
throw new Error('Authentication required — redirecting to login.');
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return response;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/** Build the WebSocket URL for a session, including auth token if available. */
|
| 53 |
+
export function getWebSocketUrl(sessionId: string): string {
|
| 54 |
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 55 |
+
// Always use same origin — Vite proxy (ws: true) handles dev,
|
| 56 |
+
// same origin works directly in production. No cross-origin issues.
|
| 57 |
+
return `${protocol}//${window.location.host}/api/ws/${sessionId}`;
|
| 58 |
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Lightweight logger that silences verbose output in production.
|
| 3 |
+
*
|
| 4 |
+
* - `log` / `debug` are only emitted when `import.meta.env.DEV` is true.
|
| 5 |
+
* - `warn` and `error` always go through so real issues surface in prod.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
const isDev = import.meta.env.DEV;
|
| 9 |
+
|
| 10 |
+
/* eslint-disable no-console */
|
| 11 |
+
export const logger = {
|
| 12 |
+
/** Debug-level log — DEV only. */
|
| 13 |
+
log: (...args: unknown[]) => {
|
| 14 |
+
if (isDev) console.log(...args);
|
| 15 |
+
},
|
| 16 |
+
/** Debug-level log — DEV only. */
|
| 17 |
+
debug: (...args: unknown[]) => {
|
| 18 |
+
if (isDev) console.debug(...args);
|
| 19 |
+
},
|
| 20 |
+
/** Warning — always emitted. */
|
| 21 |
+
warn: console.warn.bind(console),
|
| 22 |
+
/** Error — always emitted. */
|
| 23 |
+
error: console.error.bind(console),
|
| 24 |
+
};
|
|
@@ -15,6 +15,7 @@ export default defineConfig({
|
|
| 15 |
'/api': {
|
| 16 |
target: 'http://localhost:7860',
|
| 17 |
changeOrigin: true,
|
|
|
|
| 18 |
},
|
| 19 |
'/auth': {
|
| 20 |
target: 'http://localhost:7860',
|
|
|
|
| 15 |
'/api': {
|
| 16 |
target: 'http://localhost:7860',
|
| 17 |
changeOrigin: true,
|
| 18 |
+
ws: true, // Proxy WebSocket connections (/api/ws/...)
|
| 19 |
},
|
| 20 |
'/auth': {
|
| 21 |
target: 'http://localhost:7860',
|