merging frontend to github (png binaries still remain in github history)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- README.md +4 -0
- agent/context_manager/manager.py +73 -3
- agent/core/agent_loop.py +355 -89
- agent/core/session.py +48 -8
- agent/core/session_uploader.py +2 -4
- agent/core/tools.py +13 -6
- agent/prompts/system_prompt.yaml +2 -2
- agent/prompts/system_prompt_v2.yaml +46 -59
- agent/tools/jobs_tool.py +49 -19
- agent/tools/sandbox_tool.py +6 -2
- backend/dependencies.py +144 -0
- backend/main.py +8 -0
- backend/models.py +12 -0
- backend/routes/agent.py +282 -27
- backend/routes/auth.py +74 -51
- backend/session_manager.py +114 -14
- backend/websocket.py +0 -10
- configs/main_agent_config.json +2 -2
- frontend/package-lock.json +168 -0
- frontend/package.json +2 -0
- frontend/src/App.tsx +5 -0
- frontend/src/components/ApprovalModal/ApprovalModal.tsx +0 -208
- frontend/src/components/Chat/ActivityStatusBar.tsx +57 -0
- frontend/src/components/Chat/ApprovalFlow.tsx +0 -515
- frontend/src/components/Chat/AssistantMessage.tsx +119 -0
- frontend/src/components/Chat/ChatInput.tsx +218 -15
- frontend/src/components/Chat/MarkdownContent.tsx +160 -0
- frontend/src/components/Chat/MessageBubble.tsx +32 -203
- frontend/src/components/Chat/MessageList.tsx +125 -74
- frontend/src/components/Chat/ThinkingIndicator.tsx +48 -0
- frontend/src/components/Chat/ToolCallGroup.tsx +655 -0
- frontend/src/components/Chat/UserMessage.tsx +105 -0
- frontend/src/components/CodePanel/CodePanel.tsx +479 -256
- frontend/src/components/Layout/AppLayout.tsx +351 -167
- frontend/src/components/SessionSidebar/SessionSidebar.tsx +279 -181
- frontend/src/components/WelcomeScreen/WelcomeScreen.tsx +247 -0
- frontend/src/hooks/useAgentChat.ts +278 -0
- frontend/src/hooks/useAgentWebSocket.ts +0 -503
- frontend/src/hooks/useAuth.ts +77 -0
- frontend/src/lib/chat-message-store.ts +62 -0
- frontend/src/lib/ws-chat-transport.ts +593 -0
- frontend/src/main.tsx +13 -3
- frontend/src/store/agentStore.ts +121 -206
- frontend/src/store/layoutStore.ts +28 -10
- frontend/src/store/sessionStore.ts +7 -5
- frontend/src/theme.ts +202 -137
- frontend/src/types/agent.ts +9 -50
- frontend/src/types/events.ts +3 -0
- frontend/src/utils/api.ts +47 -0
- frontend/src/utils/logger.ts +24 -0
README.md
CHANGED
|
@@ -9,7 +9,11 @@ hf_oauth: true
|
|
| 9 |
hf_oauth_scopes:
|
| 10 |
- read-repos
|
| 11 |
- write-repos
|
|
|
|
|
|
|
| 12 |
- inference-api
|
|
|
|
|
|
|
| 13 |
---
|
| 14 |
|
| 15 |
# HF Agent
|
|
|
|
| 9 |
hf_oauth_scopes:
|
| 10 |
- read-repos
|
| 11 |
- write-repos
|
| 12 |
+
- contribute-repos
|
| 13 |
+
- manage-repos
|
| 14 |
- inference-api
|
| 15 |
+
- jobs
|
| 16 |
+
- write-discussions
|
| 17 |
---
|
| 18 |
|
| 19 |
# HF Agent
|
agent/context_manager/manager.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
Context management for conversation history
|
| 3 |
"""
|
| 4 |
|
|
|
|
| 5 |
import os
|
| 6 |
import zoneinfo
|
| 7 |
from datetime import datetime
|
|
@@ -13,6 +14,72 @@ 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 +121,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(
|
|
@@ -110,11 +176,15 @@ class ContextManager:
|
|
| 110 |
)
|
| 111 |
)
|
| 112 |
|
|
|
|
| 113 |
response = await acompletion(
|
| 114 |
model=model_name,
|
| 115 |
messages=messages_to_summarize,
|
| 116 |
max_completion_tokens=self.compact_size,
|
| 117 |
tools=tool_specs,
|
|
|
|
|
|
|
|
|
|
| 118 |
)
|
| 119 |
summarized_message = Message(
|
| 120 |
role="assistant", content=response.choices[0].message.content
|
|
|
|
| 2 |
Context management for conversation history
|
| 3 |
"""
|
| 4 |
|
| 5 |
+
import logging
|
| 6 |
import os
|
| 7 |
import zoneinfo
|
| 8 |
from datetime import datetime
|
|
|
|
| 14 |
from jinja2 import Template
|
| 15 |
from litellm import Message, acompletion
|
| 16 |
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
# Module-level cache for HF username — avoids repeating the slow whoami() call
|
| 20 |
+
_hf_username_cache: str | None = None
|
| 21 |
+
|
| 22 |
+
_HF_WHOAMI_URL = "https://huggingface.co/api/whoami-v2"
|
| 23 |
+
_HF_WHOAMI_TIMEOUT = 5 # seconds
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _get_hf_username() -> str:
|
| 27 |
+
"""Return the HF username, cached after the first call.
|
| 28 |
+
|
| 29 |
+
Uses subprocess + curl to avoid Python HTTP client IPv6 issues that
|
| 30 |
+
cause 40+ second hangs (httpx/urllib try IPv6 first which times out
|
| 31 |
+
at OS level before falling back to IPv4 — the "Happy Eyeballs" problem).
|
| 32 |
+
"""
|
| 33 |
+
import json
|
| 34 |
+
import subprocess
|
| 35 |
+
import time as _t
|
| 36 |
+
|
| 37 |
+
global _hf_username_cache
|
| 38 |
+
if _hf_username_cache is not None:
|
| 39 |
+
return _hf_username_cache
|
| 40 |
+
|
| 41 |
+
hf_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN")
|
| 42 |
+
if not hf_token:
|
| 43 |
+
logger.warning("No HF_TOKEN set, using 'unknown' as username")
|
| 44 |
+
_hf_username_cache = "unknown"
|
| 45 |
+
return _hf_username_cache
|
| 46 |
+
|
| 47 |
+
t0 = _t.monotonic()
|
| 48 |
+
try:
|
| 49 |
+
result = subprocess.run(
|
| 50 |
+
[
|
| 51 |
+
"curl",
|
| 52 |
+
"-s",
|
| 53 |
+
"-4", # force IPv4
|
| 54 |
+
"-m",
|
| 55 |
+
str(_HF_WHOAMI_TIMEOUT), # max time
|
| 56 |
+
"-H",
|
| 57 |
+
f"Authorization: Bearer {hf_token}",
|
| 58 |
+
_HF_WHOAMI_URL,
|
| 59 |
+
],
|
| 60 |
+
capture_output=True,
|
| 61 |
+
text=True,
|
| 62 |
+
timeout=_HF_WHOAMI_TIMEOUT + 2,
|
| 63 |
+
)
|
| 64 |
+
t1 = _t.monotonic()
|
| 65 |
+
if result.returncode == 0 and result.stdout:
|
| 66 |
+
data = json.loads(result.stdout)
|
| 67 |
+
_hf_username_cache = data.get("name", "unknown")
|
| 68 |
+
logger.info(
|
| 69 |
+
f"HF username resolved to '{_hf_username_cache}' in {t1 - t0:.2f}s"
|
| 70 |
+
)
|
| 71 |
+
else:
|
| 72 |
+
logger.warning(
|
| 73 |
+
f"curl whoami failed (rc={result.returncode}) in {t1 - t0:.2f}s"
|
| 74 |
+
)
|
| 75 |
+
_hf_username_cache = "unknown"
|
| 76 |
+
except Exception as e:
|
| 77 |
+
t1 = _t.monotonic()
|
| 78 |
+
logger.warning(f"HF whoami failed in {t1 - t0:.2f}s: {e}")
|
| 79 |
+
_hf_username_cache = "unknown"
|
| 80 |
+
|
| 81 |
+
return _hf_username_cache
|
| 82 |
+
|
| 83 |
|
| 84 |
class ContextManager:
|
| 85 |
"""Manages conversation context and message history for the agent"""
|
|
|
|
| 121 |
current_time = now.strftime("%H:%M:%S.%f")[:-3]
|
| 122 |
current_timezone = f"{now.strftime('%Z')} (UTC{now.strftime('%z')[:3]}:{now.strftime('%z')[3:]})"
|
| 123 |
|
| 124 |
+
# Get HF user info (cached after the first call)
|
| 125 |
+
hf_user_info = _get_hf_username()
|
|
|
|
| 126 |
|
| 127 |
template = Template(template_str)
|
| 128 |
return template.render(
|
|
|
|
| 176 |
)
|
| 177 |
)
|
| 178 |
|
| 179 |
+
hf_key = os.environ.get("INFERENCE_TOKEN")
|
| 180 |
response = await acompletion(
|
| 181 |
model=model_name,
|
| 182 |
messages=messages_to_summarize,
|
| 183 |
max_completion_tokens=self.compact_size,
|
| 184 |
tools=tool_specs,
|
| 185 |
+
api_key=hf_key
|
| 186 |
+
if hf_key and model_name.startswith("huggingface/")
|
| 187 |
+
else None,
|
| 188 |
)
|
| 189 |
summarized_message = Message(
|
| 190 |
role="assistant", content=response.choices[0].message.content
|
agent/core/agent_loop.py
CHANGED
|
@@ -4,8 +4,10 @@ Main agent implementation with integrated tool system and MCP support
|
|
| 4 |
|
| 5 |
import asyncio
|
| 6 |
import json
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
from litellm import ChatCompletionMessageToolCall, Message,
|
| 9 |
from litellm.exceptions import ContextWindowExceededError
|
| 10 |
from lmnr import observe
|
| 11 |
|
|
@@ -14,7 +16,42 @@ from agent.core.session import Event, OpType, Session
|
|
| 14 |
from agent.core.tools import ToolRouter
|
| 15 |
from agent.tools.jobs_tool import CPU_FLAVORS
|
| 16 |
|
|
|
|
|
|
|
| 17 |
ToolCall = ChatCompletionMessageToolCall
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
def _validate_tool_args(tool_args: dict) -> tuple[bool, str | None]:
|
|
@@ -130,6 +167,42 @@ async def _compact_and_notify(session: Session) -> None:
|
|
| 130 |
class Handlers:
|
| 131 |
"""Handler functions for each operation type"""
|
| 132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
@staticmethod
|
| 134 |
@observe(name="run_agent")
|
| 135 |
async def run_agent(
|
|
@@ -145,6 +218,11 @@ class Handlers:
|
|
| 145 |
|
| 146 |
Laminar.set_trace_session_id(session_id=session.session_id)
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
# Add user message to history only if there's actual content
|
| 149 |
if text:
|
| 150 |
user_msg = Message(role="user", content=text)
|
|
@@ -165,37 +243,100 @@ class Handlers:
|
|
| 165 |
|
| 166 |
messages = session.context_manager.get_messages()
|
| 167 |
tools = session.tool_router.get_tool_specs_for_llm()
|
| 168 |
-
|
| 169 |
try:
|
| 170 |
-
|
| 171 |
-
|
|
|
|
| 172 |
messages=messages,
|
| 173 |
tools=tools,
|
| 174 |
tool_choice="auto",
|
|
|
|
|
|
|
|
|
|
| 175 |
)
|
| 176 |
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
# If no tool calls, add assistant message and we're done
|
| 184 |
if not tool_calls:
|
| 185 |
if content:
|
| 186 |
assistant_msg = Message(role="assistant", content=content)
|
| 187 |
session.context_manager.add_message(assistant_msg, token_count)
|
| 188 |
-
await session.send_event(
|
| 189 |
-
Event(
|
| 190 |
-
event_type="assistant_message",
|
| 191 |
-
data={"content": content},
|
| 192 |
-
)
|
| 193 |
-
)
|
| 194 |
final_response = content
|
| 195 |
break
|
| 196 |
|
| 197 |
# Add assistant message with tool calls to history
|
| 198 |
-
# LiteLLM will format this correctly for the provider
|
| 199 |
assistant_msg = Message(
|
| 200 |
role="assistant",
|
| 201 |
content=content,
|
|
@@ -203,66 +344,97 @@ class Handlers:
|
|
| 203 |
)
|
| 204 |
session.context_manager.add_message(assistant_msg, token_count)
|
| 205 |
|
| 206 |
-
if content:
|
| 207 |
-
await session.send_event(
|
| 208 |
-
Event(event_type="assistant_message", data={"content": content})
|
| 209 |
-
)
|
| 210 |
-
|
| 211 |
# Separate tools into those requiring approval and those that don't
|
| 212 |
approval_required_tools = []
|
| 213 |
non_approval_tools = []
|
| 214 |
|
| 215 |
for tc in tool_calls:
|
| 216 |
tool_name = tc.function.name
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
if _needs_approval(tool_name, tool_args, session.config):
|
| 220 |
approval_required_tools.append(tc)
|
| 221 |
else:
|
| 222 |
non_approval_tools.append(tc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
await session.send_event(
|
| 237 |
-
Event(
|
| 238 |
-
event_type="tool_call",
|
| 239 |
-
data={"tool": tool_name, "arguments": tool_args},
|
| 240 |
)
|
| 241 |
-
)
|
| 242 |
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
)
|
|
|
|
| 246 |
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
name=tool_name,
|
| 253 |
)
|
| 254 |
-
session.context_manager.add_message(tool_msg)
|
| 255 |
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
)
|
| 265 |
-
)
|
| 266 |
|
| 267 |
# If there are tools requiring approval, ask for batch approval
|
| 268 |
if approval_required_tools:
|
|
@@ -270,7 +442,10 @@ class Handlers:
|
|
| 270 |
tools_data = []
|
| 271 |
for tc in approval_required_tools:
|
| 272 |
tool_name = tc.function.name
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
| 274 |
tools_data.append(
|
| 275 |
{
|
| 276 |
"tool": tool_name,
|
|
@@ -339,11 +514,27 @@ class Handlers:
|
|
| 339 |
|
| 340 |
@staticmethod
|
| 341 |
async def undo(session: Session) -> None:
|
| 342 |
-
"""
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
|
| 348 |
await session.send_event(Event(event_type="undo_complete"))
|
| 349 |
|
|
@@ -371,6 +562,9 @@ class Handlers:
|
|
| 371 |
|
| 372 |
# Create a map of tool_call_id -> approval decision
|
| 373 |
approval_map = {a["tool_call_id"]: a for a in approvals}
|
|
|
|
|
|
|
|
|
|
| 374 |
|
| 375 |
# Separate approved and rejected tool calls
|
| 376 |
approved_tasks = []
|
|
@@ -378,36 +572,99 @@ class Handlers:
|
|
| 378 |
|
| 379 |
for tc in tool_calls:
|
| 380 |
tool_name = tc.function.name
|
| 381 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
approval_decision = approval_map.get(tc.id, {"approved": False})
|
| 383 |
|
| 384 |
if approval_decision.get("approved", False):
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
else:
|
| 387 |
rejected_tasks.append((tc, tool_name, approval_decision))
|
| 388 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
# Execute all approved tools concurrently
|
| 390 |
-
async def execute_tool(tc, tool_name, tool_args):
|
| 391 |
-
"""Execute a single tool and return its result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
await session.send_event(
|
| 393 |
Event(
|
| 394 |
-
event_type="
|
| 395 |
-
data={
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
)
|
| 397 |
)
|
| 398 |
|
| 399 |
output, success = await session.tool_router.call_tool(
|
| 400 |
-
tool_name, tool_args, session=session
|
| 401 |
)
|
| 402 |
|
| 403 |
-
return (tc, tool_name, output, success)
|
| 404 |
|
| 405 |
# Execute all approved tools concurrently and wait for ALL to complete
|
| 406 |
if approved_tasks:
|
| 407 |
results = await asyncio.gather(
|
| 408 |
*[
|
| 409 |
-
execute_tool(tc, tool_name, tool_args)
|
| 410 |
-
for tc, tool_name, tool_args in approved_tasks
|
| 411 |
],
|
| 412 |
return_exceptions=True,
|
| 413 |
)
|
|
@@ -416,10 +673,13 @@ class Handlers:
|
|
| 416 |
for result in results:
|
| 417 |
if isinstance(result, Exception):
|
| 418 |
# Handle execution error
|
| 419 |
-
|
| 420 |
continue
|
| 421 |
|
| 422 |
-
tc, tool_name, output, success = result
|
|
|
|
|
|
|
|
|
|
| 423 |
|
| 424 |
# Add tool result to context
|
| 425 |
tool_msg = Message(
|
|
@@ -435,6 +695,7 @@ class Handlers:
|
|
| 435 |
event_type="tool_output",
|
| 436 |
data={
|
| 437 |
"tool": tool_name,
|
|
|
|
| 438 |
"output": output,
|
| 439 |
"success": success,
|
| 440 |
},
|
|
@@ -446,7 +707,14 @@ class Handlers:
|
|
| 446 |
rejection_msg = "Job execution cancelled by user"
|
| 447 |
user_feedback = approval_decision.get("feedback")
|
| 448 |
if user_feedback:
|
| 449 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
|
| 451 |
tool_msg = Message(
|
| 452 |
role="tool",
|
|
@@ -461,6 +729,7 @@ class Handlers:
|
|
| 461 |
event_type="tool_output",
|
| 462 |
data={
|
| 463 |
"tool": tool_name,
|
|
|
|
| 464 |
"output": rejection_msg,
|
| 465 |
"success": False,
|
| 466 |
},
|
|
@@ -478,11 +747,9 @@ class Handlers:
|
|
| 478 |
"""Handle shutdown (like shutdown in codex.rs:1329)"""
|
| 479 |
# Save session trajectory if enabled (fire-and-forget, returns immediately)
|
| 480 |
if session.config.save_sessions:
|
| 481 |
-
|
| 482 |
repo_id = session.config.session_dataset_repo
|
| 483 |
_ = session.save_and_upload_detached(repo_id)
|
| 484 |
-
# if local_path:
|
| 485 |
-
# print("✅ Session saved locally, upload in progress")
|
| 486 |
|
| 487 |
session.is_running = False
|
| 488 |
await session.send_event(Event(event_type="shutdown"))
|
|
@@ -497,7 +764,7 @@ async def process_submission(session: Session, submission) -> bool:
|
|
| 497 |
bool: True to continue, False to shutdown
|
| 498 |
"""
|
| 499 |
op = submission.operation
|
| 500 |
-
|
| 501 |
|
| 502 |
if op.op_type == OpType.USER_INPUT:
|
| 503 |
text = op.data.get("text", "") if op.data else ""
|
|
@@ -509,7 +776,6 @@ async def process_submission(session: Session, submission) -> bool:
|
|
| 509 |
return True
|
| 510 |
|
| 511 |
if op.op_type == OpType.COMPACT:
|
| 512 |
-
# compact from the frontend
|
| 513 |
await _compact_and_notify(session)
|
| 514 |
return True
|
| 515 |
|
|
@@ -525,7 +791,7 @@ async def process_submission(session: Session, submission) -> bool:
|
|
| 525 |
if op.op_type == OpType.SHUTDOWN:
|
| 526 |
return not await Handlers.shutdown(session)
|
| 527 |
|
| 528 |
-
|
| 529 |
return True
|
| 530 |
|
| 531 |
|
|
@@ -543,7 +809,7 @@ async def submission_loop(
|
|
| 543 |
|
| 544 |
# Create session with tool router
|
| 545 |
session = Session(event_queue, config=config, tool_router=tool_router)
|
| 546 |
-
|
| 547 |
|
| 548 |
# Retry any failed uploads from previous sessions (fire-and-forget)
|
| 549 |
if config and config.save_sessions:
|
|
@@ -567,25 +833,25 @@ async def submission_loop(
|
|
| 567 |
if not should_continue:
|
| 568 |
break
|
| 569 |
except asyncio.CancelledError:
|
| 570 |
-
|
| 571 |
break
|
| 572 |
except Exception as e:
|
| 573 |
-
|
| 574 |
await session.send_event(
|
| 575 |
Event(event_type="error", data={"error": str(e)})
|
| 576 |
)
|
| 577 |
|
| 578 |
-
|
| 579 |
|
| 580 |
finally:
|
| 581 |
# Emergency save if session saving is enabled and shutdown wasn't called properly
|
| 582 |
if session.config.save_sessions and session.is_running:
|
| 583 |
-
|
| 584 |
try:
|
| 585 |
local_path = session.save_and_upload_detached(
|
| 586 |
session.config.session_dataset_repo
|
| 587 |
)
|
| 588 |
if local_path:
|
| 589 |
-
|
| 590 |
except Exception as e:
|
| 591 |
-
|
|
|
|
| 4 |
|
| 5 |
import asyncio
|
| 6 |
import json
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
|
| 10 |
+
from litellm import ChatCompletionMessageToolCall, Message, acompletion
|
| 11 |
from litellm.exceptions import ContextWindowExceededError
|
| 12 |
from lmnr import observe
|
| 13 |
|
|
|
|
| 16 |
from agent.core.tools import ToolRouter
|
| 17 |
from agent.tools.jobs_tool import CPU_FLAVORS
|
| 18 |
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
ToolCall = ChatCompletionMessageToolCall
|
| 22 |
+
# Explicit inference token — needed because litellm checks HF_TOKEN before
|
| 23 |
+
# HUGGINGFACE_API_KEY, and HF_TOKEN (used for Hub ops) may lack inference permissions.
|
| 24 |
+
_INFERENCE_API_KEY = os.environ.get("INFERENCE_TOKEN")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _resolve_hf_router_params(model_name: str) -> dict:
|
| 28 |
+
"""
|
| 29 |
+
Build LiteLLM kwargs for HuggingFace Router models.
|
| 30 |
+
|
| 31 |
+
api-inference.huggingface.co is deprecated; the new router lives at
|
| 32 |
+
router.huggingface.co/<provider>/v3/openai. LiteLLM's built-in
|
| 33 |
+
``huggingface/`` provider still targets the old endpoint, so we
|
| 34 |
+
rewrite model names to ``openai/`` and supply the correct api_base.
|
| 35 |
+
|
| 36 |
+
Input format: huggingface/<router_provider>/<org>/<model>
|
| 37 |
+
Example: huggingface/novita/moonshotai/kimi-k2.5
|
| 38 |
+
"""
|
| 39 |
+
if not model_name.startswith("huggingface/"):
|
| 40 |
+
return {"model": model_name}
|
| 41 |
+
|
| 42 |
+
parts = model_name.split("/", 2) # ['huggingface', 'novita', 'moonshotai/kimi-k2.5']
|
| 43 |
+
if len(parts) < 3:
|
| 44 |
+
return {"model": model_name}
|
| 45 |
+
|
| 46 |
+
router_provider = parts[1]
|
| 47 |
+
actual_model = parts[2]
|
| 48 |
+
api_key = _INFERENCE_API_KEY or os.environ.get("HF_TOKEN")
|
| 49 |
+
|
| 50 |
+
return {
|
| 51 |
+
"model": f"openai/{actual_model}",
|
| 52 |
+
"api_base": f"https://router.huggingface.co/{router_provider}/v3/openai",
|
| 53 |
+
"api_key": api_key,
|
| 54 |
+
}
|
| 55 |
|
| 56 |
|
| 57 |
def _validate_tool_args(tool_args: dict) -> tuple[bool, str | None]:
|
|
|
|
| 167 |
class Handlers:
|
| 168 |
"""Handler functions for each operation type"""
|
| 169 |
|
| 170 |
+
@staticmethod
|
| 171 |
+
async def _abandon_pending_approval(session: Session) -> None:
|
| 172 |
+
"""Cancel pending approval tools when the user continues the conversation.
|
| 173 |
+
|
| 174 |
+
Injects rejection tool-result messages into the LLM context (so the
|
| 175 |
+
history stays valid) and notifies the frontend that those tools were
|
| 176 |
+
abandoned.
|
| 177 |
+
"""
|
| 178 |
+
tool_calls = session.pending_approval.get("tool_calls", [])
|
| 179 |
+
for tc in tool_calls:
|
| 180 |
+
tool_name = tc.function.name
|
| 181 |
+
abandon_msg = "Task abandoned — user continued the conversation without approving."
|
| 182 |
+
|
| 183 |
+
# Keep LLM context valid: every tool_call needs a tool result
|
| 184 |
+
tool_msg = Message(
|
| 185 |
+
role="tool",
|
| 186 |
+
content=abandon_msg,
|
| 187 |
+
tool_call_id=tc.id,
|
| 188 |
+
name=tool_name,
|
| 189 |
+
)
|
| 190 |
+
session.context_manager.add_message(tool_msg)
|
| 191 |
+
|
| 192 |
+
await session.send_event(
|
| 193 |
+
Event(
|
| 194 |
+
event_type="tool_state_change",
|
| 195 |
+
data={
|
| 196 |
+
"tool_call_id": tc.id,
|
| 197 |
+
"tool": tool_name,
|
| 198 |
+
"state": "abandoned",
|
| 199 |
+
},
|
| 200 |
+
)
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
session.pending_approval = None
|
| 204 |
+
logger.info("Abandoned %d pending approval tool(s)", len(tool_calls))
|
| 205 |
+
|
| 206 |
@staticmethod
|
| 207 |
@observe(name="run_agent")
|
| 208 |
async def run_agent(
|
|
|
|
| 218 |
|
| 219 |
Laminar.set_trace_session_id(session_id=session.session_id)
|
| 220 |
|
| 221 |
+
# If there's a pending approval and the user sent a new message,
|
| 222 |
+
# abandon the pending tools so the LLM context stays valid.
|
| 223 |
+
if text and session.pending_approval:
|
| 224 |
+
await Handlers._abandon_pending_approval(session)
|
| 225 |
+
|
| 226 |
# Add user message to history only if there's actual content
|
| 227 |
if text:
|
| 228 |
user_msg = Message(role="user", content=text)
|
|
|
|
| 243 |
|
| 244 |
messages = session.context_manager.get_messages()
|
| 245 |
tools = session.tool_router.get_tool_specs_for_llm()
|
|
|
|
| 246 |
try:
|
| 247 |
+
# ── Stream the LLM response ──────────────────────────
|
| 248 |
+
llm_params = _resolve_hf_router_params(session.config.model_name)
|
| 249 |
+
response = await acompletion(
|
| 250 |
messages=messages,
|
| 251 |
tools=tools,
|
| 252 |
tool_choice="auto",
|
| 253 |
+
stream=True,
|
| 254 |
+
stream_options={"include_usage": True},
|
| 255 |
+
**llm_params,
|
| 256 |
)
|
| 257 |
|
| 258 |
+
full_content = ""
|
| 259 |
+
tool_calls_acc: dict[int, dict] = {}
|
| 260 |
+
token_count = 0
|
| 261 |
+
|
| 262 |
+
async for chunk in response:
|
| 263 |
+
choice = chunk.choices[0] if chunk.choices else None
|
| 264 |
+
if not choice:
|
| 265 |
+
# Last chunk may carry only usage info
|
| 266 |
+
if hasattr(chunk, "usage") and chunk.usage:
|
| 267 |
+
token_count = chunk.usage.total_tokens
|
| 268 |
+
continue
|
| 269 |
+
|
| 270 |
+
delta = choice.delta
|
| 271 |
+
|
| 272 |
+
# Stream text deltas to the frontend
|
| 273 |
+
if delta.content:
|
| 274 |
+
full_content += delta.content
|
| 275 |
+
await session.send_event(
|
| 276 |
+
Event(
|
| 277 |
+
event_type="assistant_chunk",
|
| 278 |
+
data={"content": delta.content},
|
| 279 |
+
)
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
# Accumulate tool-call deltas (name + args arrive in pieces)
|
| 283 |
+
if delta.tool_calls:
|
| 284 |
+
for tc_delta in delta.tool_calls:
|
| 285 |
+
idx = tc_delta.index
|
| 286 |
+
if idx not in tool_calls_acc:
|
| 287 |
+
tool_calls_acc[idx] = {
|
| 288 |
+
"id": "",
|
| 289 |
+
"type": "function",
|
| 290 |
+
"function": {"name": "", "arguments": ""},
|
| 291 |
+
}
|
| 292 |
+
if tc_delta.id:
|
| 293 |
+
tool_calls_acc[idx]["id"] = tc_delta.id
|
| 294 |
+
if tc_delta.function:
|
| 295 |
+
if tc_delta.function.name:
|
| 296 |
+
tool_calls_acc[idx]["function"]["name"] += (
|
| 297 |
+
tc_delta.function.name
|
| 298 |
+
)
|
| 299 |
+
if tc_delta.function.arguments:
|
| 300 |
+
tool_calls_acc[idx]["function"]["arguments"] += (
|
| 301 |
+
tc_delta.function.arguments
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
# Capture usage from the final chunk
|
| 305 |
+
if hasattr(chunk, "usage") and chunk.usage:
|
| 306 |
+
token_count = chunk.usage.total_tokens
|
| 307 |
+
|
| 308 |
+
# ── Stream finished — reconstruct full message ───────
|
| 309 |
+
content = full_content or None
|
| 310 |
+
|
| 311 |
+
# Build tool_calls list from accumulated deltas
|
| 312 |
+
tool_calls: list[ToolCall] = []
|
| 313 |
+
for idx in sorted(tool_calls_acc.keys()):
|
| 314 |
+
tc_data = tool_calls_acc[idx]
|
| 315 |
+
tool_calls.append(
|
| 316 |
+
ToolCall(
|
| 317 |
+
id=tc_data["id"],
|
| 318 |
+
type="function",
|
| 319 |
+
function={
|
| 320 |
+
"name": tc_data["function"]["name"],
|
| 321 |
+
"arguments": tc_data["function"]["arguments"],
|
| 322 |
+
},
|
| 323 |
+
)
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
# Signal end of streaming to the frontend
|
| 327 |
+
await session.send_event(
|
| 328 |
+
Event(event_type="assistant_stream_end", data={})
|
| 329 |
+
)
|
| 330 |
|
| 331 |
# If no tool calls, add assistant message and we're done
|
| 332 |
if not tool_calls:
|
| 333 |
if content:
|
| 334 |
assistant_msg = Message(role="assistant", content=content)
|
| 335 |
session.context_manager.add_message(assistant_msg, token_count)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
final_response = content
|
| 337 |
break
|
| 338 |
|
| 339 |
# Add assistant message with tool calls to history
|
|
|
|
| 340 |
assistant_msg = Message(
|
| 341 |
role="assistant",
|
| 342 |
content=content,
|
|
|
|
| 344 |
)
|
| 345 |
session.context_manager.add_message(assistant_msg, token_count)
|
| 346 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
# Separate tools into those requiring approval and those that don't
|
| 348 |
approval_required_tools = []
|
| 349 |
non_approval_tools = []
|
| 350 |
|
| 351 |
for tc in tool_calls:
|
| 352 |
tool_name = tc.function.name
|
| 353 |
+
try:
|
| 354 |
+
tool_args = json.loads(tc.function.arguments)
|
| 355 |
+
except (json.JSONDecodeError, TypeError) as e:
|
| 356 |
+
logger.warning(f"Malformed tool arguments for {tool_name}: {e}")
|
| 357 |
+
tool_args = {}
|
| 358 |
|
| 359 |
if _needs_approval(tool_name, tool_args, session.config):
|
| 360 |
approval_required_tools.append(tc)
|
| 361 |
else:
|
| 362 |
non_approval_tools.append(tc)
|
| 363 |
+
# Execute non-approval tools (in parallel when possible)
|
| 364 |
+
if non_approval_tools:
|
| 365 |
+
# 1. Parse args and validate upfront
|
| 366 |
+
parsed_tools: list[
|
| 367 |
+
tuple[ChatCompletionMessageToolCall, str, dict, bool, str]
|
| 368 |
+
] = []
|
| 369 |
+
for tc in non_approval_tools:
|
| 370 |
+
tool_name = tc.function.name
|
| 371 |
+
try:
|
| 372 |
+
tool_args = json.loads(tc.function.arguments)
|
| 373 |
+
except (json.JSONDecodeError, TypeError):
|
| 374 |
+
tool_args = {}
|
| 375 |
+
|
| 376 |
+
args_valid, error_msg = _validate_tool_args(tool_args)
|
| 377 |
+
parsed_tools.append(
|
| 378 |
+
(tc, tool_name, tool_args, args_valid, error_msg)
|
| 379 |
+
)
|
| 380 |
|
| 381 |
+
# 2. Send all tool_call events upfront (so frontend shows them all)
|
| 382 |
+
for tc, tool_name, tool_args, args_valid, _ in parsed_tools:
|
| 383 |
+
if args_valid:
|
| 384 |
+
await session.send_event(
|
| 385 |
+
Event(
|
| 386 |
+
event_type="tool_call",
|
| 387 |
+
data={
|
| 388 |
+
"tool": tool_name,
|
| 389 |
+
"arguments": tool_args,
|
| 390 |
+
"tool_call_id": tc.id,
|
| 391 |
+
},
|
| 392 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
)
|
|
|
|
| 394 |
|
| 395 |
+
# 3. Execute all valid tools in parallel
|
| 396 |
+
async def _exec_tool(
|
| 397 |
+
tc: ChatCompletionMessageToolCall,
|
| 398 |
+
name: str,
|
| 399 |
+
args: dict,
|
| 400 |
+
valid: bool,
|
| 401 |
+
err: str,
|
| 402 |
+
) -> tuple[ChatCompletionMessageToolCall, str, dict, str, bool]:
|
| 403 |
+
if not valid:
|
| 404 |
+
return (tc, name, args, err, False)
|
| 405 |
+
out, ok = await session.tool_router.call_tool(
|
| 406 |
+
name, args, session=session
|
| 407 |
)
|
| 408 |
+
return (tc, name, args, out, ok)
|
| 409 |
|
| 410 |
+
results = await asyncio.gather(
|
| 411 |
+
*[
|
| 412 |
+
_exec_tool(tc, name, args, valid, err)
|
| 413 |
+
for tc, name, args, valid, err in parsed_tools
|
| 414 |
+
]
|
|
|
|
| 415 |
)
|
|
|
|
| 416 |
|
| 417 |
+
# 4. Record results and send outputs (order preserved)
|
| 418 |
+
for tc, tool_name, tool_args, output, success in results:
|
| 419 |
+
tool_msg = Message(
|
| 420 |
+
role="tool",
|
| 421 |
+
content=output,
|
| 422 |
+
tool_call_id=tc.id,
|
| 423 |
+
name=tool_name,
|
| 424 |
+
)
|
| 425 |
+
session.context_manager.add_message(tool_msg)
|
| 426 |
+
|
| 427 |
+
await session.send_event(
|
| 428 |
+
Event(
|
| 429 |
+
event_type="tool_output",
|
| 430 |
+
data={
|
| 431 |
+
"tool": tool_name,
|
| 432 |
+
"tool_call_id": tc.id,
|
| 433 |
+
"output": output,
|
| 434 |
+
"success": success,
|
| 435 |
+
},
|
| 436 |
+
)
|
| 437 |
)
|
|
|
|
| 438 |
|
| 439 |
# If there are tools requiring approval, ask for batch approval
|
| 440 |
if approval_required_tools:
|
|
|
|
| 442 |
tools_data = []
|
| 443 |
for tc in approval_required_tools:
|
| 444 |
tool_name = tc.function.name
|
| 445 |
+
try:
|
| 446 |
+
tool_args = json.loads(tc.function.arguments)
|
| 447 |
+
except (json.JSONDecodeError, TypeError):
|
| 448 |
+
tool_args = {}
|
| 449 |
tools_data.append(
|
| 450 |
{
|
| 451 |
"tool": tool_name,
|
|
|
|
| 514 |
|
| 515 |
@staticmethod
|
| 516 |
async def undo(session: Session) -> None:
|
| 517 |
+
"""Remove the last complete turn (user msg + all assistant/tool msgs that follow).
|
| 518 |
+
|
| 519 |
+
Anthropic requires every tool_use to have a matching tool_result,
|
| 520 |
+
so we can't just pop 2 items — we must pop everything back to
|
| 521 |
+
(and including) the last user message to keep the history valid.
|
| 522 |
+
"""
|
| 523 |
+
items = session.context_manager.items
|
| 524 |
+
if not items:
|
| 525 |
+
await session.send_event(Event(event_type="undo_complete"))
|
| 526 |
+
return
|
| 527 |
+
|
| 528 |
+
# Pop from the end until we've removed the last user message
|
| 529 |
+
removed_user = False
|
| 530 |
+
while items:
|
| 531 |
+
msg = items.pop()
|
| 532 |
+
if getattr(msg, "role", None) == "user":
|
| 533 |
+
removed_user = True
|
| 534 |
+
break
|
| 535 |
+
|
| 536 |
+
if not removed_user:
|
| 537 |
+
logger.warning("Undo: no user message found to remove")
|
| 538 |
|
| 539 |
await session.send_event(Event(event_type="undo_complete"))
|
| 540 |
|
|
|
|
| 562 |
|
| 563 |
# Create a map of tool_call_id -> approval decision
|
| 564 |
approval_map = {a["tool_call_id"]: a for a in approvals}
|
| 565 |
+
for a in approvals:
|
| 566 |
+
if a.get("edited_script"):
|
| 567 |
+
logger.info(f"Received edited script for tool_call {a['tool_call_id']} ({len(a['edited_script'])} chars)")
|
| 568 |
|
| 569 |
# Separate approved and rejected tool calls
|
| 570 |
approved_tasks = []
|
|
|
|
| 572 |
|
| 573 |
for tc in tool_calls:
|
| 574 |
tool_name = tc.function.name
|
| 575 |
+
try:
|
| 576 |
+
tool_args = json.loads(tc.function.arguments)
|
| 577 |
+
except (json.JSONDecodeError, TypeError) as e:
|
| 578 |
+
# Malformed arguments — treat as failed, notify agent
|
| 579 |
+
logger.warning(f"Malformed tool arguments for {tool_name}: {e}")
|
| 580 |
+
tool_msg = Message(
|
| 581 |
+
role="tool",
|
| 582 |
+
content=f"Malformed arguments: {e}",
|
| 583 |
+
tool_call_id=tc.id,
|
| 584 |
+
name=tool_name,
|
| 585 |
+
)
|
| 586 |
+
session.context_manager.add_message(tool_msg)
|
| 587 |
+
await session.send_event(
|
| 588 |
+
Event(
|
| 589 |
+
event_type="tool_output",
|
| 590 |
+
data={
|
| 591 |
+
"tool": tool_name,
|
| 592 |
+
"tool_call_id": tc.id,
|
| 593 |
+
"output": f"Malformed arguments: {e}",
|
| 594 |
+
"success": False,
|
| 595 |
+
},
|
| 596 |
+
)
|
| 597 |
+
)
|
| 598 |
+
continue
|
| 599 |
+
|
| 600 |
approval_decision = approval_map.get(tc.id, {"approved": False})
|
| 601 |
|
| 602 |
if approval_decision.get("approved", False):
|
| 603 |
+
edited_script = approval_decision.get("edited_script")
|
| 604 |
+
was_edited = False
|
| 605 |
+
if edited_script and "script" in tool_args:
|
| 606 |
+
tool_args["script"] = edited_script
|
| 607 |
+
was_edited = True
|
| 608 |
+
logger.info(f"Using user-edited script for {tool_name} ({tc.id})")
|
| 609 |
+
approved_tasks.append((tc, tool_name, tool_args, was_edited))
|
| 610 |
else:
|
| 611 |
rejected_tasks.append((tc, tool_name, approval_decision))
|
| 612 |
|
| 613 |
+
# Notify frontend of approval decisions immediately (before execution)
|
| 614 |
+
for tc, tool_name, tool_args, _was_edited in approved_tasks:
|
| 615 |
+
await session.send_event(
|
| 616 |
+
Event(
|
| 617 |
+
event_type="tool_state_change",
|
| 618 |
+
data={
|
| 619 |
+
"tool_call_id": tc.id,
|
| 620 |
+
"tool": tool_name,
|
| 621 |
+
"state": "approved",
|
| 622 |
+
},
|
| 623 |
+
)
|
| 624 |
+
)
|
| 625 |
+
for tc, tool_name, approval_decision in rejected_tasks:
|
| 626 |
+
await session.send_event(
|
| 627 |
+
Event(
|
| 628 |
+
event_type="tool_state_change",
|
| 629 |
+
data={
|
| 630 |
+
"tool_call_id": tc.id,
|
| 631 |
+
"tool": tool_name,
|
| 632 |
+
"state": "rejected",
|
| 633 |
+
},
|
| 634 |
+
)
|
| 635 |
+
)
|
| 636 |
+
|
| 637 |
# Execute all approved tools concurrently
|
| 638 |
+
async def execute_tool(tc, tool_name, tool_args, was_edited):
|
| 639 |
+
"""Execute a single tool and return its result.
|
| 640 |
+
|
| 641 |
+
The TraceLog already exists on the frontend (created by
|
| 642 |
+
approval_required), so we send tool_state_change instead of
|
| 643 |
+
tool_call to avoid creating a duplicate.
|
| 644 |
+
"""
|
| 645 |
await session.send_event(
|
| 646 |
Event(
|
| 647 |
+
event_type="tool_state_change",
|
| 648 |
+
data={
|
| 649 |
+
"tool_call_id": tc.id,
|
| 650 |
+
"tool": tool_name,
|
| 651 |
+
"state": "running",
|
| 652 |
+
},
|
| 653 |
)
|
| 654 |
)
|
| 655 |
|
| 656 |
output, success = await session.tool_router.call_tool(
|
| 657 |
+
tool_name, tool_args, session=session, tool_call_id=tc.id
|
| 658 |
)
|
| 659 |
|
| 660 |
+
return (tc, tool_name, output, success, was_edited)
|
| 661 |
|
| 662 |
# Execute all approved tools concurrently and wait for ALL to complete
|
| 663 |
if approved_tasks:
|
| 664 |
results = await asyncio.gather(
|
| 665 |
*[
|
| 666 |
+
execute_tool(tc, tool_name, tool_args, was_edited)
|
| 667 |
+
for tc, tool_name, tool_args, was_edited in approved_tasks
|
| 668 |
],
|
| 669 |
return_exceptions=True,
|
| 670 |
)
|
|
|
|
| 673 |
for result in results:
|
| 674 |
if isinstance(result, Exception):
|
| 675 |
# Handle execution error
|
| 676 |
+
logger.error(f"Tool execution error: {result}")
|
| 677 |
continue
|
| 678 |
|
| 679 |
+
tc, tool_name, output, success, was_edited = result
|
| 680 |
+
|
| 681 |
+
if was_edited:
|
| 682 |
+
output = f"[Note: The user edited the script before execution. The output below reflects the user-modified version, not your original script.]\n\n{output}"
|
| 683 |
|
| 684 |
# Add tool result to context
|
| 685 |
tool_msg = Message(
|
|
|
|
| 695 |
event_type="tool_output",
|
| 696 |
data={
|
| 697 |
"tool": tool_name,
|
| 698 |
+
"tool_call_id": tc.id,
|
| 699 |
"output": output,
|
| 700 |
"success": success,
|
| 701 |
},
|
|
|
|
| 707 |
rejection_msg = "Job execution cancelled by user"
|
| 708 |
user_feedback = approval_decision.get("feedback")
|
| 709 |
if user_feedback:
|
| 710 |
+
# Ensure feedback is a string and sanitize any problematic characters
|
| 711 |
+
feedback_str = str(user_feedback).strip()
|
| 712 |
+
# Remove any control characters that might break JSON parsing
|
| 713 |
+
feedback_str = "".join(char for char in feedback_str if ord(char) >= 32 or char in "\n\t")
|
| 714 |
+
rejection_msg += f". User feedback: {feedback_str}"
|
| 715 |
+
|
| 716 |
+
# Ensure rejection_msg is a clean string
|
| 717 |
+
rejection_msg = str(rejection_msg).strip()
|
| 718 |
|
| 719 |
tool_msg = Message(
|
| 720 |
role="tool",
|
|
|
|
| 729 |
event_type="tool_output",
|
| 730 |
data={
|
| 731 |
"tool": tool_name,
|
| 732 |
+
"tool_call_id": tc.id,
|
| 733 |
"output": rejection_msg,
|
| 734 |
"success": False,
|
| 735 |
},
|
|
|
|
| 747 |
"""Handle shutdown (like shutdown in codex.rs:1329)"""
|
| 748 |
# Save session trajectory if enabled (fire-and-forget, returns immediately)
|
| 749 |
if session.config.save_sessions:
|
| 750 |
+
logger.info("Saving session...")
|
| 751 |
repo_id = session.config.session_dataset_repo
|
| 752 |
_ = session.save_and_upload_detached(repo_id)
|
|
|
|
|
|
|
| 753 |
|
| 754 |
session.is_running = False
|
| 755 |
await session.send_event(Event(event_type="shutdown"))
|
|
|
|
| 764 |
bool: True to continue, False to shutdown
|
| 765 |
"""
|
| 766 |
op = submission.operation
|
| 767 |
+
logger.debug("Received operation: %s", op.op_type.value)
|
| 768 |
|
| 769 |
if op.op_type == OpType.USER_INPUT:
|
| 770 |
text = op.data.get("text", "") if op.data else ""
|
|
|
|
| 776 |
return True
|
| 777 |
|
| 778 |
if op.op_type == OpType.COMPACT:
|
|
|
|
| 779 |
await _compact_and_notify(session)
|
| 780 |
return True
|
| 781 |
|
|
|
|
| 791 |
if op.op_type == OpType.SHUTDOWN:
|
| 792 |
return not await Handlers.shutdown(session)
|
| 793 |
|
| 794 |
+
logger.warning(f"Unknown operation: {op.op_type}")
|
| 795 |
return True
|
| 796 |
|
| 797 |
|
|
|
|
| 809 |
|
| 810 |
# Create session with tool router
|
| 811 |
session = Session(event_queue, config=config, tool_router=tool_router)
|
| 812 |
+
logger.info("Agent loop started")
|
| 813 |
|
| 814 |
# Retry any failed uploads from previous sessions (fire-and-forget)
|
| 815 |
if config and config.save_sessions:
|
|
|
|
| 833 |
if not should_continue:
|
| 834 |
break
|
| 835 |
except asyncio.CancelledError:
|
| 836 |
+
logger.warning("Agent loop cancelled")
|
| 837 |
break
|
| 838 |
except Exception as e:
|
| 839 |
+
logger.error(f"Error in agent loop: {e}")
|
| 840 |
await session.send_event(
|
| 841 |
Event(event_type="error", data={"error": str(e)})
|
| 842 |
)
|
| 843 |
|
| 844 |
+
logger.info("Agent loop exited")
|
| 845 |
|
| 846 |
finally:
|
| 847 |
# Emergency save if session saving is enabled and shutdown wasn't called properly
|
| 848 |
if session.config.save_sessions and session.is_running:
|
| 849 |
+
logger.info("Emergency save: preserving session before exit...")
|
| 850 |
try:
|
| 851 |
local_path = session.save_and_upload_detached(
|
| 852 |
session.config.session_dataset_repo
|
| 853 |
)
|
| 854 |
if local_path:
|
| 855 |
+
logger.info("Emergency save successful, upload in progress")
|
| 856 |
except Exception as e:
|
| 857 |
+
logger.error(f"Emergency save failed: {e}")
|
agent/core/session.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import asyncio
|
| 2 |
import json
|
|
|
|
| 3 |
import subprocess
|
| 4 |
import sys
|
| 5 |
import uuid
|
|
@@ -9,11 +10,48 @@ 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 +84,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,
|
|
@@ -59,6 +97,8 @@ class Session:
|
|
| 59 |
self.is_running = True
|
| 60 |
self.current_task: asyncio.Task | None = None
|
| 61 |
self.pending_approval: Optional[dict[str, Any]] = None
|
|
|
|
|
|
|
| 62 |
self.sandbox = None
|
| 63 |
|
| 64 |
# Session trajectory logging
|
|
@@ -100,7 +140,7 @@ class Session:
|
|
| 100 |
|
| 101 |
turns_since_last_save = self.turn_count - self.last_auto_save_turn
|
| 102 |
if turns_since_last_save >= interval:
|
| 103 |
-
|
| 104 |
# Fire-and-forget save - returns immediately
|
| 105 |
self.save_and_upload_detached(self.config.session_dataset_repo)
|
| 106 |
self.last_auto_save_turn = self.turn_count
|
|
@@ -152,7 +192,7 @@ class Session:
|
|
| 152 |
|
| 153 |
return str(filepath)
|
| 154 |
except Exception as e:
|
| 155 |
-
|
| 156 |
return None
|
| 157 |
|
| 158 |
def update_local_save_status(
|
|
@@ -172,7 +212,7 @@ class Session:
|
|
| 172 |
|
| 173 |
return True
|
| 174 |
except Exception as e:
|
| 175 |
-
|
| 176 |
return False
|
| 177 |
|
| 178 |
def save_and_upload_detached(self, repo_id: str) -> Optional[str]:
|
|
@@ -203,7 +243,7 @@ class Session:
|
|
| 203 |
start_new_session=True, # Detach from parent
|
| 204 |
)
|
| 205 |
except Exception as e:
|
| 206 |
-
|
| 207 |
|
| 208 |
return local_path
|
| 209 |
|
|
@@ -233,4 +273,4 @@ class Session:
|
|
| 233 |
start_new_session=True, # Detach from parent
|
| 234 |
)
|
| 235 |
except Exception as e:
|
| 236 |
-
|
|
|
|
| 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
|
| 22 |
+
"anthropic/claude-opus-4-5-20251101": 200_000,
|
| 23 |
+
"anthropic/claude-sonnet-4-5-20250929": 200_000,
|
| 24 |
+
"anthropic/claude-sonnet-4-20250514": 200_000,
|
| 25 |
+
"anthropic/claude-haiku-3-5-20241022": 200_000,
|
| 26 |
+
"anthropic/claude-3-5-sonnet-20241022": 200_000,
|
| 27 |
+
"anthropic/claude-3-opus-20240229": 200_000,
|
| 28 |
+
"huggingface/novita/minimax/minimax-m2.1": 196_608,
|
| 29 |
+
"huggingface/novita/moonshotai/kimi-k2.5": 262_144,
|
| 30 |
+
"huggingface/novita/zai-org/glm-5": 200_000,
|
| 31 |
+
}
|
| 32 |
+
_DEFAULT_MAX_TOKENS = 200_000
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _get_max_tokens_safe(model_name: str) -> int:
|
| 36 |
+
"""Return the max context window for a model without network calls."""
|
| 37 |
+
tokens = _MAX_TOKENS_MAP.get(model_name)
|
| 38 |
+
if tokens:
|
| 39 |
+
return tokens
|
| 40 |
+
# Fallback: try litellm but with a short timeout via threading
|
| 41 |
+
try:
|
| 42 |
+
from litellm import get_max_tokens
|
| 43 |
+
|
| 44 |
+
result = get_max_tokens(model_name)
|
| 45 |
+
if result and isinstance(result, int):
|
| 46 |
+
return result
|
| 47 |
+
logger.warning(
|
| 48 |
+
f"get_max_tokens returned {result} for {model_name}, using default"
|
| 49 |
+
)
|
| 50 |
+
return _DEFAULT_MAX_TOKENS
|
| 51 |
+
except Exception as e:
|
| 52 |
+
logger.warning(f"get_max_tokens failed for {model_name}, using default: {e}")
|
| 53 |
+
return _DEFAULT_MAX_TOKENS
|
| 54 |
+
|
| 55 |
|
| 56 |
class OpType(Enum):
|
| 57 |
USER_INPUT = "user_input"
|
|
|
|
| 84 |
self.tool_router = tool_router
|
| 85 |
tool_specs = tool_router.get_tool_specs_for_llm() if tool_router else []
|
| 86 |
self.context_manager = context_manager or ContextManager(
|
| 87 |
+
max_context=_get_max_tokens_safe(config.model_name),
|
| 88 |
compact_size=0.1,
|
| 89 |
untouched_messages=5,
|
| 90 |
tool_specs=tool_specs,
|
|
|
|
| 97 |
self.is_running = True
|
| 98 |
self.current_task: asyncio.Task | None = None
|
| 99 |
self.pending_approval: Optional[dict[str, Any]] = None
|
| 100 |
+
# User's HF OAuth token — set by session_manager after construction
|
| 101 |
+
self.hf_token: Optional[str] = None
|
| 102 |
self.sandbox = None
|
| 103 |
|
| 104 |
# Session trajectory logging
|
|
|
|
| 140 |
|
| 141 |
turns_since_last_save = self.turn_count - self.last_auto_save_turn
|
| 142 |
if turns_since_last_save >= interval:
|
| 143 |
+
logger.info(f"Auto-saving session (turn {self.turn_count})...")
|
| 144 |
# Fire-and-forget save - returns immediately
|
| 145 |
self.save_and_upload_detached(self.config.session_dataset_repo)
|
| 146 |
self.last_auto_save_turn = self.turn_count
|
|
|
|
| 192 |
|
| 193 |
return str(filepath)
|
| 194 |
except Exception as e:
|
| 195 |
+
logger.error(f"Failed to save session locally: {e}")
|
| 196 |
return None
|
| 197 |
|
| 198 |
def update_local_save_status(
|
|
|
|
| 212 |
|
| 213 |
return True
|
| 214 |
except Exception as e:
|
| 215 |
+
logger.error(f"Failed to update local save status: {e}")
|
| 216 |
return False
|
| 217 |
|
| 218 |
def save_and_upload_detached(self, repo_id: str) -> Optional[str]:
|
|
|
|
| 243 |
start_new_session=True, # Detach from parent
|
| 244 |
)
|
| 245 |
except Exception as e:
|
| 246 |
+
logger.warning(f"Failed to spawn upload subprocess: {e}")
|
| 247 |
|
| 248 |
return local_path
|
| 249 |
|
|
|
|
| 273 |
start_new_session=True, # Detach from parent
|
| 274 |
)
|
| 275 |
except Exception as e:
|
| 276 |
+
logger.warning(f"Failed to spawn retry subprocess: {e}")
|
agent/core/session_uploader.py
CHANGED
|
@@ -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(
|
agent/core/tools.py
CHANGED
|
@@ -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
|
|
@@ -132,6 +135,7 @@ class ToolRouter:
|
|
| 132 |
for tool in create_builtin_tools():
|
| 133 |
self.register_tool(tool)
|
| 134 |
|
|
|
|
| 135 |
if mcp_servers:
|
| 136 |
mcp_servers_payload = {}
|
| 137 |
for name, server in mcp_servers.items():
|
|
@@ -159,7 +163,7 @@ class ToolRouter:
|
|
| 159 |
handler=None,
|
| 160 |
)
|
| 161 |
)
|
| 162 |
-
|
| 163 |
f"Loaded {len(registered_names)} MCP tools: {', '.join(registered_names)} ({skipped_count} disabled)"
|
| 164 |
)
|
| 165 |
|
|
@@ -180,7 +184,7 @@ class ToolRouter:
|
|
| 180 |
handler=search_openapi_handler,
|
| 181 |
)
|
| 182 |
)
|
| 183 |
-
|
| 184 |
|
| 185 |
def get_tool_specs_for_llm(self) -> list[dict[str, Any]]:
|
| 186 |
"""Get tool specifications in OpenAI format"""
|
|
@@ -209,7 +213,7 @@ class ToolRouter:
|
|
| 209 |
await self.register_openapi_tool()
|
| 210 |
|
| 211 |
total_tools = len(self.tools)
|
| 212 |
-
|
| 213 |
|
| 214 |
return self
|
| 215 |
|
|
@@ -220,7 +224,7 @@ class ToolRouter:
|
|
| 220 |
|
| 221 |
@observe(name="call_tool")
|
| 222 |
async def call_tool(
|
| 223 |
-
self, tool_name: str, arguments: dict[str, Any], session: Any = None
|
| 224 |
) -> tuple[str, bool]:
|
| 225 |
"""
|
| 226 |
Call a tool and return (output_string, success_bool).
|
|
@@ -236,6 +240,9 @@ class ToolRouter:
|
|
| 236 |
# Check if handler accepts session argument
|
| 237 |
sig = inspect.signature(tool.handler)
|
| 238 |
if "session" in sig.parameters:
|
|
|
|
|
|
|
|
|
|
| 239 |
return await tool.handler(arguments, session=session)
|
| 240 |
return await tool.handler(arguments)
|
| 241 |
|
|
@@ -328,10 +335,10 @@ def create_builtin_tools() -> list[ToolSpec]:
|
|
| 328 |
),
|
| 329 |
]
|
| 330 |
|
| 331 |
-
# Sandbox tools
|
| 332 |
tools = get_sandbox_tools() + tools
|
| 333 |
|
| 334 |
tool_names = ", ".join([t.name for t in tools])
|
| 335 |
-
|
| 336 |
|
| 337 |
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
|
|
|
|
| 135 |
for tool in create_builtin_tools():
|
| 136 |
self.register_tool(tool)
|
| 137 |
|
| 138 |
+
self.mcp_client: Client | None = None
|
| 139 |
if mcp_servers:
|
| 140 |
mcp_servers_payload = {}
|
| 141 |
for name, server in mcp_servers.items():
|
|
|
|
| 163 |
handler=None,
|
| 164 |
)
|
| 165 |
)
|
| 166 |
+
logger.info(
|
| 167 |
f"Loaded {len(registered_names)} MCP tools: {', '.join(registered_names)} ({skipped_count} disabled)"
|
| 168 |
)
|
| 169 |
|
|
|
|
| 184 |
handler=search_openapi_handler,
|
| 185 |
)
|
| 186 |
)
|
| 187 |
+
logger.info(f"Loaded OpenAPI search tool: {openapi_spec['name']}")
|
| 188 |
|
| 189 |
def get_tool_specs_for_llm(self) -> list[dict[str, Any]]:
|
| 190 |
"""Get tool specifications in OpenAI format"""
|
|
|
|
| 213 |
await self.register_openapi_tool()
|
| 214 |
|
| 215 |
total_tools = len(self.tools)
|
| 216 |
+
logger.info(f"Agent ready with {total_tools} tools total")
|
| 217 |
|
| 218 |
return self
|
| 219 |
|
|
|
|
| 224 |
|
| 225 |
@observe(name="call_tool")
|
| 226 |
async def call_tool(
|
| 227 |
+
self, tool_name: str, arguments: dict[str, Any], session: Any = None, tool_call_id: str | None = None
|
| 228 |
) -> tuple[str, bool]:
|
| 229 |
"""
|
| 230 |
Call a tool and return (output_string, success_bool).
|
|
|
|
| 240 |
# Check if handler accepts session argument
|
| 241 |
sig = inspect.signature(tool.handler)
|
| 242 |
if "session" in sig.parameters:
|
| 243 |
+
# Check if handler also accepts tool_call_id parameter
|
| 244 |
+
if "tool_call_id" in sig.parameters:
|
| 245 |
+
return await tool.handler(arguments, session=session, tool_call_id=tool_call_id)
|
| 246 |
return await tool.handler(arguments, session=session)
|
| 247 |
return await tool.handler(arguments)
|
| 248 |
|
|
|
|
| 335 |
),
|
| 336 |
]
|
| 337 |
|
| 338 |
+
# Sandbox tools (highest priority)
|
| 339 |
tools = get_sandbox_tools() + tools
|
| 340 |
|
| 341 |
tool_names = ", ".join([t.name for t in tools])
|
| 342 |
+
logger.info(f"Loaded {len(tools)} built-in tools: {tool_names}")
|
| 343 |
|
| 344 |
return tools
|
agent/prompts/system_prompt.yaml
CHANGED
|
@@ -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").
|
agent/prompts/system_prompt_v2.yaml
CHANGED
|
@@ -186,59 +186,61 @@ system_prompt: |
|
|
| 186 |
3. ✅ Determine optimal processing approach based on requirements
|
| 187 |
4. ✅ Plan output format and destination
|
| 188 |
|
| 189 |
-
## PHASE 3: IMPLEMENT (
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
**
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
Do NOT write long inline scripts directly in hf_jobs if necessary — develop in sandbox first.
|
| 211 |
-
|
| 212 |
-
### Training Script Requirements
|
| 213 |
-
|
| 214 |
-
**Script MUST Include:**
|
| 215 |
-
- Imports from researched documentation (current APIs)
|
| 216 |
-
- Trackio initialization with project/run_name/config
|
| 217 |
-
- Model and tokenizer loading
|
| 218 |
-
- Dataset loading with verified columns and conversational format
|
| 219 |
-
- Training config with ALL critical settings:
|
| 220 |
- `push_to_hub=True` ⚠️ MANDATORY
|
| 221 |
- `hub_model_id="username/model-name"` ⚠️ MANDATORY
|
| 222 |
- `report_to=["trackio"]` (for monitoring)
|
| 223 |
- `output_dir="./output"`
|
| 224 |
- `num_train_epochs`, `per_device_train_batch_size`, `learning_rate`
|
| 225 |
- `logging_steps`, `save_steps`
|
| 226 |
-
|
| 227 |
-
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
|
| 239 |
### For Data Processing Tasks
|
| 240 |
|
| 241 |
-
**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
- Use `cpu-upgrade` or `cpu-performance` for most data tasks
|
| 243 |
- Set timeout based on dataset size (1-4 hours typical)
|
| 244 |
|
|
@@ -339,21 +341,6 @@ system_prompt: |
|
|
| 339 |
- ⚠️ Include HF_TOKEN for Hub operations
|
| 340 |
- ⚠️ Storage is EPHEMERAL - must push_to_hub
|
| 341 |
|
| 342 |
-
## Sandbox (Interactive Development Environment)
|
| 343 |
-
|
| 344 |
-
**sandbox_create:**
|
| 345 |
-
- ⚠️ **Create a sandbox FIRST for any implementation task** — develop and test before launching jobs
|
| 346 |
-
- Persistent remote Linux environment on HF Spaces
|
| 347 |
-
- First call sandbox_create with hardware choice, then use bash/read/write/edit freely
|
| 348 |
-
- Hardware: cpu-basic (free tier), cpu-upgrade (8vCPU/32GB), t4-small (16GB GPU), a10g-small (24GB GPU), a10g-large (24GB GPU + 46GB RAM), a100-large (80GB GPU)
|
| 349 |
-
- `pip install` works out of the box — no special flags needed
|
| 350 |
-
- Workflow: sandbox_create → write script → test → fix → hf_jobs(script="/app/script.py") to launch at scale
|
| 351 |
-
|
| 352 |
-
**bash / read / write / edit:**
|
| 353 |
-
- Available after sandbox_create — no additional approvals needed
|
| 354 |
-
- Same semantics as local file/shell operations, but run on the remote sandbox
|
| 355 |
-
- bash: run shell commands; read/write/edit: file operations
|
| 356 |
-
|
| 357 |
**hf_private_repos:**
|
| 358 |
- Store job outputs persistently in datasets with push_to_hub (jobs lose files after completion)
|
| 359 |
- Upload logs, scripts, results that can't push_to_hub
|
|
|
|
| 186 |
3. ✅ Determine optimal processing approach based on requirements
|
| 187 |
4. ✅ Plan output format and destination
|
| 188 |
|
| 189 |
+
## PHASE 3: IMPLEMENT (Execute with Researched Approaches)
|
| 190 |
+
|
| 191 |
+
### For Training Tasks
|
| 192 |
+
|
| 193 |
+
⚠️ **TRAINING REQUIREMENTS CHECKLIST:**
|
| 194 |
+
|
| 195 |
+
**Before Submission:**
|
| 196 |
+
- [ ] Researched current TRL documentation
|
| 197 |
+
- [ ] Found and verified base model
|
| 198 |
+
- [ ] Found dataset and VALIDATED columns and conversational format matches method
|
| 199 |
+
- [ ] Selected optimal model + dataset + hardware configuration
|
| 200 |
+
- [ ] Created plan with plan_tool
|
| 201 |
+
- [ ] Researched Trackio monitoring setup
|
| 202 |
+
|
| 203 |
+
**Training Script MUST Include:**
|
| 204 |
+
- [ ] Imports from researched documentation (current APIs)
|
| 205 |
+
- [ ] Trackio initialization with project/run_name/config
|
| 206 |
+
- [ ] Model and tokenizer loading
|
| 207 |
+
- [ ] Dataset loading with verified columns and conversational format
|
| 208 |
+
- [ ] Training config with ALL critical settings:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
- `push_to_hub=True` ⚠️ MANDATORY
|
| 210 |
- `hub_model_id="username/model-name"` ⚠️ MANDATORY
|
| 211 |
- `report_to=["trackio"]` (for monitoring)
|
| 212 |
- `output_dir="./output"`
|
| 213 |
- `num_train_epochs`, `per_device_train_batch_size`, `learning_rate`
|
| 214 |
- `logging_steps`, `save_steps`
|
| 215 |
+
- `max_length` if needed (default 1024 usually fine)
|
| 216 |
+
- [ ] Trainer initialization with model, args, dataset, tokenizer
|
| 217 |
+
- [ ] `trainer.train()` call
|
| 218 |
+
- [ ] `trainer.push_to_hub()` at end ⚠️ MANDATORY
|
| 219 |
+
- [ ] `tracker.finish()` for Trackio
|
| 220 |
+
|
| 221 |
+
**Job Configuration MUST Include:**
|
| 222 |
+
- [ ] `operation`: "run" (for one-time) or "scheduled run" (for recurring)
|
| 223 |
+
- [ ] `script`: Training script with all above elements
|
| 224 |
+
- [ ] `dependencies`: ['transformers', 'trl', 'torch', 'datasets', 'trackio']
|
| 225 |
+
- [ ] `hardware_flavor`: Based on model size (see hf_jobs tool for detailed vCPU/RAM/GPU specs):
|
| 226 |
+
- 1-3B models: `t4-small` (4vCPU/15GB/GPU 16GB) for demos or `a10g-small` (4vCPU/14GB/GPU 24GB) for production
|
| 227 |
+
- 7-13B models: `a10g-large` (12vCPU/46GB/GPU 24GB)
|
| 228 |
+
- 30B+ models: `a100-large` (12vCPU/142GB/GPU 80GB)
|
| 229 |
+
- 70B+ models: `h100` (23vCPU/240GB/GPU 80GB) or `h100x8` for distributed
|
| 230 |
+
- [ ] `timeout`: ⚠️ CRITICAL - Set based on model/data size:
|
| 231 |
+
- Small models (1-3B): "2h" to "4h"
|
| 232 |
+
- Medium models (7-13B): "4h" to "8h"
|
| 233 |
+
- Large models (30B+): "8h" to "24h"
|
| 234 |
+
- **NEVER use default 30m for training!**
|
| 235 |
|
| 236 |
### For Data Processing Tasks
|
| 237 |
|
| 238 |
+
**Script Requirements:**
|
| 239 |
+
- Load dataset with `load_dataset`
|
| 240 |
+
- Process according to user requirements
|
| 241 |
+
- Push results with `push_to_hub()` or upload to `hf_private_repos`
|
| 242 |
+
|
| 243 |
+
**Job Configuration:**
|
| 244 |
- Use `cpu-upgrade` or `cpu-performance` for most data tasks
|
| 245 |
- Set timeout based on dataset size (1-4 hours typical)
|
| 246 |
|
|
|
|
| 341 |
- ⚠️ Include HF_TOKEN for Hub operations
|
| 342 |
- ⚠️ Storage is EPHEMERAL - must push_to_hub
|
| 343 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
**hf_private_repos:**
|
| 345 |
- Store job outputs persistently in datasets with push_to_hub (jobs lose files after completion)
|
| 346 |
- Upload logs, scripts, results that can't push_to_hub
|
agent/tools/jobs_tool.py
CHANGED
|
@@ -9,7 +9,9 @@ import base64
|
|
| 9 |
import http.client
|
| 10 |
import os
|
| 11 |
import re
|
| 12 |
-
from typing import Any,
|
|
|
|
|
|
|
| 13 |
|
| 14 |
import httpx
|
| 15 |
from huggingface_hub import HfApi
|
|
@@ -17,6 +19,8 @@ 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,
|
|
@@ -128,8 +132,11 @@ def _add_default_env(params: Dict[str, Any] | None) -> Dict[str, Any]:
|
|
| 128 |
return result
|
| 129 |
|
| 130 |
|
| 131 |
-
def _add_environment_variables(
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
# Start with user-provided env vars, then force-set token last
|
| 135 |
result = dict(params or {})
|
|
@@ -285,10 +292,15 @@ class HfJobsTool:
|
|
| 285 |
hf_token: Optional[str] = None,
|
| 286 |
namespace: Optional[str] = None,
|
| 287 |
log_callback: Optional[Callable[[str], Awaitable[None]]] = None,
|
|
|
|
|
|
|
| 288 |
):
|
|
|
|
| 289 |
self.api = HfApi(token=hf_token)
|
| 290 |
self.namespace = namespace
|
| 291 |
self.log_callback = log_callback
|
|
|
|
|
|
|
| 292 |
|
| 293 |
async def execute(self, params: Dict[str, Any]) -> ToolResult:
|
| 294 |
"""Execute the specified operation"""
|
|
@@ -384,9 +396,7 @@ class HfJobsTool:
|
|
| 384 |
def log_producer():
|
| 385 |
try:
|
| 386 |
# fetch_job_logs is a blocking sync generator
|
| 387 |
-
logs_gen = self.api.fetch_job_logs(
|
| 388 |
-
job_id=job_id, namespace=namespace
|
| 389 |
-
)
|
| 390 |
for line in logs_gen:
|
| 391 |
# Push line to queue thread-safely
|
| 392 |
loop.call_soon_threadsafe(queue.put_nowait, line)
|
|
@@ -413,7 +423,7 @@ class HfJobsTool:
|
|
| 413 |
|
| 414 |
# Process log line
|
| 415 |
log_line = item
|
| 416 |
-
|
| 417 |
if self.log_callback:
|
| 418 |
await self.log_callback(log_line)
|
| 419 |
all_logs.append(log_line)
|
|
@@ -441,19 +451,19 @@ class HfJobsTool:
|
|
| 441 |
|
| 442 |
if current_status in terminal_states:
|
| 443 |
# Job finished, no need to retry
|
| 444 |
-
|
| 445 |
break
|
| 446 |
|
| 447 |
# Job still running, retry connection
|
| 448 |
-
|
| 449 |
-
f"
|
| 450 |
)
|
| 451 |
await asyncio.sleep(retry_delay)
|
| 452 |
continue
|
| 453 |
|
| 454 |
except (ConnectionError, TimeoutError, OSError):
|
| 455 |
# Can't even check job status, wait and retry
|
| 456 |
-
|
| 457 |
await asyncio.sleep(retry_delay)
|
| 458 |
continue
|
| 459 |
|
|
@@ -510,15 +520,29 @@ class HfJobsTool:
|
|
| 510 |
image=image,
|
| 511 |
command=command,
|
| 512 |
env=_add_default_env(args.get("env")),
|
| 513 |
-
secrets=_add_environment_variables(args.get("secrets")),
|
| 514 |
flavor=args.get("hardware_flavor", "cpu-basic"),
|
| 515 |
timeout=args.get("timeout", "30m"),
|
| 516 |
namespace=self.namespace,
|
| 517 |
)
|
| 518 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 519 |
# Wait for completion and stream logs
|
| 520 |
-
|
| 521 |
-
|
| 522 |
|
| 523 |
final_status, all_logs = await self._wait_for_job_completion(
|
| 524 |
job_id=job.id,
|
|
@@ -728,7 +752,7 @@ To verify, call this tool with `{{"operation": "inspect", "job_id": "{job_id}"}}
|
|
| 728 |
command=command,
|
| 729 |
schedule=schedule,
|
| 730 |
env=_add_default_env(args.get("env")),
|
| 731 |
-
secrets=_add_environment_variables(args.get("secrets")),
|
| 732 |
flavor=args.get("hardware_flavor", "cpu-basic"),
|
| 733 |
timeout=args.get("timeout", "30m"),
|
| 734 |
namespace=self.namespace,
|
|
@@ -998,7 +1022,7 @@ HF_JOBS_TOOL_SPEC = {
|
|
| 998 |
|
| 999 |
|
| 1000 |
async def hf_jobs_handler(
|
| 1001 |
-
arguments: Dict[str, Any], session: Any = None
|
| 1002 |
) -> tuple[str, bool]:
|
| 1003 |
"""Handler for agent tool router"""
|
| 1004 |
try:
|
|
@@ -1031,14 +1055,20 @@ async def hf_jobs_handler(
|
|
| 1031 |
return f"Failed to read {script} from sandbox: {result.error}", False
|
| 1032 |
arguments = {**arguments, "script": result.output}
|
| 1033 |
|
| 1034 |
-
#
|
| 1035 |
-
hf_token =
|
| 1036 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1037 |
|
| 1038 |
tool = HfJobsTool(
|
| 1039 |
namespace=namespace,
|
| 1040 |
hf_token=hf_token,
|
| 1041 |
log_callback=log_callback if session else None,
|
|
|
|
|
|
|
| 1042 |
)
|
| 1043 |
result = await tool.execute(arguments)
|
| 1044 |
return result["formatted"], not result.get("isError", False)
|
|
|
|
| 9 |
import http.client
|
| 10 |
import os
|
| 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
|
|
|
|
| 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,
|
|
|
|
| 132 |
return result
|
| 133 |
|
| 134 |
|
| 135 |
+
def _add_environment_variables(
|
| 136 |
+
params: Dict[str, Any] | None, user_token: str | None = None
|
| 137 |
+
) -> Dict[str, Any]:
|
| 138 |
+
# Prefer the authenticated user's OAuth token, fall back to global env var
|
| 139 |
+
token = user_token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_HUB_TOKEN") or ""
|
| 140 |
|
| 141 |
# Start with user-provided env vars, then force-set token last
|
| 142 |
result = dict(params or {})
|
|
|
|
| 292 |
hf_token: Optional[str] = None,
|
| 293 |
namespace: Optional[str] = None,
|
| 294 |
log_callback: Optional[Callable[[str], Awaitable[None]]] = None,
|
| 295 |
+
session: Any = None,
|
| 296 |
+
tool_call_id: Optional[str] = None,
|
| 297 |
):
|
| 298 |
+
self.hf_token = hf_token
|
| 299 |
self.api = HfApi(token=hf_token)
|
| 300 |
self.namespace = namespace
|
| 301 |
self.log_callback = log_callback
|
| 302 |
+
self.session = session
|
| 303 |
+
self.tool_call_id = tool_call_id
|
| 304 |
|
| 305 |
async def execute(self, params: Dict[str, Any]) -> ToolResult:
|
| 306 |
"""Execute the specified operation"""
|
|
|
|
| 396 |
def log_producer():
|
| 397 |
try:
|
| 398 |
# fetch_job_logs is a blocking sync generator
|
| 399 |
+
logs_gen = self.api.fetch_job_logs(job_id=job_id, namespace=namespace)
|
|
|
|
|
|
|
| 400 |
for line in logs_gen:
|
| 401 |
# Push line to queue thread-safely
|
| 402 |
loop.call_soon_threadsafe(queue.put_nowait, line)
|
|
|
|
| 423 |
|
| 424 |
# Process log line
|
| 425 |
log_line = item
|
| 426 |
+
logger.debug(log_line)
|
| 427 |
if self.log_callback:
|
| 428 |
await self.log_callback(log_line)
|
| 429 |
all_logs.append(log_line)
|
|
|
|
| 451 |
|
| 452 |
if current_status in terminal_states:
|
| 453 |
# Job finished, no need to retry
|
| 454 |
+
logger.info(f"Job reached terminal state: {current_status}")
|
| 455 |
break
|
| 456 |
|
| 457 |
# Job still running, retry connection
|
| 458 |
+
logger.warning(
|
| 459 |
+
f"Connection interrupted ({str(e)[:50]}...), reconnecting in {retry_delay}s..."
|
| 460 |
)
|
| 461 |
await asyncio.sleep(retry_delay)
|
| 462 |
continue
|
| 463 |
|
| 464 |
except (ConnectionError, TimeoutError, OSError):
|
| 465 |
# Can't even check job status, wait and retry
|
| 466 |
+
logger.warning(f"Connection error, retrying in {retry_delay}s...")
|
| 467 |
await asyncio.sleep(retry_delay)
|
| 468 |
continue
|
| 469 |
|
|
|
|
| 520 |
image=image,
|
| 521 |
command=command,
|
| 522 |
env=_add_default_env(args.get("env")),
|
| 523 |
+
secrets=_add_environment_variables(args.get("secrets"), self.hf_token),
|
| 524 |
flavor=args.get("hardware_flavor", "cpu-basic"),
|
| 525 |
timeout=args.get("timeout", "30m"),
|
| 526 |
namespace=self.namespace,
|
| 527 |
)
|
| 528 |
|
| 529 |
+
# Send job URL immediately after job creation (before waiting for completion)
|
| 530 |
+
if self.session and self.tool_call_id:
|
| 531 |
+
await self.session.send_event(
|
| 532 |
+
Event(
|
| 533 |
+
event_type="tool_state_change",
|
| 534 |
+
data={
|
| 535 |
+
"tool_call_id": self.tool_call_id,
|
| 536 |
+
"tool": "hf_jobs",
|
| 537 |
+
"state": "running",
|
| 538 |
+
"jobUrl": job.url,
|
| 539 |
+
},
|
| 540 |
+
)
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
# Wait for completion and stream logs
|
| 544 |
+
logger.info(f"{job_type} job started: {job.url}")
|
| 545 |
+
logger.info("Streaming logs...")
|
| 546 |
|
| 547 |
final_status, all_logs = await self._wait_for_job_completion(
|
| 548 |
job_id=job.id,
|
|
|
|
| 752 |
command=command,
|
| 753 |
schedule=schedule,
|
| 754 |
env=_add_default_env(args.get("env")),
|
| 755 |
+
secrets=_add_environment_variables(args.get("secrets"), self.hf_token),
|
| 756 |
flavor=args.get("hardware_flavor", "cpu-basic"),
|
| 757 |
timeout=args.get("timeout", "30m"),
|
| 758 |
namespace=self.namespace,
|
|
|
|
| 1022 |
|
| 1023 |
|
| 1024 |
async def hf_jobs_handler(
|
| 1025 |
+
arguments: Dict[str, Any], session: Any = None, tool_call_id: str | None = None
|
| 1026 |
) -> tuple[str, bool]:
|
| 1027 |
"""Handler for agent tool router"""
|
| 1028 |
try:
|
|
|
|
| 1055 |
return f"Failed to read {script} from sandbox: {result.error}", False
|
| 1056 |
arguments = {**arguments, "script": result.output}
|
| 1057 |
|
| 1058 |
+
# Prefer the authenticated user's OAuth token, fall back to global env
|
| 1059 |
+
hf_token = (
|
| 1060 |
+
(getattr(session, "hf_token", None) if session else None)
|
| 1061 |
+
or os.environ.get("HF_TOKEN")
|
| 1062 |
+
or os.environ.get("HUGGINGFACE_HUB_TOKEN")
|
| 1063 |
+
)
|
| 1064 |
+
namespace = os.environ.get("HF_NAMESPACE") or (HfApi(token=hf_token).whoami().get("name") if hf_token else None)
|
| 1065 |
|
| 1066 |
tool = HfJobsTool(
|
| 1067 |
namespace=namespace,
|
| 1068 |
hf_token=hf_token,
|
| 1069 |
log_callback=log_callback if session else None,
|
| 1070 |
+
session=session,
|
| 1071 |
+
tool_call_id=tool_call_id,
|
| 1072 |
)
|
| 1073 |
result = await tool.execute(arguments)
|
| 1074 |
return result["formatted"], not result.get("isError", False)
|
agent/tools/sandbox_tool.py
CHANGED
|
@@ -38,9 +38,13 @@ async def _ensure_sandbox(
|
|
| 38 |
if not session:
|
| 39 |
return None, "No session available."
|
| 40 |
|
| 41 |
-
token =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
if not token:
|
| 43 |
-
return None, "
|
| 44 |
|
| 45 |
api = HfApi(token=token)
|
| 46 |
user_info = api.whoami()
|
|
|
|
| 38 |
if not session:
|
| 39 |
return None, "No session available."
|
| 40 |
|
| 41 |
+
token = (
|
| 42 |
+
getattr(session, "hf_token", None)
|
| 43 |
+
or os.environ.get("HF_TOKEN")
|
| 44 |
+
or os.environ.get("HUGGINGFACE_HUB_TOKEN")
|
| 45 |
+
)
|
| 46 |
if not token:
|
| 47 |
+
return None, "No HF token available. Cannot create sandbox."
|
| 48 |
|
| 49 |
api = HfApi(token=token)
|
| 50 |
user_info = api.whoami()
|
backend/dependencies.py
ADDED
|
@@ -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
|
backend/main.py
CHANGED
|
@@ -5,6 +5,14 @@ import os
|
|
| 5 |
from contextlib import asynccontextmanager
|
| 6 |
from pathlib import Path
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
from fastapi import FastAPI
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
from fastapi.staticfiles import StaticFiles
|
|
|
|
| 5 |
from contextlib import asynccontextmanager
|
| 6 |
from pathlib import Path
|
| 7 |
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
|
| 10 |
+
load_dotenv()
|
| 11 |
+
|
| 12 |
+
# Ensure HF_TOKEN is set — fall back to HF_ADMIN_TOKEN if available (HF Spaces)
|
| 13 |
+
if not os.environ.get("HF_TOKEN") and os.environ.get("HF_ADMIN_TOKEN"):
|
| 14 |
+
os.environ["HF_TOKEN"] = os.environ["HF_ADMIN_TOKEN"]
|
| 15 |
+
|
| 16 |
from fastapi import FastAPI
|
| 17 |
from fastapi.middleware.cors import CORSMiddleware
|
| 18 |
from fastapi.staticfiles import StaticFiles
|
backend/models.py
CHANGED
|
@@ -37,6 +37,7 @@ class ToolApproval(BaseModel):
|
|
| 37 |
tool_call_id: str
|
| 38 |
approved: bool
|
| 39 |
feedback: str | None = None
|
|
|
|
| 40 |
|
| 41 |
|
| 42 |
class ApprovalRequest(BaseModel):
|
|
@@ -67,6 +68,7 @@ class SessionInfo(BaseModel):
|
|
| 67 |
created_at: str
|
| 68 |
is_active: bool
|
| 69 |
message_count: int
|
|
|
|
| 70 |
|
| 71 |
|
| 72 |
class HealthResponse(BaseModel):
|
|
@@ -74,3 +76,13 @@ class HealthResponse(BaseModel):
|
|
| 74 |
|
| 75 |
status: str = "ok"
|
| 76 |
active_sessions: int = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
tool_call_id: str
|
| 38 |
approved: bool
|
| 39 |
feedback: str | None = None
|
| 40 |
+
edited_script: str | None = None
|
| 41 |
|
| 42 |
|
| 43 |
class ApprovalRequest(BaseModel):
|
|
|
|
| 68 |
created_at: str
|
| 69 |
is_active: bool
|
| 70 |
message_count: int
|
| 71 |
+
user_id: str = "dev"
|
| 72 |
|
| 73 |
|
| 74 |
class HealthResponse(BaseModel):
|
|
|
|
| 76 |
|
| 77 |
status: str = "ok"
|
| 78 |
active_sessions: int = 0
|
| 79 |
+
max_sessions: int = 0
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class LLMHealthResponse(BaseModel):
|
| 83 |
+
"""LLM provider health check response."""
|
| 84 |
+
|
| 85 |
+
status: str # "ok" | "error"
|
| 86 |
+
model: str
|
| 87 |
+
error: str | None = None
|
| 88 |
+
error_type: str | None = None # "auth" | "credits" | "rate_limit" | "network" | "unknown"
|
backend/routes/agent.py
CHANGED
|
@@ -1,58 +1,252 @@
|
|
| 1 |
-
"""Agent API routes - WebSocket and REST endpoints.
|
| 2 |
|
| 3 |
-
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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__)
|
| 18 |
|
| 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(
|
| 32 |
-
|
| 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 +254,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,13 +266,17 @@ 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,
|
| 77 |
"approved": a.approved,
|
| 78 |
"feedback": a.feedback,
|
|
|
|
| 79 |
}
|
| 80 |
for a in request.approvals
|
| 81 |
]
|
|
@@ -86,8 +287,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 +299,9 @@ async def interrupt_session(session_id: str) -> dict:
|
|
| 95 |
|
| 96 |
|
| 97 |
@router.post("/undo/{session_id}")
|
| 98 |
-
async def undo_session(session_id: str) -> dict:
|
| 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 +309,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 +321,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 +334,61 @@ 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 |
+
import os
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from dependencies import get_current_user, get_ws_user
|
| 12 |
+
from fastapi import (
|
| 13 |
+
APIRouter,
|
| 14 |
+
Depends,
|
| 15 |
+
HTTPException,
|
| 16 |
+
Request,
|
| 17 |
+
WebSocket,
|
| 18 |
+
WebSocketDisconnect,
|
| 19 |
+
)
|
| 20 |
+
from litellm import acompletion
|
| 21 |
|
| 22 |
+
from agent.core.agent_loop import _resolve_hf_router_params
|
| 23 |
from models import (
|
| 24 |
ApprovalRequest,
|
| 25 |
HealthResponse,
|
| 26 |
+
LLMHealthResponse,
|
| 27 |
SessionInfo,
|
| 28 |
SessionResponse,
|
| 29 |
SubmitRequest,
|
| 30 |
)
|
| 31 |
+
from session_manager import MAX_SESSIONS, SessionCapacityError, session_manager
|
| 32 |
from websocket import manager as ws_manager
|
| 33 |
|
| 34 |
logger = logging.getLogger(__name__)
|
| 35 |
|
| 36 |
router = APIRouter(prefix="/api", tags=["agent"])
|
| 37 |
|
| 38 |
+
AVAILABLE_MODELS = [
|
| 39 |
+
{
|
| 40 |
+
"id": "huggingface/novita/minimax/minimax-m2.1",
|
| 41 |
+
"label": "MiniMax M2.1",
|
| 42 |
+
"provider": "huggingface",
|
| 43 |
+
"recommended": True,
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"id": "anthropic/claude-opus-4-5-20251101",
|
| 47 |
+
"label": "Claude Opus 4.5",
|
| 48 |
+
"provider": "anthropic",
|
| 49 |
+
"recommended": True,
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"id": "huggingface/novita/moonshotai/kimi-k2.5",
|
| 53 |
+
"label": "Kimi K2.5",
|
| 54 |
+
"provider": "huggingface",
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
"id": "huggingface/novita/zai-org/glm-5",
|
| 58 |
+
"label": "GLM 5",
|
| 59 |
+
"provider": "huggingface",
|
| 60 |
+
},
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _check_session_access(session_id: str, user: dict[str, Any]) -> None:
|
| 65 |
+
"""Verify the user has access to the given session. Raises 403 or 404."""
|
| 66 |
+
info = session_manager.get_session_info(session_id)
|
| 67 |
+
if not info:
|
| 68 |
+
raise HTTPException(status_code=404, detail="Session not found")
|
| 69 |
+
if not session_manager.verify_session_access(session_id, user["user_id"]):
|
| 70 |
+
raise HTTPException(status_code=403, detail="Access denied to this session")
|
| 71 |
+
|
| 72 |
|
| 73 |
@router.get("/health", response_model=HealthResponse)
|
| 74 |
async def health_check() -> HealthResponse:
|
| 75 |
"""Health check endpoint."""
|
| 76 |
return HealthResponse(
|
| 77 |
+
status="ok",
|
| 78 |
+
active_sessions=session_manager.active_session_count,
|
| 79 |
+
max_sessions=MAX_SESSIONS,
|
| 80 |
)
|
| 81 |
|
| 82 |
|
| 83 |
+
@router.get("/health/llm", response_model=LLMHealthResponse)
|
| 84 |
+
async def llm_health_check() -> LLMHealthResponse:
|
| 85 |
+
"""Check if the LLM provider is reachable and the API key is valid.
|
| 86 |
+
|
| 87 |
+
Makes a minimal 1-token completion call. Catches common errors:
|
| 88 |
+
- 401 → invalid API key
|
| 89 |
+
- 402/insufficient_quota → out of credits
|
| 90 |
+
- 429 → rate limited
|
| 91 |
+
- timeout / network → provider unreachable
|
| 92 |
+
"""
|
| 93 |
+
model = session_manager.config.model_name
|
| 94 |
+
try:
|
| 95 |
+
llm_params = _resolve_hf_router_params(model)
|
| 96 |
+
await acompletion(
|
| 97 |
+
messages=[{"role": "user", "content": "hi"}],
|
| 98 |
+
max_tokens=1,
|
| 99 |
+
timeout=10,
|
| 100 |
+
**llm_params,
|
| 101 |
+
)
|
| 102 |
+
return LLMHealthResponse(status="ok", model=model)
|
| 103 |
+
except Exception as e:
|
| 104 |
+
err_str = str(e).lower()
|
| 105 |
+
error_type = "unknown"
|
| 106 |
+
|
| 107 |
+
if (
|
| 108 |
+
"401" in err_str
|
| 109 |
+
or "auth" in err_str
|
| 110 |
+
or "invalid" in err_str
|
| 111 |
+
or "api key" in err_str
|
| 112 |
+
):
|
| 113 |
+
error_type = "auth"
|
| 114 |
+
elif (
|
| 115 |
+
"402" in err_str
|
| 116 |
+
or "credit" in err_str
|
| 117 |
+
or "quota" in err_str
|
| 118 |
+
or "insufficient" in err_str
|
| 119 |
+
or "billing" in err_str
|
| 120 |
+
):
|
| 121 |
+
error_type = "credits"
|
| 122 |
+
elif "429" in err_str or "rate" in err_str:
|
| 123 |
+
error_type = "rate_limit"
|
| 124 |
+
elif "timeout" in err_str or "connect" in err_str or "network" in err_str:
|
| 125 |
+
error_type = "network"
|
| 126 |
+
|
| 127 |
+
logger.warning(f"LLM health check failed ({error_type}): {e}")
|
| 128 |
+
return LLMHealthResponse(
|
| 129 |
+
status="error",
|
| 130 |
+
model=model,
|
| 131 |
+
error=str(e)[:500],
|
| 132 |
+
error_type=error_type,
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@router.get("/config/model")
|
| 137 |
+
async def get_model() -> dict:
|
| 138 |
+
"""Get current model and available models. No auth required."""
|
| 139 |
+
return {
|
| 140 |
+
"current": session_manager.config.model_name,
|
| 141 |
+
"available": AVAILABLE_MODELS,
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
@router.post("/config/model")
|
| 146 |
+
async def set_model(body: dict, user: dict = Depends(get_current_user)) -> dict:
|
| 147 |
+
"""Set the LLM model. Applies to new conversations."""
|
| 148 |
+
model_id = body.get("model")
|
| 149 |
+
if not model_id:
|
| 150 |
+
raise HTTPException(status_code=400, detail="Missing 'model' field")
|
| 151 |
+
valid_ids = {m["id"] for m in AVAILABLE_MODELS}
|
| 152 |
+
if model_id not in valid_ids:
|
| 153 |
+
raise HTTPException(status_code=400, detail=f"Unknown model: {model_id}")
|
| 154 |
+
session_manager.config.model_name = model_id
|
| 155 |
+
logger.info(f"Model changed to {model_id} by {user.get('username', 'unknown')}")
|
| 156 |
+
return {"model": model_id}
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
@router.post("/title")
|
| 160 |
+
async def generate_title(
|
| 161 |
+
request: SubmitRequest, user: dict = Depends(get_current_user)
|
| 162 |
+
) -> dict:
|
| 163 |
+
"""Generate a short title for a chat session based on the first user message."""
|
| 164 |
+
model = session_manager.config.model_name
|
| 165 |
+
llm_params = _resolve_hf_router_params(model)
|
| 166 |
+
try:
|
| 167 |
+
response = await acompletion(
|
| 168 |
+
messages=[
|
| 169 |
+
{
|
| 170 |
+
"role": "system",
|
| 171 |
+
"content": (
|
| 172 |
+
"Generate a very short title (max 6 words) for a chat conversation "
|
| 173 |
+
"that starts with the following user message. "
|
| 174 |
+
"Reply with ONLY the title, no quotes, no punctuation at the end."
|
| 175 |
+
),
|
| 176 |
+
},
|
| 177 |
+
{"role": "user", "content": request.text[:500]},
|
| 178 |
+
],
|
| 179 |
+
max_tokens=20,
|
| 180 |
+
temperature=0.3,
|
| 181 |
+
timeout=8,
|
| 182 |
+
**llm_params,
|
| 183 |
+
)
|
| 184 |
+
title = response.choices[0].message.content.strip().strip('"').strip("'")
|
| 185 |
+
# Safety: cap at 50 chars
|
| 186 |
+
if len(title) > 50:
|
| 187 |
+
title = title[:50].rstrip() + "…"
|
| 188 |
+
return {"title": title}
|
| 189 |
+
except Exception as e:
|
| 190 |
+
logger.warning(f"Title generation failed: {e}")
|
| 191 |
+
# Fallback: truncate the message
|
| 192 |
+
fallback = request.text.strip()
|
| 193 |
+
title = fallback[:40].rstrip() + "…" if len(fallback) > 40 else fallback
|
| 194 |
+
return {"title": title}
|
| 195 |
+
|
| 196 |
+
|
| 197 |
@router.post("/session", response_model=SessionResponse)
|
| 198 |
+
async def create_session(
|
| 199 |
+
request: Request, user: dict = Depends(get_current_user)
|
| 200 |
+
) -> SessionResponse:
|
| 201 |
+
"""Create a new agent session bound to the authenticated user.
|
| 202 |
+
|
| 203 |
+
The user's HF access token is extracted from the Authorization header
|
| 204 |
+
and stored in the session so that tools (e.g. hf_jobs) can act on
|
| 205 |
+
behalf of the user.
|
| 206 |
+
|
| 207 |
+
Returns 503 if the server or user has reached the session limit.
|
| 208 |
+
"""
|
| 209 |
+
# Extract the user's HF token (Bearer header or HttpOnly cookie)
|
| 210 |
+
hf_token = None
|
| 211 |
+
auth_header = request.headers.get("Authorization", "")
|
| 212 |
+
if auth_header.startswith("Bearer "):
|
| 213 |
+
hf_token = auth_header[7:]
|
| 214 |
+
if not hf_token:
|
| 215 |
+
hf_token = request.cookies.get("hf_access_token")
|
| 216 |
+
|
| 217 |
+
try:
|
| 218 |
+
session_id = await session_manager.create_session(
|
| 219 |
+
user_id=user["user_id"], hf_token=hf_token
|
| 220 |
+
)
|
| 221 |
+
except SessionCapacityError as e:
|
| 222 |
+
raise HTTPException(status_code=503, detail=str(e))
|
| 223 |
+
|
| 224 |
return SessionResponse(session_id=session_id, ready=True)
|
| 225 |
|
| 226 |
|
| 227 |
@router.get("/session/{session_id}", response_model=SessionInfo)
|
| 228 |
+
async def get_session(
|
| 229 |
+
session_id: str, user: dict = Depends(get_current_user)
|
| 230 |
+
) -> SessionInfo:
|
| 231 |
+
"""Get session information. Only accessible by the session owner."""
|
| 232 |
+
_check_session_access(session_id, user)
|
| 233 |
info = session_manager.get_session_info(session_id)
|
|
|
|
|
|
|
| 234 |
return SessionInfo(**info)
|
| 235 |
|
| 236 |
|
| 237 |
@router.get("/sessions", response_model=list[SessionInfo])
|
| 238 |
+
async def list_sessions(user: dict = Depends(get_current_user)) -> list[SessionInfo]:
|
| 239 |
+
"""List sessions belonging to the authenticated user."""
|
| 240 |
+
sessions = session_manager.list_sessions(user_id=user["user_id"])
|
| 241 |
return [SessionInfo(**s) for s in sessions]
|
| 242 |
|
| 243 |
|
| 244 |
@router.delete("/session/{session_id}")
|
| 245 |
+
async def delete_session(
|
| 246 |
+
session_id: str, user: dict = Depends(get_current_user)
|
| 247 |
+
) -> dict:
|
| 248 |
+
"""Delete a session. Only accessible by the session owner."""
|
| 249 |
+
_check_session_access(session_id, user)
|
| 250 |
success = await session_manager.delete_session(session_id)
|
| 251 |
if not success:
|
| 252 |
raise HTTPException(status_code=404, detail="Session not found")
|
|
|
|
| 254 |
|
| 255 |
|
| 256 |
@router.post("/submit")
|
| 257 |
+
async def submit_input(
|
| 258 |
+
request: SubmitRequest, user: dict = Depends(get_current_user)
|
| 259 |
+
) -> dict:
|
| 260 |
+
"""Submit user input to a session. Only accessible by the session owner."""
|
| 261 |
+
_check_session_access(request.session_id, user)
|
| 262 |
success = await session_manager.submit_user_input(request.session_id, request.text)
|
| 263 |
if not success:
|
| 264 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
|
|
| 266 |
|
| 267 |
|
| 268 |
@router.post("/approve")
|
| 269 |
+
async def submit_approval(
|
| 270 |
+
request: ApprovalRequest, user: dict = Depends(get_current_user)
|
| 271 |
+
) -> dict:
|
| 272 |
+
"""Submit tool approvals to a session. Only accessible by the session owner."""
|
| 273 |
+
_check_session_access(request.session_id, user)
|
| 274 |
approvals = [
|
| 275 |
{
|
| 276 |
"tool_call_id": a.tool_call_id,
|
| 277 |
"approved": a.approved,
|
| 278 |
"feedback": a.feedback,
|
| 279 |
+
"edited_script": a.edited_script,
|
| 280 |
}
|
| 281 |
for a in request.approvals
|
| 282 |
]
|
|
|
|
| 287 |
|
| 288 |
|
| 289 |
@router.post("/interrupt/{session_id}")
|
| 290 |
+
async def interrupt_session(
|
| 291 |
+
session_id: str, user: dict = Depends(get_current_user)
|
| 292 |
+
) -> dict:
|
| 293 |
"""Interrupt the current operation in a session."""
|
| 294 |
+
_check_session_access(session_id, user)
|
| 295 |
success = await session_manager.interrupt(session_id)
|
| 296 |
if not success:
|
| 297 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
|
|
| 299 |
|
| 300 |
|
| 301 |
@router.post("/undo/{session_id}")
|
| 302 |
+
async def undo_session(session_id: str, user: dict = Depends(get_current_user)) -> dict:
|
| 303 |
"""Undo the last turn in a session."""
|
| 304 |
+
_check_session_access(session_id, user)
|
| 305 |
success = await session_manager.undo(session_id)
|
| 306 |
if not success:
|
| 307 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
|
|
| 309 |
|
| 310 |
|
| 311 |
@router.post("/compact/{session_id}")
|
| 312 |
+
async def compact_session(
|
| 313 |
+
session_id: str, user: dict = Depends(get_current_user)
|
| 314 |
+
) -> dict:
|
| 315 |
"""Compact the context in a session."""
|
| 316 |
+
_check_session_access(session_id, user)
|
| 317 |
success = await session_manager.compact(session_id)
|
| 318 |
if not success:
|
| 319 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
|
|
| 321 |
|
| 322 |
|
| 323 |
@router.post("/shutdown/{session_id}")
|
| 324 |
+
async def shutdown_session(
|
| 325 |
+
session_id: str, user: dict = Depends(get_current_user)
|
| 326 |
+
) -> dict:
|
| 327 |
"""Shutdown a session."""
|
| 328 |
+
_check_session_access(session_id, user)
|
| 329 |
success = await session_manager.shutdown_session(session_id)
|
| 330 |
if not success:
|
| 331 |
raise HTTPException(status_code=404, detail="Session not found or inactive")
|
|
|
|
| 334 |
|
| 335 |
@router.websocket("/ws/{session_id}")
|
| 336 |
async def websocket_endpoint(websocket: WebSocket, session_id: str) -> None:
|
| 337 |
+
"""WebSocket endpoint for real-time events.
|
| 338 |
+
|
| 339 |
+
Authentication is done via:
|
| 340 |
+
- ?token= query parameter (for browsers that can't send WS headers)
|
| 341 |
+
- Cookie (automatic for same-origin connections)
|
| 342 |
+
- Dev mode bypass (when OAUTH_CLIENT_ID is not set)
|
| 343 |
+
|
| 344 |
+
NOTE: We must accept() before close() so the browser receives our custom
|
| 345 |
+
close codes (4001, 4003, 4004). If we close() before accept(), Starlette
|
| 346 |
+
sends HTTP 403 and the browser only sees code 1006 (abnormal closure).
|
| 347 |
+
"""
|
| 348 |
logger.info(f"WebSocket connection request for session {session_id}")
|
| 349 |
+
|
| 350 |
+
# Authenticate the WebSocket connection
|
| 351 |
+
user = await get_ws_user(websocket)
|
| 352 |
+
if not user:
|
| 353 |
+
logger.warning(
|
| 354 |
+
f"WebSocket rejected: authentication failed for session {session_id}"
|
| 355 |
+
)
|
| 356 |
+
await websocket.accept()
|
| 357 |
+
await websocket.close(code=4001, reason="Authentication required")
|
| 358 |
+
return
|
| 359 |
+
|
| 360 |
# Verify session exists
|
| 361 |
info = session_manager.get_session_info(session_id)
|
| 362 |
if not info:
|
| 363 |
+
logger.warning(f"WebSocket rejected: session {session_id} not found")
|
| 364 |
+
await websocket.accept()
|
| 365 |
await websocket.close(code=4004, reason="Session not found")
|
| 366 |
return
|
| 367 |
|
| 368 |
+
# Verify user owns the session
|
| 369 |
+
if not session_manager.verify_session_access(session_id, user["user_id"]):
|
| 370 |
+
logger.warning(
|
| 371 |
+
f"WebSocket rejected: user {user['user_id']} denied access to session {session_id}"
|
| 372 |
+
)
|
| 373 |
+
await websocket.accept()
|
| 374 |
+
await websocket.close(code=4003, reason="Access denied")
|
| 375 |
+
return
|
| 376 |
+
|
| 377 |
await ws_manager.connect(websocket, session_id)
|
| 378 |
|
| 379 |
+
# Send "ready" immediately on WebSocket connection so the frontend
|
| 380 |
+
# knows the session is alive. The original ready event from _run_session
|
| 381 |
+
# fires before the WS is connected and is always lost.
|
| 382 |
+
try:
|
| 383 |
+
await websocket.send_json(
|
| 384 |
+
{
|
| 385 |
+
"event_type": "ready",
|
| 386 |
+
"data": {"message": "Agent initialized"},
|
| 387 |
+
}
|
| 388 |
+
)
|
| 389 |
+
except Exception as e:
|
| 390 |
+
logger.error(f"Failed to send ready event for session {session_id}: {e}")
|
| 391 |
+
|
| 392 |
try:
|
| 393 |
while True:
|
| 394 |
# Keep connection alive, handle ping/pong
|
backend/routes/auth.py
CHANGED
|
@@ -1,11 +1,17 @@
|
|
| 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
|
|
|
|
| 9 |
from fastapi.responses import RedirectResponse
|
| 10 |
|
| 11 |
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
@@ -15,10 +21,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,17 +53,26 @@ 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 = {
|
| 47 |
"client_id": OAUTH_CLIENT_ID,
|
| 48 |
"redirect_uri": get_redirect_uri(request),
|
| 49 |
-
"scope": "openid profile",
|
| 50 |
"response_type": "code",
|
| 51 |
"state": state,
|
|
|
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
auth_url = f"{OPENID_PROVIDER_URL}/oauth/authorize?{urlencode(params)}"
|
| 54 |
|
|
@@ -91,58 +115,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 dependencies import AUTH_ENABLED, get_current_user
|
| 14 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 15 |
from fastapi.responses import RedirectResponse
|
| 16 |
|
| 17 |
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
|
|
| 21 |
OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET", "")
|
| 22 |
OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co")
|
| 23 |
|
| 24 |
+
# In-memory OAuth state store with expiry (5 min TTL)
|
| 25 |
+
_OAUTH_STATE_TTL = 300
|
| 26 |
oauth_states: dict[str, dict] = {}
|
| 27 |
|
| 28 |
|
| 29 |
+
def _cleanup_expired_states() -> None:
|
| 30 |
+
"""Remove expired OAuth states to prevent memory growth."""
|
| 31 |
+
now = time.time()
|
| 32 |
+
expired = [k for k, v in oauth_states.items() if now > v.get("expires_at", 0)]
|
| 33 |
+
for k in expired:
|
| 34 |
+
del oauth_states[k]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
def get_redirect_uri(request: Request) -> str:
|
| 38 |
"""Get the OAuth callback redirect URI."""
|
| 39 |
# In HF Spaces, use the SPACE_HOST if available
|
|
|
|
| 53 |
detail="OAuth not configured. Set OAUTH_CLIENT_ID environment variable.",
|
| 54 |
)
|
| 55 |
|
| 56 |
+
# Clean up expired states to prevent memory growth
|
| 57 |
+
_cleanup_expired_states()
|
| 58 |
+
|
| 59 |
# Generate state for CSRF protection
|
| 60 |
state = secrets.token_urlsafe(32)
|
| 61 |
+
oauth_states[state] = {
|
| 62 |
+
"redirect_uri": get_redirect_uri(request),
|
| 63 |
+
"expires_at": time.time() + _OAUTH_STATE_TTL,
|
| 64 |
+
}
|
| 65 |
|
| 66 |
# Build authorization URL
|
| 67 |
params = {
|
| 68 |
"client_id": OAUTH_CLIENT_ID,
|
| 69 |
"redirect_uri": get_redirect_uri(request),
|
| 70 |
+
"scope": "openid profile read-repos write-repos contribute-repos manage-repos inference-api jobs write-discussions",
|
| 71 |
"response_type": "code",
|
| 72 |
"state": state,
|
| 73 |
+
"orgIds": os.environ.get(
|
| 74 |
+
"HF_OAUTH_ORG_ID", "698dbf55845d85df163175f1"
|
| 75 |
+
), # ml-agent-explorers
|
| 76 |
}
|
| 77 |
auth_url = f"{OPENID_PROVIDER_URL}/oauth/authorize?{urlencode(params)}"
|
| 78 |
|
|
|
|
| 115 |
|
| 116 |
# Get user info
|
| 117 |
access_token = token_data.get("access_token")
|
| 118 |
+
if not access_token:
|
| 119 |
+
raise HTTPException(
|
| 120 |
+
status_code=500,
|
| 121 |
+
detail="Token exchange succeeded but no access_token was returned.",
|
| 122 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
+
# Fetch user info (optional — failure is not fatal)
|
| 125 |
+
async with httpx.AsyncClient() as client:
|
| 126 |
+
try:
|
| 127 |
+
userinfo_response = await client.get(
|
| 128 |
+
f"{OPENID_PROVIDER_URL}/oauth/userinfo",
|
| 129 |
+
headers={"Authorization": f"Bearer {access_token}"},
|
| 130 |
+
)
|
| 131 |
+
userinfo_response.raise_for_status()
|
| 132 |
+
except httpx.HTTPError:
|
| 133 |
+
pass # user_info not required for auth flow
|
| 134 |
+
|
| 135 |
+
# Set access token as HttpOnly cookie (not in URL — avoids leaks via
|
| 136 |
+
# Referrer headers, browser history, and server logs)
|
| 137 |
+
is_production = bool(os.environ.get("SPACE_HOST"))
|
| 138 |
+
response = RedirectResponse(url="/", status_code=302)
|
| 139 |
+
response.set_cookie(
|
| 140 |
+
key="hf_access_token",
|
| 141 |
+
value=access_token,
|
| 142 |
+
httponly=True,
|
| 143 |
+
secure=is_production, # Secure flag only in production (HTTPS)
|
| 144 |
+
samesite="lax",
|
| 145 |
+
max_age=3600 * 24, # 24 hours
|
| 146 |
+
path="/",
|
| 147 |
+
)
|
| 148 |
+
return response
|
| 149 |
|
| 150 |
|
| 151 |
@router.get("/logout")
|
| 152 |
async def logout() -> RedirectResponse:
|
| 153 |
+
"""Log out the user by clearing the auth cookie."""
|
| 154 |
+
response = RedirectResponse(url="/")
|
| 155 |
+
response.delete_cookie(key="hf_access_token", path="/")
|
| 156 |
+
return response
|
| 157 |
|
| 158 |
|
| 159 |
+
@router.get("/status")
|
| 160 |
+
async def auth_status() -> dict:
|
| 161 |
+
"""Check if OAuth is enabled on this instance."""
|
| 162 |
+
return {"auth_enabled": AUTH_ENABLED}
|
|
|
|
|
|
|
| 163 |
|
|
|
|
| 164 |
|
| 165 |
+
@router.get("/me")
|
| 166 |
+
async def get_me(user: dict = Depends(get_current_user)) -> dict:
|
| 167 |
+
"""Get current user info. Returns the authenticated user or dev user.
|
| 168 |
+
|
| 169 |
+
Uses the shared auth dependency which handles cookie + Bearer token.
|
| 170 |
+
"""
|
| 171 |
+
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/session_manager.py
CHANGED
|
@@ -48,11 +48,28 @@ 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 +78,69 @@ 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 |
-
|
|
|
|
|
|
|
| 77 |
|
| 78 |
# Create wrapper
|
| 79 |
agent_session = AgentSession(
|
|
@@ -81,6 +148,8 @@ class SessionManager:
|
|
| 81 |
session=session,
|
| 82 |
tool_router=tool_router,
|
| 83 |
submission_queue=submission_queue,
|
|
|
|
|
|
|
| 84 |
)
|
| 85 |
|
| 86 |
async with self._lock:
|
|
@@ -92,7 +161,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 +314,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 +346,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 |
+
hf_token: str | None = None # User's HF OAuth token for tool execution
|
| 53 |
task: asyncio.Task | None = None
|
| 54 |
created_at: datetime = field(default_factory=datetime.utcnow)
|
| 55 |
is_active: bool = True
|
| 56 |
|
| 57 |
|
| 58 |
+
class SessionCapacityError(Exception):
|
| 59 |
+
"""Raised when no more sessions can be created."""
|
| 60 |
+
|
| 61 |
+
def __init__(self, message: str, error_type: str = "global") -> None:
|
| 62 |
+
super().__init__(message)
|
| 63 |
+
self.error_type = error_type # "global" or "per_user"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
# ── Capacity limits ─────────────────────────────────────────────────
|
| 67 |
+
# Estimated for HF Spaces cpu-basic (2 vCPU, 16 GB RAM).
|
| 68 |
+
# Each session uses ~10-20 MB (context, tools, queues, task).
|
| 69 |
+
MAX_SESSIONS: int = 50
|
| 70 |
+
MAX_SESSIONS_PER_USER: int = 10
|
| 71 |
+
|
| 72 |
+
|
| 73 |
class SessionManager:
|
| 74 |
"""Manages multiple concurrent agent sessions."""
|
| 75 |
|
|
|
|
| 78 |
self.sessions: dict[str, AgentSession] = {}
|
| 79 |
self._lock = asyncio.Lock()
|
| 80 |
|
| 81 |
+
def _count_user_sessions(self, user_id: str) -> int:
|
| 82 |
+
"""Count active sessions owned by a specific user."""
|
| 83 |
+
return sum(
|
| 84 |
+
1
|
| 85 |
+
for s in self.sessions.values()
|
| 86 |
+
if s.user_id == user_id and s.is_active
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
async def create_session(self, user_id: str = "dev", hf_token: str | None = None) -> str:
|
| 90 |
+
"""Create a new agent session and return its ID.
|
| 91 |
+
|
| 92 |
+
Session() and ToolRouter() constructors contain blocking I/O
|
| 93 |
+
(e.g. HfApi().whoami(), litellm.get_max_tokens()) so they are
|
| 94 |
+
executed in a thread pool to avoid freezing the async event loop.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
user_id: The ID of the user who owns this session.
|
| 98 |
+
|
| 99 |
+
Raises:
|
| 100 |
+
SessionCapacityError: If the server or user has reached the
|
| 101 |
+
maximum number of concurrent sessions.
|
| 102 |
+
"""
|
| 103 |
+
# ── Capacity checks ──────────────────────────────────────────
|
| 104 |
+
async with self._lock:
|
| 105 |
+
active_count = self.active_session_count
|
| 106 |
+
if active_count >= MAX_SESSIONS:
|
| 107 |
+
raise SessionCapacityError(
|
| 108 |
+
f"Server is at capacity ({active_count}/{MAX_SESSIONS} sessions). "
|
| 109 |
+
"Please try again later.",
|
| 110 |
+
error_type="global",
|
| 111 |
+
)
|
| 112 |
+
if user_id != "dev":
|
| 113 |
+
user_count = self._count_user_sessions(user_id)
|
| 114 |
+
if user_count >= MAX_SESSIONS_PER_USER:
|
| 115 |
+
raise SessionCapacityError(
|
| 116 |
+
f"You have reached the maximum of {MAX_SESSIONS_PER_USER} "
|
| 117 |
+
"concurrent sessions. Please close an existing session first.",
|
| 118 |
+
error_type="per_user",
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
session_id = str(uuid.uuid4())
|
| 122 |
|
| 123 |
# Create queues for this session
|
| 124 |
submission_queue: asyncio.Queue = asyncio.Queue()
|
| 125 |
event_queue: asyncio.Queue = asyncio.Queue()
|
| 126 |
|
| 127 |
+
# Run blocking constructors in a thread to keep the event loop responsive.
|
| 128 |
+
# Without this, Session.__init__ → ContextManager → litellm.get_max_tokens()
|
| 129 |
+
# blocks all HTTP/WebSocket handling.
|
| 130 |
+
import time as _time
|
| 131 |
+
|
| 132 |
+
def _create_session_sync():
|
| 133 |
+
t0 = _time.monotonic()
|
| 134 |
+
tool_router = ToolRouter(self.config.mcpServers)
|
| 135 |
+
session = Session(event_queue, config=self.config, tool_router=tool_router)
|
| 136 |
+
t1 = _time.monotonic()
|
| 137 |
+
logger.info(f"Session initialized in {t1 - t0:.2f}s")
|
| 138 |
+
return tool_router, session
|
| 139 |
|
| 140 |
+
tool_router, session = await asyncio.to_thread(_create_session_sync)
|
| 141 |
+
|
| 142 |
+
# Store user's HF token on the session so tools can use it
|
| 143 |
+
session.hf_token = hf_token
|
| 144 |
|
| 145 |
# Create wrapper
|
| 146 |
agent_session = AgentSession(
|
|
|
|
| 148 |
session=session,
|
| 149 |
tool_router=tool_router,
|
| 150 |
submission_queue=submission_queue,
|
| 151 |
+
user_id=user_id,
|
| 152 |
+
hf_token=hf_token,
|
| 153 |
)
|
| 154 |
|
| 155 |
async with self._lock:
|
|
|
|
| 161 |
)
|
| 162 |
agent_session.task = task
|
| 163 |
|
| 164 |
+
logger.info(f"Created session {session_id} for user {user_id}")
|
| 165 |
return session_id
|
| 166 |
|
| 167 |
async def _run_session(
|
|
|
|
| 314 |
|
| 315 |
return True
|
| 316 |
|
| 317 |
+
def get_session_owner(self, session_id: str) -> str | None:
|
| 318 |
+
"""Get the user_id that owns a session, or None if session doesn't exist."""
|
| 319 |
+
agent_session = self.sessions.get(session_id)
|
| 320 |
+
if not agent_session:
|
| 321 |
+
return None
|
| 322 |
+
return agent_session.user_id
|
| 323 |
+
|
| 324 |
+
def verify_session_access(self, session_id: str, user_id: str) -> bool:
|
| 325 |
+
"""Check if a user has access to a session.
|
| 326 |
+
|
| 327 |
+
Returns True if:
|
| 328 |
+
- The session exists AND the user owns it
|
| 329 |
+
- The user_id is "dev" (dev mode bypass)
|
| 330 |
+
"""
|
| 331 |
+
owner = self.get_session_owner(session_id)
|
| 332 |
+
if owner is None:
|
| 333 |
+
return False
|
| 334 |
+
if user_id == "dev" or owner == "dev":
|
| 335 |
+
return True
|
| 336 |
+
return owner == user_id
|
| 337 |
+
|
| 338 |
def get_session_info(self, session_id: str) -> dict[str, Any] | None:
|
| 339 |
"""Get information about a session."""
|
| 340 |
agent_session = self.sessions.get(session_id)
|
|
|
|
| 346 |
"created_at": agent_session.created_at.isoformat(),
|
| 347 |
"is_active": agent_session.is_active,
|
| 348 |
"message_count": len(agent_session.session.context_manager.items),
|
| 349 |
+
"user_id": agent_session.user_id,
|
| 350 |
}
|
| 351 |
|
| 352 |
+
def list_sessions(self, user_id: str | None = None) -> list[dict[str, Any]]:
|
| 353 |
+
"""List sessions, optionally filtered by user.
|
| 354 |
+
|
| 355 |
+
Args:
|
| 356 |
+
user_id: If provided, only return sessions owned by this user.
|
| 357 |
+
If "dev", return all sessions (dev mode).
|
| 358 |
+
"""
|
| 359 |
+
results = []
|
| 360 |
+
for sid in self.sessions:
|
| 361 |
+
info = self.get_session_info(sid)
|
| 362 |
+
if not info:
|
| 363 |
+
continue
|
| 364 |
+
if user_id and user_id != "dev" and info.get("user_id") != user_id:
|
| 365 |
+
continue
|
| 366 |
+
results.append(info)
|
| 367 |
+
return results
|
| 368 |
|
| 369 |
@property
|
| 370 |
def active_session_count(self) -> int:
|
backend/websocket.py
CHANGED
|
@@ -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()
|
configs/main_agent_config.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
| 1 |
{
|
| 2 |
-
"model_name": "
|
| 3 |
"save_sessions": true,
|
| 4 |
"session_dataset_repo": "akseljoonas/hf-agent-sessions",
|
| 5 |
"yolo_mode": false,
|
| 6 |
-
"confirm_cpu_jobs":
|
| 7 |
"auto_file_upload": true,
|
| 8 |
"mcpServers": {
|
| 9 |
"hf-mcp-server": {
|
|
|
|
| 1 |
{
|
| 2 |
+
"model_name": "huggingface/novita/moonshotai/kimi-k2.5",
|
| 3 |
"save_sessions": true,
|
| 4 |
"session_dataset_repo": "akseljoonas/hf-agent-sessions",
|
| 5 |
"yolo_mode": false,
|
| 6 |
+
"confirm_cpu_jobs": true,
|
| 7 |
"auto_file_upload": true,
|
| 8 |
"mcpServers": {
|
| 9 |
"hf-mcp-server": {
|
frontend/package-lock.json
CHANGED
|
@@ -8,10 +8,12 @@
|
|
| 8 |
"name": "hf-agent-frontend",
|
| 9 |
"version": "1.0.0",
|
| 10 |
"dependencies": {
|
|
|
|
| 11 |
"@emotion/react": "^11.13.0",
|
| 12 |
"@emotion/styled": "^11.13.0",
|
| 13 |
"@mui/icons-material": "^6.1.0",
|
| 14 |
"@mui/material": "^6.1.0",
|
|
|
|
| 15 |
"react": "^18.3.1",
|
| 16 |
"react-dom": "^18.3.1",
|
| 17 |
"react-markdown": "^9.0.1",
|
|
@@ -34,6 +36,70 @@
|
|
| 34 |
"vite": "^5.4.10"
|
| 35 |
}
|
| 36 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
"node_modules/@babel/code-frame": {
|
| 38 |
"version": "7.28.6",
|
| 39 |
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
|
|
@@ -1348,6 +1414,15 @@
|
|
| 1348 |
}
|
| 1349 |
}
|
| 1350 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1351 |
"node_modules/@popperjs/core": {
|
| 1352 |
"version": "2.11.8",
|
| 1353 |
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
|
@@ -1715,6 +1790,12 @@
|
|
| 1715 |
"win32"
|
| 1716 |
]
|
| 1717 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1718 |
"node_modules/@types/babel__core": {
|
| 1719 |
"version": "7.20.5",
|
| 1720 |
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
|
@@ -2155,6 +2236,15 @@
|
|
| 2155 |
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
| 2156 |
"license": "ISC"
|
| 2157 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2158 |
"node_modules/@vitejs/plugin-react": {
|
| 2159 |
"version": "4.7.0",
|
| 2160 |
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
|
@@ -2200,6 +2290,24 @@
|
|
| 2200 |
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
| 2201 |
}
|
| 2202 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2203 |
"node_modules/ajv": {
|
| 2204 |
"version": "6.12.6",
|
| 2205 |
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
|
@@ -2848,6 +2956,15 @@
|
|
| 2848 |
"node": ">=0.10.0"
|
| 2849 |
}
|
| 2850 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2851 |
"node_modules/extend": {
|
| 2852 |
"version": "3.0.2",
|
| 2853 |
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
|
@@ -3356,6 +3473,12 @@
|
|
| 3356 |
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
| 3357 |
"license": "MIT"
|
| 3358 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3359 |
"node_modules/json-schema-traverse": {
|
| 3360 |
"version": "0.4.1",
|
| 3361 |
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
|
@@ -5052,6 +5175,31 @@
|
|
| 5052 |
"url": "https://github.com/sponsors/ljharb"
|
| 5053 |
}
|
| 5054 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5055 |
"node_modules/tinyglobby": {
|
| 5056 |
"version": "0.2.15",
|
| 5057 |
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
|
@@ -5282,6 +5430,16 @@
|
|
| 5282 |
"punycode": "^2.1.0"
|
| 5283 |
}
|
| 5284 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5285 |
"node_modules/vfile": {
|
| 5286 |
"version": "6.0.3",
|
| 5287 |
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
|
@@ -5426,6 +5584,16 @@
|
|
| 5426 |
"url": "https://github.com/sponsors/sindresorhus"
|
| 5427 |
}
|
| 5428 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5429 |
"node_modules/zustand": {
|
| 5430 |
"version": "5.0.10",
|
| 5431 |
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz",
|
|
|
|
| 8 |
"name": "hf-agent-frontend",
|
| 9 |
"version": "1.0.0",
|
| 10 |
"dependencies": {
|
| 11 |
+
"@ai-sdk/react": "^3.0.93",
|
| 12 |
"@emotion/react": "^11.13.0",
|
| 13 |
"@emotion/styled": "^11.13.0",
|
| 14 |
"@mui/icons-material": "^6.1.0",
|
| 15 |
"@mui/material": "^6.1.0",
|
| 16 |
+
"ai": "^6.0.91",
|
| 17 |
"react": "^18.3.1",
|
| 18 |
"react-dom": "^18.3.1",
|
| 19 |
"react-markdown": "^9.0.1",
|
|
|
|
| 36 |
"vite": "^5.4.10"
|
| 37 |
}
|
| 38 |
},
|
| 39 |
+
"node_modules/@ai-sdk/gateway": {
|
| 40 |
+
"version": "3.0.50",
|
| 41 |
+
"resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.50.tgz",
|
| 42 |
+
"integrity": "sha512-Jdd1a8VgbD7l7r+COj0h5SuaYRfPvOJ/AO6l0OrmTPEcI2MUQPr3C4JttfpNkcheEN+gOdy0CtZWuG17bW2fjw==",
|
| 43 |
+
"license": "Apache-2.0",
|
| 44 |
+
"dependencies": {
|
| 45 |
+
"@ai-sdk/provider": "3.0.8",
|
| 46 |
+
"@ai-sdk/provider-utils": "4.0.15",
|
| 47 |
+
"@vercel/oidc": "3.1.0"
|
| 48 |
+
},
|
| 49 |
+
"engines": {
|
| 50 |
+
"node": ">=18"
|
| 51 |
+
},
|
| 52 |
+
"peerDependencies": {
|
| 53 |
+
"zod": "^3.25.76 || ^4.1.8"
|
| 54 |
+
}
|
| 55 |
+
},
|
| 56 |
+
"node_modules/@ai-sdk/provider": {
|
| 57 |
+
"version": "3.0.8",
|
| 58 |
+
"resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz",
|
| 59 |
+
"integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==",
|
| 60 |
+
"license": "Apache-2.0",
|
| 61 |
+
"dependencies": {
|
| 62 |
+
"json-schema": "^0.4.0"
|
| 63 |
+
},
|
| 64 |
+
"engines": {
|
| 65 |
+
"node": ">=18"
|
| 66 |
+
}
|
| 67 |
+
},
|
| 68 |
+
"node_modules/@ai-sdk/provider-utils": {
|
| 69 |
+
"version": "4.0.15",
|
| 70 |
+
"resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.15.tgz",
|
| 71 |
+
"integrity": "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w==",
|
| 72 |
+
"license": "Apache-2.0",
|
| 73 |
+
"dependencies": {
|
| 74 |
+
"@ai-sdk/provider": "3.0.8",
|
| 75 |
+
"@standard-schema/spec": "^1.1.0",
|
| 76 |
+
"eventsource-parser": "^3.0.6"
|
| 77 |
+
},
|
| 78 |
+
"engines": {
|
| 79 |
+
"node": ">=18"
|
| 80 |
+
},
|
| 81 |
+
"peerDependencies": {
|
| 82 |
+
"zod": "^3.25.76 || ^4.1.8"
|
| 83 |
+
}
|
| 84 |
+
},
|
| 85 |
+
"node_modules/@ai-sdk/react": {
|
| 86 |
+
"version": "3.0.93",
|
| 87 |
+
"resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-3.0.93.tgz",
|
| 88 |
+
"integrity": "sha512-FY1HmeAfCpiAGLhIZh2QR8QFzHFZfhjMmkA9D5KC/O3eGqPeY7CwBABLkzRH+5Gkf+MfxXnEm4VF0MpmvDMjpg==",
|
| 89 |
+
"license": "Apache-2.0",
|
| 90 |
+
"dependencies": {
|
| 91 |
+
"@ai-sdk/provider-utils": "4.0.15",
|
| 92 |
+
"ai": "6.0.91",
|
| 93 |
+
"swr": "^2.2.5",
|
| 94 |
+
"throttleit": "2.1.0"
|
| 95 |
+
},
|
| 96 |
+
"engines": {
|
| 97 |
+
"node": ">=18"
|
| 98 |
+
},
|
| 99 |
+
"peerDependencies": {
|
| 100 |
+
"react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1"
|
| 101 |
+
}
|
| 102 |
+
},
|
| 103 |
"node_modules/@babel/code-frame": {
|
| 104 |
"version": "7.28.6",
|
| 105 |
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz",
|
|
|
|
| 1414 |
}
|
| 1415 |
}
|
| 1416 |
},
|
| 1417 |
+
"node_modules/@opentelemetry/api": {
|
| 1418 |
+
"version": "1.9.0",
|
| 1419 |
+
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
| 1420 |
+
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
| 1421 |
+
"license": "Apache-2.0",
|
| 1422 |
+
"engines": {
|
| 1423 |
+
"node": ">=8.0.0"
|
| 1424 |
+
}
|
| 1425 |
+
},
|
| 1426 |
"node_modules/@popperjs/core": {
|
| 1427 |
"version": "2.11.8",
|
| 1428 |
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
|
|
|
| 1790 |
"win32"
|
| 1791 |
]
|
| 1792 |
},
|
| 1793 |
+
"node_modules/@standard-schema/spec": {
|
| 1794 |
+
"version": "1.1.0",
|
| 1795 |
+
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
| 1796 |
+
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
| 1797 |
+
"license": "MIT"
|
| 1798 |
+
},
|
| 1799 |
"node_modules/@types/babel__core": {
|
| 1800 |
"version": "7.20.5",
|
| 1801 |
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
|
|
|
| 2236 |
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
| 2237 |
"license": "ISC"
|
| 2238 |
},
|
| 2239 |
+
"node_modules/@vercel/oidc": {
|
| 2240 |
+
"version": "3.1.0",
|
| 2241 |
+
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.1.0.tgz",
|
| 2242 |
+
"integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==",
|
| 2243 |
+
"license": "Apache-2.0",
|
| 2244 |
+
"engines": {
|
| 2245 |
+
"node": ">= 20"
|
| 2246 |
+
}
|
| 2247 |
+
},
|
| 2248 |
"node_modules/@vitejs/plugin-react": {
|
| 2249 |
"version": "4.7.0",
|
| 2250 |
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
|
|
|
| 2290 |
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
| 2291 |
}
|
| 2292 |
},
|
| 2293 |
+
"node_modules/ai": {
|
| 2294 |
+
"version": "6.0.91",
|
| 2295 |
+
"resolved": "https://registry.npmjs.org/ai/-/ai-6.0.91.tgz",
|
| 2296 |
+
"integrity": "sha512-k1/8BusZMhYVxxLZt0BUZzm9HVDCCh117nyWfWUx5xjR2+tWisJbXgysL7EBMq2lgyHwgpA1jDR3tVjWSdWZXw==",
|
| 2297 |
+
"license": "Apache-2.0",
|
| 2298 |
+
"dependencies": {
|
| 2299 |
+
"@ai-sdk/gateway": "3.0.50",
|
| 2300 |
+
"@ai-sdk/provider": "3.0.8",
|
| 2301 |
+
"@ai-sdk/provider-utils": "4.0.15",
|
| 2302 |
+
"@opentelemetry/api": "1.9.0"
|
| 2303 |
+
},
|
| 2304 |
+
"engines": {
|
| 2305 |
+
"node": ">=18"
|
| 2306 |
+
},
|
| 2307 |
+
"peerDependencies": {
|
| 2308 |
+
"zod": "^3.25.76 || ^4.1.8"
|
| 2309 |
+
}
|
| 2310 |
+
},
|
| 2311 |
"node_modules/ajv": {
|
| 2312 |
"version": "6.12.6",
|
| 2313 |
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
|
|
|
| 2956 |
"node": ">=0.10.0"
|
| 2957 |
}
|
| 2958 |
},
|
| 2959 |
+
"node_modules/eventsource-parser": {
|
| 2960 |
+
"version": "3.0.6",
|
| 2961 |
+
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
|
| 2962 |
+
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
|
| 2963 |
+
"license": "MIT",
|
| 2964 |
+
"engines": {
|
| 2965 |
+
"node": ">=18.0.0"
|
| 2966 |
+
}
|
| 2967 |
+
},
|
| 2968 |
"node_modules/extend": {
|
| 2969 |
"version": "3.0.2",
|
| 2970 |
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
|
|
|
| 3473 |
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
|
| 3474 |
"license": "MIT"
|
| 3475 |
},
|
| 3476 |
+
"node_modules/json-schema": {
|
| 3477 |
+
"version": "0.4.0",
|
| 3478 |
+
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
|
| 3479 |
+
"integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
|
| 3480 |
+
"license": "(AFL-2.1 OR BSD-3-Clause)"
|
| 3481 |
+
},
|
| 3482 |
"node_modules/json-schema-traverse": {
|
| 3483 |
"version": "0.4.1",
|
| 3484 |
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
|
|
|
| 5175 |
"url": "https://github.com/sponsors/ljharb"
|
| 5176 |
}
|
| 5177 |
},
|
| 5178 |
+
"node_modules/swr": {
|
| 5179 |
+
"version": "2.4.0",
|
| 5180 |
+
"resolved": "https://registry.npmjs.org/swr/-/swr-2.4.0.tgz",
|
| 5181 |
+
"integrity": "sha512-sUlC20T8EOt1pHmDiqueUWMmRRX03W7w5YxovWX7VR2KHEPCTMly85x05vpkP5i6Bu4h44ePSMD9Tc+G2MItFw==",
|
| 5182 |
+
"license": "MIT",
|
| 5183 |
+
"dependencies": {
|
| 5184 |
+
"dequal": "^2.0.3",
|
| 5185 |
+
"use-sync-external-store": "^1.6.0"
|
| 5186 |
+
},
|
| 5187 |
+
"peerDependencies": {
|
| 5188 |
+
"react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 5189 |
+
}
|
| 5190 |
+
},
|
| 5191 |
+
"node_modules/throttleit": {
|
| 5192 |
+
"version": "2.1.0",
|
| 5193 |
+
"resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz",
|
| 5194 |
+
"integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==",
|
| 5195 |
+
"license": "MIT",
|
| 5196 |
+
"engines": {
|
| 5197 |
+
"node": ">=18"
|
| 5198 |
+
},
|
| 5199 |
+
"funding": {
|
| 5200 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 5201 |
+
}
|
| 5202 |
+
},
|
| 5203 |
"node_modules/tinyglobby": {
|
| 5204 |
"version": "0.2.15",
|
| 5205 |
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
|
|
|
| 5430 |
"punycode": "^2.1.0"
|
| 5431 |
}
|
| 5432 |
},
|
| 5433 |
+
"node_modules/use-sync-external-store": {
|
| 5434 |
+
"version": "1.6.0",
|
| 5435 |
+
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
| 5436 |
+
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
| 5437 |
+
"license": "MIT",
|
| 5438 |
+
"peer": true,
|
| 5439 |
+
"peerDependencies": {
|
| 5440 |
+
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 5441 |
+
}
|
| 5442 |
+
},
|
| 5443 |
"node_modules/vfile": {
|
| 5444 |
"version": "6.0.3",
|
| 5445 |
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
|
|
|
| 5584 |
"url": "https://github.com/sponsors/sindresorhus"
|
| 5585 |
}
|
| 5586 |
},
|
| 5587 |
+
"node_modules/zod": {
|
| 5588 |
+
"version": "4.3.6",
|
| 5589 |
+
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
| 5590 |
+
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
| 5591 |
+
"license": "MIT",
|
| 5592 |
+
"peer": true,
|
| 5593 |
+
"funding": {
|
| 5594 |
+
"url": "https://github.com/sponsors/colinhacks"
|
| 5595 |
+
}
|
| 5596 |
+
},
|
| 5597 |
"node_modules/zustand": {
|
| 5598 |
"version": "5.0.10",
|
| 5599 |
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz",
|
frontend/package.json
CHANGED
|
@@ -10,10 +10,12 @@
|
|
| 10 |
"preview": "vite preview"
|
| 11 |
},
|
| 12 |
"dependencies": {
|
|
|
|
| 13 |
"@emotion/react": "^11.13.0",
|
| 14 |
"@emotion/styled": "^11.13.0",
|
| 15 |
"@mui/icons-material": "^6.1.0",
|
| 16 |
"@mui/material": "^6.1.0",
|
|
|
|
| 17 |
"react": "^18.3.1",
|
| 18 |
"react-dom": "^18.3.1",
|
| 19 |
"react-markdown": "^9.0.1",
|
|
|
|
| 10 |
"preview": "vite preview"
|
| 11 |
},
|
| 12 |
"dependencies": {
|
| 13 |
+
"@ai-sdk/react": "^3.0.93",
|
| 14 |
"@emotion/react": "^11.13.0",
|
| 15 |
"@emotion/styled": "^11.13.0",
|
| 16 |
"@mui/icons-material": "^6.1.0",
|
| 17 |
"@mui/material": "^6.1.0",
|
| 18 |
+
"ai": "^6.0.91",
|
| 19 |
"react": "^18.3.1",
|
| 20 |
"react-dom": "^18.3.1",
|
| 21 |
"react-markdown": "^9.0.1",
|
frontend/src/App.tsx
CHANGED
|
@@ -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 />
|
frontend/src/components/ApprovalModal/ApprovalModal.tsx
DELETED
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/Chat/ActivityStatusBar.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Box, Typography } from '@mui/material';
|
| 2 |
+
import { keyframes } from '@mui/system';
|
| 3 |
+
import { useAgentStore, type ActivityStatus } from '@/store/agentStore';
|
| 4 |
+
|
| 5 |
+
const shimmer = keyframes`
|
| 6 |
+
0% { background-position: -100% center; }
|
| 7 |
+
50% { background-position: 200% center; }
|
| 8 |
+
100% { background-position: -100% center; }
|
| 9 |
+
`;
|
| 10 |
+
|
| 11 |
+
const TOOL_LABELS: Record<string, string> = {
|
| 12 |
+
hf_jobs: 'Running job',
|
| 13 |
+
hf_repo_files: 'Uploading file',
|
| 14 |
+
hf_repo_git: 'Git operation',
|
| 15 |
+
hf_inspect_dataset: 'Inspecting dataset',
|
| 16 |
+
hf_search: 'Searching',
|
| 17 |
+
plan_tool: 'Planning',
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
function statusLabel(status: ActivityStatus): string {
|
| 21 |
+
switch (status.type) {
|
| 22 |
+
case 'thinking': return 'Thinking';
|
| 23 |
+
case 'streaming': return 'Writing';
|
| 24 |
+
case 'tool': return TOOL_LABELS[status.toolName] || `Running ${status.toolName}`;
|
| 25 |
+
case 'waiting-approval': return 'Waiting for approval';
|
| 26 |
+
default: return '';
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
export default function ActivityStatusBar() {
|
| 31 |
+
const activityStatus = useAgentStore(s => s.activityStatus);
|
| 32 |
+
|
| 33 |
+
if (activityStatus.type === 'idle') return null;
|
| 34 |
+
|
| 35 |
+
const label = statusLabel(activityStatus);
|
| 36 |
+
|
| 37 |
+
return (
|
| 38 |
+
<Box sx={{ px: 2, py: 0.5, minHeight: 28, display: 'flex', alignItems: 'center' }}>
|
| 39 |
+
<Typography
|
| 40 |
+
sx={{
|
| 41 |
+
fontFamily: 'monospace',
|
| 42 |
+
fontSize: '0.72rem',
|
| 43 |
+
fontWeight: 500,
|
| 44 |
+
letterSpacing: '0.02em',
|
| 45 |
+
background: 'linear-gradient(90deg, var(--muted-text) 30%, var(--text) 50%, var(--muted-text) 70%)',
|
| 46 |
+
backgroundSize: '250% 100%',
|
| 47 |
+
backgroundClip: 'text',
|
| 48 |
+
WebkitBackgroundClip: 'text',
|
| 49 |
+
WebkitTextFillColor: 'transparent',
|
| 50 |
+
animation: `${shimmer} 4s ease-in-out infinite`,
|
| 51 |
+
}}
|
| 52 |
+
>
|
| 53 |
+
{label}…
|
| 54 |
+
</Typography>
|
| 55 |
+
</Box>
|
| 56 |
+
);
|
| 57 |
+
}
|
frontend/src/components/Chat/ApprovalFlow.tsx
DELETED
|
@@ -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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/components/Chat/AssistantMessage.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useMemo } from 'react';
|
| 2 |
+
import { Box, Stack, Typography } from '@mui/material';
|
| 3 |
+
import MarkdownContent from './MarkdownContent';
|
| 4 |
+
import ToolCallGroup from './ToolCallGroup';
|
| 5 |
+
import type { UIMessage } from 'ai';
|
| 6 |
+
import type { MessageMeta } from '@/types/agent';
|
| 7 |
+
|
| 8 |
+
interface AssistantMessageProps {
|
| 9 |
+
message: UIMessage;
|
| 10 |
+
isStreaming?: boolean;
|
| 11 |
+
approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>) => Promise<boolean>;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Groups consecutive tool parts together so they render as a single
|
| 16 |
+
* ToolCallGroup (visually identical to the old segments approach).
|
| 17 |
+
*/
|
| 18 |
+
type DynamicToolPart = Extract<UIMessage['parts'][number], { type: 'dynamic-tool' }>;
|
| 19 |
+
|
| 20 |
+
function groupParts(parts: UIMessage['parts']) {
|
| 21 |
+
const groups: Array<
|
| 22 |
+
| { kind: 'text'; text: string; idx: number }
|
| 23 |
+
| { kind: 'tools'; tools: DynamicToolPart[]; idx: number }
|
| 24 |
+
> = [];
|
| 25 |
+
|
| 26 |
+
for (let i = 0; i < parts.length; i++) {
|
| 27 |
+
const part = parts[i];
|
| 28 |
+
|
| 29 |
+
if (part.type === 'text') {
|
| 30 |
+
groups.push({ kind: 'text', text: part.text, idx: i });
|
| 31 |
+
} else if (part.type === 'dynamic-tool') {
|
| 32 |
+
const toolPart = part as DynamicToolPart;
|
| 33 |
+
const last = groups[groups.length - 1];
|
| 34 |
+
if (last?.kind === 'tools') {
|
| 35 |
+
last.tools.push(toolPart);
|
| 36 |
+
} else {
|
| 37 |
+
groups.push({ kind: 'tools', tools: [toolPart], idx: i });
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
// step-start, step-end, etc. are ignored visually
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
return groups;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export default function AssistantMessage({ message, isStreaming = false, approveTools }: AssistantMessageProps) {
|
| 47 |
+
const groups = useMemo(() => groupParts(message.parts), [message.parts]);
|
| 48 |
+
|
| 49 |
+
// Find the last text group index for streaming cursor
|
| 50 |
+
let lastTextIdx = -1;
|
| 51 |
+
for (let i = groups.length - 1; i >= 0; i--) {
|
| 52 |
+
if (groups[i].kind === 'text') { lastTextIdx = i; break; }
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const meta = message.metadata as MessageMeta | undefined;
|
| 56 |
+
const timeStr = meta?.createdAt
|
| 57 |
+
? new Date(meta.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
| 58 |
+
: null;
|
| 59 |
+
|
| 60 |
+
if (groups.length === 0) return null;
|
| 61 |
+
|
| 62 |
+
return (
|
| 63 |
+
<Box sx={{ minWidth: 0 }}>
|
| 64 |
+
<Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 0.5 }}>
|
| 65 |
+
<Typography
|
| 66 |
+
variant="caption"
|
| 67 |
+
sx={{
|
| 68 |
+
fontWeight: 700,
|
| 69 |
+
fontSize: '0.72rem',
|
| 70 |
+
color: 'var(--muted-text)',
|
| 71 |
+
textTransform: 'uppercase',
|
| 72 |
+
letterSpacing: '0.04em',
|
| 73 |
+
}}
|
| 74 |
+
>
|
| 75 |
+
Assistant
|
| 76 |
+
</Typography>
|
| 77 |
+
{timeStr && (
|
| 78 |
+
<Typography variant="caption" sx={{ color: 'var(--muted-text)', fontSize: '0.7rem' }}>
|
| 79 |
+
{timeStr}
|
| 80 |
+
</Typography>
|
| 81 |
+
)}
|
| 82 |
+
</Stack>
|
| 83 |
+
|
| 84 |
+
<Box
|
| 85 |
+
sx={{
|
| 86 |
+
maxWidth: { xs: '95%', md: '85%' },
|
| 87 |
+
bgcolor: 'var(--surface)',
|
| 88 |
+
borderRadius: 1.5,
|
| 89 |
+
borderTopLeftRadius: 4,
|
| 90 |
+
px: { xs: 1.5, md: 2.5 },
|
| 91 |
+
py: 1.5,
|
| 92 |
+
border: '1px solid var(--border)',
|
| 93 |
+
}}
|
| 94 |
+
>
|
| 95 |
+
{groups.map((group, i) => {
|
| 96 |
+
if (group.kind === 'text' && group.text) {
|
| 97 |
+
return (
|
| 98 |
+
<MarkdownContent
|
| 99 |
+
key={group.idx}
|
| 100 |
+
content={group.text}
|
| 101 |
+
isStreaming={isStreaming && i === lastTextIdx}
|
| 102 |
+
/>
|
| 103 |
+
);
|
| 104 |
+
}
|
| 105 |
+
if (group.kind === 'tools' && group.tools.length > 0) {
|
| 106 |
+
return (
|
| 107 |
+
<ToolCallGroup
|
| 108 |
+
key={group.idx}
|
| 109 |
+
tools={group.tools}
|
| 110 |
+
approveTools={approveTools}
|
| 111 |
+
/>
|
| 112 |
+
);
|
| 113 |
+
}
|
| 114 |
+
return null;
|
| 115 |
+
})}
|
| 116 |
+
</Box>
|
| 117 |
+
</Box>
|
| 118 |
+
);
|
| 119 |
+
}
|
frontend/src/components/Chat/ChatInput.tsx
CHANGED
|
@@ -1,14 +1,103 @@
|
|
| 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 |
|
| 5 |
interface ChatInputProps {
|
| 6 |
onSend: (text: string) => void;
|
| 7 |
disabled?: boolean;
|
|
|
|
| 8 |
}
|
| 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) {
|
|
@@ -27,26 +116,48 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
|
|
| 27 |
[handleSend]
|
| 28 |
);
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)',
|
|
@@ -61,9 +172,10 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
|
|
| 61 |
value={input}
|
| 62 |
onChange={(e) => setInput(e.target.value)}
|
| 63 |
onKeyDown={handleKeyDown}
|
| 64 |
-
placeholder=
|
| 65 |
disabled={disabled}
|
| 66 |
variant="standard"
|
|
|
|
| 67 |
InputProps={{
|
| 68 |
disableUnderline: true,
|
| 69 |
sx: {
|
|
@@ -72,7 +184,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 +211,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,
|
|
@@ -109,17 +221,108 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
|
|
| 109 |
{disabled ? <CircularProgress size={20} color="inherit" /> : <ArrowUpwardIcon fontSize="small" />}
|
| 110 |
</IconButton>
|
| 111 |
</Box>
|
| 112 |
-
|
| 113 |
{/* Powered By Badge */}
|
| 114 |
-
<Box
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
<Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--text)', fontWeight: 600, letterSpacing: '0.02em' }}>
|
| 120 |
-
|
| 121 |
</Typography>
|
|
|
|
| 122 |
</Box>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
</Box>
|
| 124 |
</Box>
|
| 125 |
);
|
|
|
|
| 1 |
+
import { useState, useCallback, useEffect, useRef, KeyboardEvent } from 'react';
|
| 2 |
+
import { Box, TextField, IconButton, CircularProgress, Typography, Menu, MenuItem, ListItemIcon, ListItemText, Chip } from '@mui/material';
|
| 3 |
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
| 4 |
+
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
|
| 5 |
+
import { apiFetch } from '@/utils/api';
|
| 6 |
+
|
| 7 |
+
// Model configuration
|
| 8 |
+
interface ModelOption {
|
| 9 |
+
id: string;
|
| 10 |
+
name: string;
|
| 11 |
+
description: string;
|
| 12 |
+
modelPath: string;
|
| 13 |
+
avatarUrl: string;
|
| 14 |
+
recommended?: boolean;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const getHfAvatarUrl = (modelId: string) => {
|
| 18 |
+
const org = modelId.split('/')[0];
|
| 19 |
+
return `https://huggingface.co/api/avatars/${org}`;
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
const MODEL_OPTIONS: ModelOption[] = [
|
| 23 |
+
{
|
| 24 |
+
id: 'minimax-m2.1',
|
| 25 |
+
name: 'MiniMax M2.1',
|
| 26 |
+
description: 'Via Novita',
|
| 27 |
+
modelPath: 'huggingface/novita/minimax/minimax-m2.1',
|
| 28 |
+
avatarUrl: getHfAvatarUrl('MiniMaxAI/MiniMax-M2.1'),
|
| 29 |
+
recommended: true,
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
id: 'claude-opus',
|
| 33 |
+
name: 'Claude Opus 4.5',
|
| 34 |
+
description: 'Anthropic',
|
| 35 |
+
modelPath: 'anthropic/claude-opus-4-5-20251101',
|
| 36 |
+
avatarUrl: 'https://huggingface.co/api/avatars/Anthropic',
|
| 37 |
+
recommended: true,
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
id: 'kimi-k2.5',
|
| 41 |
+
name: 'Kimi K2.5',
|
| 42 |
+
description: 'Via Novita',
|
| 43 |
+
modelPath: 'huggingface/novita/moonshotai/kimi-k2.5',
|
| 44 |
+
avatarUrl: getHfAvatarUrl('moonshotai/Kimi-K2.5'),
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
id: 'glm-5',
|
| 48 |
+
name: 'GLM 5',
|
| 49 |
+
description: 'Via Novita',
|
| 50 |
+
modelPath: 'huggingface/novita/zai-org/glm-5',
|
| 51 |
+
avatarUrl: getHfAvatarUrl('zai-org/GLM-5'),
|
| 52 |
+
},
|
| 53 |
+
];
|
| 54 |
+
|
| 55 |
+
const findModelByPath = (path: string): ModelOption | undefined => {
|
| 56 |
+
return MODEL_OPTIONS.find(m => m.modelPath === path || path?.includes(m.id));
|
| 57 |
+
};
|
| 58 |
|
| 59 |
interface ChatInputProps {
|
| 60 |
onSend: (text: string) => void;
|
| 61 |
disabled?: boolean;
|
| 62 |
+
placeholder?: string;
|
| 63 |
}
|
| 64 |
|
| 65 |
+
export default function ChatInput({ onSend, disabled = false, placeholder = 'Ask anything...' }: ChatInputProps) {
|
| 66 |
const [input, setInput] = useState('');
|
| 67 |
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
| 68 |
+
const [selectedModelId, setSelectedModelId] = useState<string>(() => {
|
| 69 |
+
try {
|
| 70 |
+
const stored = localStorage.getItem('hf-agent-model');
|
| 71 |
+
if (stored && MODEL_OPTIONS.some(m => m.id === stored)) return stored;
|
| 72 |
+
} catch { /* localStorage unavailable */ }
|
| 73 |
+
return MODEL_OPTIONS[0].id;
|
| 74 |
+
});
|
| 75 |
+
const [modelAnchorEl, setModelAnchorEl] = useState<null | HTMLElement>(null);
|
| 76 |
+
|
| 77 |
+
// Sync with backend on mount (backend is source of truth, localStorage is just a cache)
|
| 78 |
+
useEffect(() => {
|
| 79 |
+
fetch('/api/config/model')
|
| 80 |
+
.then((res) => (res.ok ? res.json() : null))
|
| 81 |
+
.then((data) => {
|
| 82 |
+
if (data?.current) {
|
| 83 |
+
const model = findModelByPath(data.current);
|
| 84 |
+
if (model) {
|
| 85 |
+
setSelectedModelId(model.id);
|
| 86 |
+
try { localStorage.setItem('hf-agent-model', model.id); } catch { /* ignore */ }
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
})
|
| 90 |
+
.catch(() => { /* ignore */ });
|
| 91 |
+
}, []);
|
| 92 |
+
|
| 93 |
+
const selectedModel = MODEL_OPTIONS.find(m => m.id === selectedModelId) || MODEL_OPTIONS[0];
|
| 94 |
+
|
| 95 |
+
// Auto-focus the textarea when the session becomes ready (disabled -> false)
|
| 96 |
+
useEffect(() => {
|
| 97 |
+
if (!disabled && inputRef.current) {
|
| 98 |
+
inputRef.current.focus();
|
| 99 |
+
}
|
| 100 |
+
}, [disabled]);
|
| 101 |
|
| 102 |
const handleSend = useCallback(() => {
|
| 103 |
if (input.trim() && !disabled) {
|
|
|
|
| 116 |
[handleSend]
|
| 117 |
);
|
| 118 |
|
| 119 |
+
const handleModelClick = (event: React.MouseEvent<HTMLElement>) => {
|
| 120 |
+
setModelAnchorEl(event.currentTarget);
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
const handleModelClose = () => {
|
| 124 |
+
setModelAnchorEl(null);
|
| 125 |
+
};
|
| 126 |
+
|
| 127 |
+
const handleSelectModel = async (model: ModelOption) => {
|
| 128 |
+
handleModelClose();
|
| 129 |
+
try {
|
| 130 |
+
const res = await apiFetch('/api/config/model', {
|
| 131 |
+
method: 'POST',
|
| 132 |
+
body: JSON.stringify({ model: model.modelPath }),
|
| 133 |
+
});
|
| 134 |
+
if (res.ok) {
|
| 135 |
+
setSelectedModelId(model.id);
|
| 136 |
+
try { localStorage.setItem('hf-agent-model', model.id); } catch { /* ignore */ }
|
| 137 |
+
}
|
| 138 |
+
} catch { /* ignore */ }
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
return (
|
| 142 |
<Box
|
| 143 |
sx={{
|
| 144 |
+
pb: { xs: 2, md: 4 },
|
| 145 |
+
pt: { xs: 1, md: 2 },
|
| 146 |
position: 'relative',
|
| 147 |
zIndex: 10,
|
| 148 |
}}
|
| 149 |
>
|
| 150 |
+
<Box sx={{ maxWidth: '880px', mx: 'auto', width: '100%', px: { xs: 0, sm: 1, md: 2 } }}>
|
| 151 |
<Box
|
| 152 |
className="composer"
|
| 153 |
sx={{
|
| 154 |
display: 'flex',
|
| 155 |
gap: '10px',
|
| 156 |
alignItems: 'flex-start',
|
| 157 |
+
bgcolor: 'var(--composer-bg)',
|
| 158 |
borderRadius: 'var(--radius-md)',
|
| 159 |
p: '12px',
|
| 160 |
+
border: '1px solid var(--border)',
|
| 161 |
transition: 'box-shadow 0.2s ease, border-color 0.2s ease',
|
| 162 |
'&:focus-within': {
|
| 163 |
borderColor: 'var(--accent-yellow)',
|
|
|
|
| 172 |
value={input}
|
| 173 |
onChange={(e) => setInput(e.target.value)}
|
| 174 |
onKeyDown={handleKeyDown}
|
| 175 |
+
placeholder={placeholder}
|
| 176 |
disabled={disabled}
|
| 177 |
variant="standard"
|
| 178 |
+
inputRef={inputRef}
|
| 179 |
InputProps={{
|
| 180 |
disableUnderline: true,
|
| 181 |
sx: {
|
|
|
|
| 184 |
fontFamily: 'inherit',
|
| 185 |
padding: 0,
|
| 186 |
lineHeight: 1.5,
|
| 187 |
+
minHeight: { xs: '44px', md: '56px' },
|
| 188 |
alignItems: 'flex-start',
|
| 189 |
}
|
| 190 |
}}
|
|
|
|
| 211 |
transition: 'all 0.2s',
|
| 212 |
'&:hover': {
|
| 213 |
color: 'var(--accent-yellow)',
|
| 214 |
+
bgcolor: 'var(--hover-bg)',
|
| 215 |
},
|
| 216 |
'&.Mui-disabled': {
|
| 217 |
opacity: 0.3,
|
|
|
|
| 221 |
{disabled ? <CircularProgress size={20} color="inherit" /> : <ArrowUpwardIcon fontSize="small" />}
|
| 222 |
</IconButton>
|
| 223 |
</Box>
|
| 224 |
+
|
| 225 |
{/* Powered By Badge */}
|
| 226 |
+
<Box
|
| 227 |
+
onClick={handleModelClick}
|
| 228 |
+
sx={{
|
| 229 |
+
display: 'flex',
|
| 230 |
+
alignItems: 'center',
|
| 231 |
+
justifyContent: 'center',
|
| 232 |
+
mt: 1.5,
|
| 233 |
+
gap: 0.8,
|
| 234 |
+
opacity: 0.6,
|
| 235 |
+
cursor: 'pointer',
|
| 236 |
+
transition: 'opacity 0.2s',
|
| 237 |
+
'&:hover': {
|
| 238 |
+
opacity: 1
|
| 239 |
+
}
|
| 240 |
+
}}
|
| 241 |
+
>
|
| 242 |
<Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
|
| 243 |
powered by
|
| 244 |
</Typography>
|
| 245 |
+
<img
|
| 246 |
+
src={selectedModel.avatarUrl}
|
| 247 |
+
alt={selectedModel.name}
|
| 248 |
+
style={{ height: '14px', width: '14px', objectFit: 'contain', borderRadius: '2px' }}
|
| 249 |
+
/>
|
| 250 |
<Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--text)', fontWeight: 600, letterSpacing: '0.02em' }}>
|
| 251 |
+
{selectedModel.name}
|
| 252 |
</Typography>
|
| 253 |
+
<ArrowDropDownIcon sx={{ fontSize: '14px', color: 'var(--muted-text)' }} />
|
| 254 |
</Box>
|
| 255 |
+
|
| 256 |
+
{/* Model Selection Menu */}
|
| 257 |
+
<Menu
|
| 258 |
+
anchorEl={modelAnchorEl}
|
| 259 |
+
open={Boolean(modelAnchorEl)}
|
| 260 |
+
onClose={handleModelClose}
|
| 261 |
+
anchorOrigin={{
|
| 262 |
+
vertical: 'top',
|
| 263 |
+
horizontal: 'center',
|
| 264 |
+
}}
|
| 265 |
+
transformOrigin={{
|
| 266 |
+
vertical: 'bottom',
|
| 267 |
+
horizontal: 'center',
|
| 268 |
+
}}
|
| 269 |
+
slotProps={{
|
| 270 |
+
paper: {
|
| 271 |
+
sx: {
|
| 272 |
+
bgcolor: 'var(--panel)',
|
| 273 |
+
border: '1px solid var(--divider)',
|
| 274 |
+
mb: 1,
|
| 275 |
+
maxHeight: '400px',
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
}}
|
| 279 |
+
>
|
| 280 |
+
{MODEL_OPTIONS.map((model) => (
|
| 281 |
+
<MenuItem
|
| 282 |
+
key={model.id}
|
| 283 |
+
onClick={() => handleSelectModel(model)}
|
| 284 |
+
selected={selectedModelId === model.id}
|
| 285 |
+
sx={{
|
| 286 |
+
py: 1.5,
|
| 287 |
+
'&.Mui-selected': {
|
| 288 |
+
bgcolor: 'rgba(255,255,255,0.05)',
|
| 289 |
+
}
|
| 290 |
+
}}
|
| 291 |
+
>
|
| 292 |
+
<ListItemIcon>
|
| 293 |
+
<img
|
| 294 |
+
src={model.avatarUrl}
|
| 295 |
+
alt={model.name}
|
| 296 |
+
style={{ width: 24, height: 24, borderRadius: '4px', objectFit: 'cover' }}
|
| 297 |
+
/>
|
| 298 |
+
</ListItemIcon>
|
| 299 |
+
<ListItemText
|
| 300 |
+
primary={
|
| 301 |
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
| 302 |
+
{model.name}
|
| 303 |
+
{model.recommended && (
|
| 304 |
+
<Chip
|
| 305 |
+
label="Recommended"
|
| 306 |
+
size="small"
|
| 307 |
+
sx={{
|
| 308 |
+
height: '18px',
|
| 309 |
+
fontSize: '10px',
|
| 310 |
+
bgcolor: 'var(--accent-yellow)',
|
| 311 |
+
color: '#000',
|
| 312 |
+
fontWeight: 600,
|
| 313 |
+
}}
|
| 314 |
+
/>
|
| 315 |
+
)}
|
| 316 |
+
</Box>
|
| 317 |
+
}
|
| 318 |
+
secondary={model.description}
|
| 319 |
+
secondaryTypographyProps={{
|
| 320 |
+
sx: { fontSize: '12px', color: 'var(--muted-text)' }
|
| 321 |
+
}}
|
| 322 |
+
/>
|
| 323 |
+
</MenuItem>
|
| 324 |
+
))}
|
| 325 |
+
</Menu>
|
| 326 |
</Box>
|
| 327 |
</Box>
|
| 328 |
);
|
frontend/src/components/Chat/MarkdownContent.tsx
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
/**
|
| 98 |
+
* Throttled content for streaming: render the full markdown through
|
| 99 |
+
* ReactMarkdown but only re-parse every ~80ms to avoid layout thrashing.
|
| 100 |
+
* This is the Claude approach — always render as markdown, never split
|
| 101 |
+
* into raw text. The parser handles incomplete tables gracefully.
|
| 102 |
+
*/
|
| 103 |
+
function useThrottledValue(value: string, isStreaming: boolean, intervalMs = 80): string {
|
| 104 |
+
const [throttled, setThrottled] = useState(value);
|
| 105 |
+
const lastUpdate = useRef(0);
|
| 106 |
+
const pending = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 107 |
+
const latestValue = useRef(value);
|
| 108 |
+
latestValue.current = value;
|
| 109 |
+
|
| 110 |
+
useEffect(() => {
|
| 111 |
+
if (!isStreaming) {
|
| 112 |
+
// Not streaming — always use latest value immediately
|
| 113 |
+
setThrottled(value);
|
| 114 |
+
return;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
const now = Date.now();
|
| 118 |
+
const elapsed = now - lastUpdate.current;
|
| 119 |
+
|
| 120 |
+
if (elapsed >= intervalMs) {
|
| 121 |
+
// Enough time passed — update immediately
|
| 122 |
+
setThrottled(value);
|
| 123 |
+
lastUpdate.current = now;
|
| 124 |
+
} else {
|
| 125 |
+
// Schedule an update for the remaining time
|
| 126 |
+
if (pending.current) clearTimeout(pending.current);
|
| 127 |
+
pending.current = setTimeout(() => {
|
| 128 |
+
setThrottled(latestValue.current);
|
| 129 |
+
lastUpdate.current = Date.now();
|
| 130 |
+
pending.current = null;
|
| 131 |
+
}, intervalMs - elapsed);
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
return () => {
|
| 135 |
+
if (pending.current) clearTimeout(pending.current);
|
| 136 |
+
};
|
| 137 |
+
}, [value, isStreaming, intervalMs]);
|
| 138 |
+
|
| 139 |
+
// When streaming ends, flush immediately
|
| 140 |
+
useEffect(() => {
|
| 141 |
+
if (!isStreaming) {
|
| 142 |
+
setThrottled(latestValue.current);
|
| 143 |
+
}
|
| 144 |
+
}, [isStreaming]);
|
| 145 |
+
|
| 146 |
+
return throttled;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
export default function MarkdownContent({ content, sx, isStreaming = false }: MarkdownContentProps) {
|
| 150 |
+
// Throttle re-parses during streaming to ~12fps (every 80ms)
|
| 151 |
+
const displayContent = useThrottledValue(content, isStreaming);
|
| 152 |
+
|
| 153 |
+
const remarkPlugins = useMemo(() => [remarkGfm], []);
|
| 154 |
+
|
| 155 |
+
return (
|
| 156 |
+
<Box sx={[markdownSx, ...(Array.isArray(sx) ? sx : sx ? [sx] : [])]}>
|
| 157 |
+
<ReactMarkdown remarkPlugins={remarkPlugins}>{displayContent}</ReactMarkdown>
|
| 158 |
+
</Box>
|
| 159 |
+
);
|
| 160 |
+
}
|
frontend/src/components/Chat/MessageBubble.tsx
CHANGED
|
@@ -1,215 +1,44 @@
|
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
}
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
}
|
| 23 |
-
};
|
| 24 |
-
|
| 25 |
-
return (
|
| 26 |
-
<Box
|
| 27 |
-
sx={{
|
| 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 |
-
<
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
return (
|
| 179 |
-
<Box
|
| 180 |
-
sx={{
|
| 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 |
-
className="meta"
|
| 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 { UIMessage } from 'ai';
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
interface MessageBubbleProps {
|
| 6 |
+
message: UIMessage;
|
| 7 |
+
isLastTurn?: boolean;
|
| 8 |
+
onUndoTurn?: () => void;
|
| 9 |
+
isProcessing?: boolean;
|
| 10 |
+
isStreaming?: boolean;
|
| 11 |
+
approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>) => Promise<boolean>;
|
| 12 |
}
|
| 13 |
|
| 14 |
+
export default function MessageBubble({
|
| 15 |
+
message,
|
| 16 |
+
isLastTurn = false,
|
| 17 |
+
onUndoTurn,
|
| 18 |
+
isProcessing = false,
|
| 19 |
+
isStreaming = false,
|
| 20 |
+
approveTools,
|
| 21 |
+
}: MessageBubbleProps) {
|
| 22 |
+
if (message.role === 'user') {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
return (
|
| 24 |
+
<UserMessage
|
| 25 |
+
message={message}
|
| 26 |
+
isLastTurn={isLastTurn}
|
| 27 |
+
onUndoTurn={onUndoTurn}
|
| 28 |
+
isProcessing={isProcessing}
|
| 29 |
+
/>
|
| 30 |
);
|
| 31 |
}
|
| 32 |
|
| 33 |
+
if (message.role === 'assistant') {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
return (
|
| 35 |
+
<AssistantMessage
|
| 36 |
+
message={message}
|
| 37 |
+
isStreaming={isStreaming}
|
| 38 |
+
approveTools={approveTools}
|
| 39 |
+
/>
|
| 40 |
);
|
| 41 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
+
return null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
}
|
frontend/src/components/Chat/MessageList.tsx
CHANGED
|
@@ -1,100 +1,151 @@
|
|
| 1 |
-
import { useEffect, useRef } from 'react';
|
| 2 |
-
import { Box, Typography } from '@mui/material';
|
| 3 |
-
import { useSessionStore } from '@/store/sessionStore';
|
| 4 |
import MessageBubble from './MessageBubble';
|
| 5 |
-
import
|
|
|
|
|
|
|
| 6 |
|
| 7 |
interface MessageListProps {
|
| 8 |
-
messages:
|
| 9 |
isProcessing: boolean;
|
|
|
|
|
|
|
| 10 |
}
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
width: '1em',
|
| 24 |
-
letterSpacing: '-3px',
|
| 25 |
-
transform: 'scale(0.6) translateY(-2px)',
|
| 26 |
-
'&::after': {
|
| 27 |
-
content: '""',
|
| 28 |
-
animation: 'dots 2s steps(4, end) infinite',
|
| 29 |
-
},
|
| 30 |
-
'@keyframes dots': {
|
| 31 |
-
'0%': { content: '""' },
|
| 32 |
-
'25%': { content: '"."' },
|
| 33 |
-
'50%': { content: '".."' },
|
| 34 |
-
'75%, 100%': { content: '"..."' },
|
| 35 |
-
},
|
| 36 |
-
}}
|
| 37 |
-
/>
|
| 38 |
-
);
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
-
// Auto-scroll to bottom when new messages arrive
|
| 45 |
useEffect(() => {
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
return (
|
| 50 |
<Box
|
|
|
|
| 51 |
sx={{
|
| 52 |
flex: 1,
|
| 53 |
overflow: 'auto',
|
| 54 |
-
|
|
|
|
| 55 |
display: 'flex',
|
| 56 |
flexDirection: 'column',
|
| 57 |
}}
|
| 58 |
>
|
| 59 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
{messages.length === 0 && !isProcessing ? (
|
| 61 |
-
<
|
| 62 |
-
sx={{
|
| 63 |
-
flex: 1,
|
| 64 |
-
display: 'flex',
|
| 65 |
-
alignItems: 'center',
|
| 66 |
-
justifyContent: 'center',
|
| 67 |
-
py: 8,
|
| 68 |
-
}}
|
| 69 |
-
>
|
| 70 |
-
<Typography color="text.secondary" sx={{ fontFamily: 'monospace' }}>
|
| 71 |
-
Awaiting input…
|
| 72 |
-
</Typography>
|
| 73 |
-
</Box>
|
| 74 |
) : (
|
| 75 |
-
messages.map((
|
| 76 |
-
<MessageBubble
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<div ref={bottomRef} />
|
| 97 |
-
</Box>
|
| 98 |
</Box>
|
| 99 |
);
|
| 100 |
-
}
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useRef, useMemo } from 'react';
|
| 2 |
+
import { Box, Stack, Typography } from '@mui/material';
|
|
|
|
| 3 |
import MessageBubble from './MessageBubble';
|
| 4 |
+
import ActivityStatusBar from './ActivityStatusBar';
|
| 5 |
+
import { useAgentStore } from '@/store/agentStore';
|
| 6 |
+
import type { UIMessage } from 'ai';
|
| 7 |
|
| 8 |
interface MessageListProps {
|
| 9 |
+
messages: UIMessage[];
|
| 10 |
isProcessing: boolean;
|
| 11 |
+
approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>) => Promise<boolean>;
|
| 12 |
+
onUndoLastTurn: () => void | Promise<void>;
|
| 13 |
}
|
| 14 |
|
| 15 |
+
function getGreeting(): string {
|
| 16 |
+
const h = new Date().getHours();
|
| 17 |
+
if (h < 12) return 'Morning';
|
| 18 |
+
if (h < 17) return 'Afternoon';
|
| 19 |
+
return 'Evening';
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function WelcomeGreeting() {
|
| 23 |
+
const { user } = useAgentStore();
|
| 24 |
+
const firstName = user?.name?.split(' ')[0] || user?.username;
|
| 25 |
+
const greeting = firstName ? `${getGreeting()}, ${firstName}` : getGreeting();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
+
return (
|
| 28 |
+
<Box
|
| 29 |
+
sx={{
|
| 30 |
+
flex: 1,
|
| 31 |
+
display: 'flex',
|
| 32 |
+
flexDirection: 'column',
|
| 33 |
+
alignItems: 'center',
|
| 34 |
+
justifyContent: 'center',
|
| 35 |
+
py: 8,
|
| 36 |
+
gap: 1.5,
|
| 37 |
+
}}
|
| 38 |
+
>
|
| 39 |
+
<Typography
|
| 40 |
+
sx={{
|
| 41 |
+
fontFamily: 'monospace',
|
| 42 |
+
fontSize: '1.6rem',
|
| 43 |
+
color: 'var(--text)',
|
| 44 |
+
fontWeight: 600,
|
| 45 |
+
}}
|
| 46 |
+
>
|
| 47 |
+
{greeting}
|
| 48 |
+
</Typography>
|
| 49 |
+
<Typography
|
| 50 |
+
color="text.secondary"
|
| 51 |
+
sx={{ fontFamily: 'monospace', fontSize: '0.9rem' }}
|
| 52 |
+
>
|
| 53 |
+
Let's build something impressive?
|
| 54 |
+
</Typography>
|
| 55 |
+
</Box>
|
| 56 |
+
);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export default function MessageList({ messages, isProcessing, approveTools, onUndoLastTurn }: MessageListProps) {
|
| 60 |
+
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
| 61 |
+
const stickToBottom = useRef(true);
|
| 62 |
+
|
| 63 |
+
const scrollToBottom = useCallback(() => {
|
| 64 |
+
const el = scrollContainerRef.current;
|
| 65 |
+
if (el) el.scrollTop = el.scrollHeight;
|
| 66 |
+
}, []);
|
| 67 |
+
|
| 68 |
+
useEffect(() => {
|
| 69 |
+
const el = scrollContainerRef.current;
|
| 70 |
+
if (!el) return;
|
| 71 |
+
const onScroll = () => {
|
| 72 |
+
const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
| 73 |
+
stickToBottom.current = distFromBottom < 80;
|
| 74 |
+
};
|
| 75 |
+
el.addEventListener('scroll', onScroll, { passive: true });
|
| 76 |
+
return () => el.removeEventListener('scroll', onScroll);
|
| 77 |
+
}, []);
|
| 78 |
+
|
| 79 |
+
useEffect(() => {
|
| 80 |
+
if (stickToBottom.current) scrollToBottom();
|
| 81 |
+
}, [messages, isProcessing, scrollToBottom]);
|
| 82 |
|
|
|
|
| 83 |
useEffect(() => {
|
| 84 |
+
const el = scrollContainerRef.current;
|
| 85 |
+
if (!el) return;
|
| 86 |
+
const observer = new MutationObserver(() => {
|
| 87 |
+
if (stickToBottom.current) el.scrollTop = el.scrollHeight;
|
| 88 |
+
});
|
| 89 |
+
observer.observe(el, { childList: true, subtree: true, characterData: true });
|
| 90 |
+
return () => observer.disconnect();
|
| 91 |
+
}, []);
|
| 92 |
+
|
| 93 |
+
const lastUserMsgId = useMemo(() => {
|
| 94 |
+
for (let i = messages.length - 1; i >= 0; i--) {
|
| 95 |
+
if (messages[i].role === 'user') return messages[i].id;
|
| 96 |
+
}
|
| 97 |
+
return null;
|
| 98 |
+
}, [messages]);
|
| 99 |
+
|
| 100 |
+
// The last assistant message is "streaming" when we're processing
|
| 101 |
+
const lastAssistantId = useMemo(() => {
|
| 102 |
+
for (let i = messages.length - 1; i >= 0; i--) {
|
| 103 |
+
if (messages[i].role === 'assistant') return messages[i].id;
|
| 104 |
+
}
|
| 105 |
+
return null;
|
| 106 |
+
}, [messages]);
|
| 107 |
|
| 108 |
return (
|
| 109 |
<Box
|
| 110 |
+
ref={scrollContainerRef}
|
| 111 |
sx={{
|
| 112 |
flex: 1,
|
| 113 |
overflow: 'auto',
|
| 114 |
+
px: { xs: 0.5, sm: 1, md: 2 },
|
| 115 |
+
py: { xs: 2, md: 3 },
|
| 116 |
display: 'flex',
|
| 117 |
flexDirection: 'column',
|
| 118 |
}}
|
| 119 |
>
|
| 120 |
+
<Stack
|
| 121 |
+
spacing={3}
|
| 122 |
+
sx={{
|
| 123 |
+
maxWidth: 880,
|
| 124 |
+
mx: 'auto',
|
| 125 |
+
width: '100%',
|
| 126 |
+
flex: messages.length === 0 && !isProcessing ? 1 : undefined,
|
| 127 |
+
}}
|
| 128 |
+
>
|
| 129 |
{messages.length === 0 && !isProcessing ? (
|
| 130 |
+
<WelcomeGreeting />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
) : (
|
| 132 |
+
messages.map((msg) => (
|
| 133 |
+
<MessageBubble
|
| 134 |
+
key={msg.id}
|
| 135 |
+
message={msg}
|
| 136 |
+
isLastTurn={msg.id === lastUserMsgId}
|
| 137 |
+
onUndoTurn={onUndoLastTurn}
|
| 138 |
+
isProcessing={isProcessing}
|
| 139 |
+
isStreaming={isProcessing && msg.id === lastAssistantId}
|
| 140 |
+
approveTools={approveTools}
|
| 141 |
+
/>
|
| 142 |
))
|
| 143 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
+
<ActivityStatusBar />
|
| 146 |
+
|
| 147 |
+
<div />
|
| 148 |
+
</Stack>
|
|
|
|
|
|
|
|
|
|
| 149 |
</Box>
|
| 150 |
);
|
| 151 |
+
}
|
frontend/src/components/Chat/ThinkingIndicator.tsx
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Box, Typography } from '@mui/material';
|
| 2 |
+
|
| 3 |
+
/** Pulsing dots shown while the agent is processing. */
|
| 4 |
+
export default function ThinkingIndicator() {
|
| 5 |
+
return (
|
| 6 |
+
<Box sx={{ pt: 0.75 }}>
|
| 7 |
+
<Typography
|
| 8 |
+
variant="caption"
|
| 9 |
+
sx={{
|
| 10 |
+
fontWeight: 700,
|
| 11 |
+
fontSize: '0.72rem',
|
| 12 |
+
color: 'var(--muted-text)',
|
| 13 |
+
textTransform: 'uppercase',
|
| 14 |
+
letterSpacing: '0.04em',
|
| 15 |
+
display: 'flex',
|
| 16 |
+
alignItems: 'center',
|
| 17 |
+
gap: 0.75,
|
| 18 |
+
}}
|
| 19 |
+
>
|
| 20 |
+
Thinking
|
| 21 |
+
<Box
|
| 22 |
+
component="span"
|
| 23 |
+
sx={{
|
| 24 |
+
display: 'inline-flex',
|
| 25 |
+
gap: '3px',
|
| 26 |
+
'& span': {
|
| 27 |
+
width: 4,
|
| 28 |
+
height: 4,
|
| 29 |
+
borderRadius: '50%',
|
| 30 |
+
bgcolor: 'primary.main',
|
| 31 |
+
animation: 'dotPulse 1.4s ease-in-out infinite',
|
| 32 |
+
},
|
| 33 |
+
'& span:nth-of-type(2)': { animationDelay: '0.2s' },
|
| 34 |
+
'& span:nth-of-type(3)': { animationDelay: '0.4s' },
|
| 35 |
+
'@keyframes dotPulse': {
|
| 36 |
+
'0%, 80%, 100%': { opacity: 0.25, transform: 'scale(0.8)' },
|
| 37 |
+
'40%': { opacity: 1, transform: 'scale(1)' },
|
| 38 |
+
},
|
| 39 |
+
}}
|
| 40 |
+
>
|
| 41 |
+
<span />
|
| 42 |
+
<span />
|
| 43 |
+
<span />
|
| 44 |
+
</Box>
|
| 45 |
+
</Typography>
|
| 46 |
+
</Box>
|
| 47 |
+
);
|
| 48 |
+
}
|
frontend/src/components/Chat/ToolCallGroup.tsx
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useMemo, useRef, useState } from 'react';
|
| 2 |
+
import { Box, Stack, Typography, Chip, Button, TextField, IconButton, Link, CircularProgress } from '@mui/material';
|
| 3 |
+
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
|
| 4 |
+
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
|
| 5 |
+
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
| 6 |
+
import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty';
|
| 7 |
+
import LaunchIcon from '@mui/icons-material/Launch';
|
| 8 |
+
import SendIcon from '@mui/icons-material/Send';
|
| 9 |
+
import BlockIcon from '@mui/icons-material/Block';
|
| 10 |
+
import { useAgentStore } from '@/store/agentStore';
|
| 11 |
+
import { useLayoutStore } from '@/store/layoutStore';
|
| 12 |
+
import { logger } from '@/utils/logger';
|
| 13 |
+
import type { UIMessage } from 'ai';
|
| 14 |
+
|
| 15 |
+
// ---------------------------------------------------------------------------
|
| 16 |
+
// Type helpers — extract the dynamic-tool part type from UIMessage
|
| 17 |
+
// ---------------------------------------------------------------------------
|
| 18 |
+
type DynamicToolPart = Extract<UIMessage['parts'][number], { type: 'dynamic-tool' }>;
|
| 19 |
+
|
| 20 |
+
type ToolPartState = DynamicToolPart['state'];
|
| 21 |
+
|
| 22 |
+
interface ToolCallGroupProps {
|
| 23 |
+
tools: DynamicToolPart[];
|
| 24 |
+
approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null; edited_script?: string | null }>) => Promise<boolean>;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
// ---------------------------------------------------------------------------
|
| 28 |
+
// Visual helpers
|
| 29 |
+
// ---------------------------------------------------------------------------
|
| 30 |
+
|
| 31 |
+
function StatusIcon({ state }: { state: ToolPartState }) {
|
| 32 |
+
switch (state) {
|
| 33 |
+
case 'approval-requested':
|
| 34 |
+
return <HourglassEmptyIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />;
|
| 35 |
+
case 'output-available':
|
| 36 |
+
return <CheckCircleOutlineIcon sx={{ fontSize: 16, color: 'success.main' }} />;
|
| 37 |
+
case 'output-error':
|
| 38 |
+
return <ErrorOutlineIcon sx={{ fontSize: 16, color: 'error.main' }} />;
|
| 39 |
+
case 'output-denied':
|
| 40 |
+
return <BlockIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />;
|
| 41 |
+
case 'input-streaming':
|
| 42 |
+
case 'input-available':
|
| 43 |
+
default:
|
| 44 |
+
return <CircularProgress size={14} thickness={5} sx={{ color: 'var(--accent-yellow)' }} />;
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
function statusLabel(state: ToolPartState): string | null {
|
| 49 |
+
switch (state) {
|
| 50 |
+
case 'approval-requested': return 'awaiting approval';
|
| 51 |
+
case 'input-streaming':
|
| 52 |
+
case 'input-available': return 'running';
|
| 53 |
+
case 'output-denied': return 'denied';
|
| 54 |
+
case 'output-error': return 'error';
|
| 55 |
+
default: return null;
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function statusColor(state: ToolPartState): string {
|
| 60 |
+
switch (state) {
|
| 61 |
+
case 'approval-requested': return 'var(--accent-yellow)';
|
| 62 |
+
case 'output-available': return 'var(--accent-green)';
|
| 63 |
+
case 'output-error': return 'var(--accent-red)';
|
| 64 |
+
case 'output-denied': return 'var(--muted-text)';
|
| 65 |
+
default: return 'var(--accent-yellow)';
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// ---------------------------------------------------------------------------
|
| 70 |
+
// Inline approval UI (per-tool)
|
| 71 |
+
// ---------------------------------------------------------------------------
|
| 72 |
+
|
| 73 |
+
function InlineApproval({
|
| 74 |
+
toolCallId,
|
| 75 |
+
toolName,
|
| 76 |
+
input,
|
| 77 |
+
scriptLabel,
|
| 78 |
+
onResolve,
|
| 79 |
+
}: {
|
| 80 |
+
toolCallId: string;
|
| 81 |
+
toolName: string;
|
| 82 |
+
input: unknown;
|
| 83 |
+
scriptLabel: string;
|
| 84 |
+
onResolve: (toolCallId: string, approved: boolean, feedback?: string) => void;
|
| 85 |
+
}) {
|
| 86 |
+
const [feedback, setFeedback] = useState('');
|
| 87 |
+
const args = input as Record<string, unknown> | undefined;
|
| 88 |
+
const { setPanel, getEditedScript } = useAgentStore();
|
| 89 |
+
const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
|
| 90 |
+
const hasEditedScript = !!getEditedScript(toolCallId);
|
| 91 |
+
|
| 92 |
+
const handleScriptClick = useCallback(() => {
|
| 93 |
+
if (toolName === 'hf_jobs' && args?.script) {
|
| 94 |
+
const scriptContent = getEditedScript(toolCallId) || String(args.script);
|
| 95 |
+
setPanel(
|
| 96 |
+
{ title: scriptLabel, script: { content: scriptContent, language: 'python' }, parameters: { tool_call_id: toolCallId } },
|
| 97 |
+
'script',
|
| 98 |
+
true,
|
| 99 |
+
);
|
| 100 |
+
setRightPanelOpen(true);
|
| 101 |
+
setLeftSidebarOpen(false);
|
| 102 |
+
}
|
| 103 |
+
}, [toolCallId, toolName, args, scriptLabel, setPanel, getEditedScript, setRightPanelOpen, setLeftSidebarOpen]);
|
| 104 |
+
|
| 105 |
+
return (
|
| 106 |
+
<Box sx={{ px: 1.5, py: 1.5, borderTop: '1px solid var(--tool-border)' }}>
|
| 107 |
+
{toolName === 'hf_jobs' && args && (
|
| 108 |
+
<Box sx={{ mb: 1.5 }}>
|
| 109 |
+
<Typography variant="body2" sx={{ color: 'var(--muted-text)', fontSize: '0.75rem', mb: 1 }}>
|
| 110 |
+
Execute <Box component="span" sx={{ color: 'var(--accent-yellow)', fontWeight: 500 }}>{scriptLabel.replace('Script', 'Job')}</Box> on{' '}
|
| 111 |
+
<Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>
|
| 112 |
+
{String(args.hardware_flavor || 'default')}
|
| 113 |
+
</Box>
|
| 114 |
+
{!!args.timeout && (
|
| 115 |
+
<> with timeout <Box component="span" sx={{ fontWeight: 500, color: 'var(--text)' }}>
|
| 116 |
+
{String(args.timeout)}
|
| 117 |
+
</Box></>
|
| 118 |
+
)}
|
| 119 |
+
</Typography>
|
| 120 |
+
{typeof args.script === 'string' && args.script && (
|
| 121 |
+
<Box
|
| 122 |
+
onClick={handleScriptClick}
|
| 123 |
+
sx={{
|
| 124 |
+
mt: 0.5,
|
| 125 |
+
p: 1.5,
|
| 126 |
+
bgcolor: 'var(--code-panel-bg)',
|
| 127 |
+
border: '1px solid var(--tool-border)',
|
| 128 |
+
borderRadius: '8px',
|
| 129 |
+
cursor: 'pointer',
|
| 130 |
+
transition: 'border-color 0.15s ease',
|
| 131 |
+
'&:hover': { borderColor: 'var(--accent-yellow)' },
|
| 132 |
+
}}
|
| 133 |
+
>
|
| 134 |
+
<Box
|
| 135 |
+
component="pre"
|
| 136 |
+
sx={{
|
| 137 |
+
m: 0,
|
| 138 |
+
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, monospace',
|
| 139 |
+
fontSize: '0.7rem',
|
| 140 |
+
lineHeight: 1.5,
|
| 141 |
+
color: 'var(--text)',
|
| 142 |
+
overflow: 'hidden',
|
| 143 |
+
display: '-webkit-box',
|
| 144 |
+
WebkitLineClamp: 3,
|
| 145 |
+
WebkitBoxOrient: 'vertical',
|
| 146 |
+
whiteSpace: 'pre-wrap',
|
| 147 |
+
wordBreak: 'break-all',
|
| 148 |
+
}}
|
| 149 |
+
>
|
| 150 |
+
{String(args.script).trim()}
|
| 151 |
+
</Box>
|
| 152 |
+
<Typography
|
| 153 |
+
variant="caption"
|
| 154 |
+
sx={{
|
| 155 |
+
display: 'flex',
|
| 156 |
+
alignItems: 'center',
|
| 157 |
+
gap: 0.5,
|
| 158 |
+
mt: 1,
|
| 159 |
+
fontSize: '0.65rem',
|
| 160 |
+
color: 'var(--muted-text)',
|
| 161 |
+
'&:hover': { color: 'var(--accent-yellow)' },
|
| 162 |
+
}}
|
| 163 |
+
>
|
| 164 |
+
Click to view & edit
|
| 165 |
+
</Typography>
|
| 166 |
+
</Box>
|
| 167 |
+
)}
|
| 168 |
+
</Box>
|
| 169 |
+
)}
|
| 170 |
+
|
| 171 |
+
<Box sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
| 172 |
+
<TextField
|
| 173 |
+
fullWidth
|
| 174 |
+
size="small"
|
| 175 |
+
placeholder="Feedback (optional)"
|
| 176 |
+
value={feedback}
|
| 177 |
+
onChange={(e) => setFeedback(e.target.value)}
|
| 178 |
+
variant="outlined"
|
| 179 |
+
sx={{
|
| 180 |
+
'& .MuiOutlinedInput-root': {
|
| 181 |
+
bgcolor: 'var(--hover-bg)',
|
| 182 |
+
fontFamily: 'inherit',
|
| 183 |
+
fontSize: '0.8rem',
|
| 184 |
+
'& fieldset': { borderColor: 'var(--tool-border)' },
|
| 185 |
+
'&:hover fieldset': { borderColor: 'var(--border-hover)' },
|
| 186 |
+
'&.Mui-focused fieldset': { borderColor: 'var(--accent-yellow)' },
|
| 187 |
+
},
|
| 188 |
+
'& .MuiOutlinedInput-input': {
|
| 189 |
+
color: 'var(--text)',
|
| 190 |
+
'&::placeholder': { color: 'var(--muted-text)', opacity: 0.7 },
|
| 191 |
+
},
|
| 192 |
+
}}
|
| 193 |
+
/>
|
| 194 |
+
<IconButton
|
| 195 |
+
onClick={() => onResolve(toolCallId, false, feedback || 'Rejected by user')}
|
| 196 |
+
disabled={!feedback}
|
| 197 |
+
size="small"
|
| 198 |
+
sx={{
|
| 199 |
+
color: 'var(--accent-red)',
|
| 200 |
+
border: '1px solid var(--tool-border)',
|
| 201 |
+
borderRadius: '6px',
|
| 202 |
+
'&:hover': { bgcolor: 'rgba(224,90,79,0.1)', borderColor: 'var(--accent-red)' },
|
| 203 |
+
'&.Mui-disabled': { color: 'var(--muted-text)', opacity: 0.3 },
|
| 204 |
+
}}
|
| 205 |
+
>
|
| 206 |
+
<SendIcon sx={{ fontSize: 14 }} />
|
| 207 |
+
</IconButton>
|
| 208 |
+
</Box>
|
| 209 |
+
|
| 210 |
+
<Box sx={{ display: 'flex', gap: 1 }}>
|
| 211 |
+
<Button
|
| 212 |
+
size="small"
|
| 213 |
+
onClick={() => onResolve(toolCallId, false, feedback || 'Rejected by user')}
|
| 214 |
+
sx={{
|
| 215 |
+
flex: 1,
|
| 216 |
+
textTransform: 'none',
|
| 217 |
+
border: '1px solid rgba(255,255,255,0.05)',
|
| 218 |
+
color: 'var(--accent-red)',
|
| 219 |
+
fontSize: '0.75rem',
|
| 220 |
+
py: 0.75,
|
| 221 |
+
borderRadius: '8px',
|
| 222 |
+
'&:hover': { bgcolor: 'rgba(224,90,79,0.05)', borderColor: 'var(--accent-red)' },
|
| 223 |
+
}}
|
| 224 |
+
>
|
| 225 |
+
Reject
|
| 226 |
+
</Button>
|
| 227 |
+
<Button
|
| 228 |
+
size="small"
|
| 229 |
+
onClick={() => onResolve(toolCallId, true)}
|
| 230 |
+
sx={{
|
| 231 |
+
flex: 1,
|
| 232 |
+
textTransform: 'none',
|
| 233 |
+
border: hasEditedScript ? '1px solid var(--accent-green)' : '1px solid rgba(255,255,255,0.05)',
|
| 234 |
+
color: 'var(--accent-green)',
|
| 235 |
+
fontSize: '0.75rem',
|
| 236 |
+
py: 0.75,
|
| 237 |
+
borderRadius: '8px',
|
| 238 |
+
bgcolor: hasEditedScript ? 'rgba(47,204,113,0.08)' : 'transparent',
|
| 239 |
+
'&:hover': { bgcolor: 'rgba(47,204,113,0.05)', borderColor: 'var(--accent-green)' },
|
| 240 |
+
}}
|
| 241 |
+
>
|
| 242 |
+
{hasEditedScript ? 'Approve (edited)' : 'Approve'}
|
| 243 |
+
</Button>
|
| 244 |
+
</Box>
|
| 245 |
+
</Box>
|
| 246 |
+
);
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
// ---------------------------------------------------------------------------
|
| 250 |
+
// Main component
|
| 251 |
+
// ---------------------------------------------------------------------------
|
| 252 |
+
|
| 253 |
+
export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProps) {
|
| 254 |
+
const { setPanel, lockPanel, getJobUrl, getEditedScript } = useAgentStore();
|
| 255 |
+
const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
|
| 256 |
+
|
| 257 |
+
// ── Batch approval state ─────────────────��────────────────────────
|
| 258 |
+
const pendingTools = useMemo(
|
| 259 |
+
() => tools.filter(t => t.state === 'approval-requested'),
|
| 260 |
+
[tools],
|
| 261 |
+
);
|
| 262 |
+
|
| 263 |
+
const [decisions, setDecisions] = useState<Record<string, { approved: boolean; feedback?: string }>>({});
|
| 264 |
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 265 |
+
const submittingRef = useRef(false);
|
| 266 |
+
|
| 267 |
+
const { scriptLabelMap, toolDisplayMap } = useMemo(() => {
|
| 268 |
+
const hfJobs = tools.filter(t => t.toolName === 'hf_jobs' && (t.input as Record<string, unknown>)?.script);
|
| 269 |
+
const scriptMap: Record<string, string> = {};
|
| 270 |
+
const displayMap: Record<string, string> = {};
|
| 271 |
+
for (let i = 0; i < hfJobs.length; i++) {
|
| 272 |
+
const id = hfJobs[i].toolCallId;
|
| 273 |
+
if (hfJobs.length > 1) {
|
| 274 |
+
scriptMap[id] = `Script ${i + 1}`;
|
| 275 |
+
displayMap[id] = `hf_jobs #${i + 1}`;
|
| 276 |
+
} else {
|
| 277 |
+
scriptMap[id] = 'Script';
|
| 278 |
+
displayMap[id] = 'hf_jobs';
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
return { scriptLabelMap: scriptMap, toolDisplayMap: displayMap };
|
| 282 |
+
}, [tools]);
|
| 283 |
+
|
| 284 |
+
// ── Send all decisions as a single batch ──────────────────────────
|
| 285 |
+
const sendBatch = useCallback(
|
| 286 |
+
async (batch: Record<string, { approved: boolean; feedback?: string }>) => {
|
| 287 |
+
if (submittingRef.current) return;
|
| 288 |
+
submittingRef.current = true;
|
| 289 |
+
setIsSubmitting(true);
|
| 290 |
+
|
| 291 |
+
const approvals = Object.entries(batch).map(([toolCallId, d]) => {
|
| 292 |
+
const editedScript = d.approved ? (getEditedScript(toolCallId) ?? null) : null;
|
| 293 |
+
if (editedScript) {
|
| 294 |
+
logger.log(`Sending edited script for ${toolCallId} (${editedScript.length} chars)`);
|
| 295 |
+
}
|
| 296 |
+
return {
|
| 297 |
+
tool_call_id: toolCallId,
|
| 298 |
+
approved: d.approved,
|
| 299 |
+
feedback: d.approved ? null : (d.feedback || 'Rejected by user'),
|
| 300 |
+
edited_script: editedScript,
|
| 301 |
+
};
|
| 302 |
+
});
|
| 303 |
+
|
| 304 |
+
const ok = await approveTools(approvals);
|
| 305 |
+
if (ok) {
|
| 306 |
+
lockPanel();
|
| 307 |
+
} else {
|
| 308 |
+
logger.error('Batch approval failed');
|
| 309 |
+
submittingRef.current = false;
|
| 310 |
+
setIsSubmitting(false);
|
| 311 |
+
}
|
| 312 |
+
},
|
| 313 |
+
[approveTools, lockPanel, getEditedScript],
|
| 314 |
+
);
|
| 315 |
+
|
| 316 |
+
const handleApproveAll = useCallback(() => {
|
| 317 |
+
const batch: Record<string, { approved: boolean }> = {};
|
| 318 |
+
for (const t of pendingTools) batch[t.toolCallId] = { approved: true };
|
| 319 |
+
sendBatch(batch);
|
| 320 |
+
}, [pendingTools, sendBatch]);
|
| 321 |
+
|
| 322 |
+
const handleRejectAll = useCallback(() => {
|
| 323 |
+
const batch: Record<string, { approved: boolean }> = {};
|
| 324 |
+
for (const t of pendingTools) batch[t.toolCallId] = { approved: false };
|
| 325 |
+
sendBatch(batch);
|
| 326 |
+
}, [pendingTools, sendBatch]);
|
| 327 |
+
|
| 328 |
+
const handleIndividualDecision = useCallback(
|
| 329 |
+
(toolCallId: string, approved: boolean, feedback?: string) => {
|
| 330 |
+
setDecisions(prev => {
|
| 331 |
+
const next = { ...prev, [toolCallId]: { approved, feedback } };
|
| 332 |
+
if (pendingTools.every(t => next[t.toolCallId])) {
|
| 333 |
+
queueMicrotask(() => sendBatch(next));
|
| 334 |
+
}
|
| 335 |
+
return next;
|
| 336 |
+
});
|
| 337 |
+
},
|
| 338 |
+
[pendingTools, sendBatch],
|
| 339 |
+
);
|
| 340 |
+
|
| 341 |
+
const undoDecision = useCallback((toolCallId: string) => {
|
| 342 |
+
setDecisions(prev => {
|
| 343 |
+
const next = { ...prev };
|
| 344 |
+
delete next[toolCallId];
|
| 345 |
+
return next;
|
| 346 |
+
});
|
| 347 |
+
}, []);
|
| 348 |
+
|
| 349 |
+
// ── Panel click handler ───────────────────────────────────────────
|
| 350 |
+
const handleClick = useCallback(
|
| 351 |
+
(tool: DynamicToolPart) => {
|
| 352 |
+
const args = tool.input as Record<string, unknown> | undefined;
|
| 353 |
+
const displayName = toolDisplayMap[tool.toolCallId] || tool.toolName;
|
| 354 |
+
|
| 355 |
+
if (tool.toolName === 'hf_jobs' && args?.script) {
|
| 356 |
+
const hasOutput = (tool.state === 'output-available' || tool.state === 'output-error') && tool.output;
|
| 357 |
+
const scriptContent = getEditedScript(tool.toolCallId) || String(args.script);
|
| 358 |
+
setPanel(
|
| 359 |
+
{
|
| 360 |
+
title: displayName,
|
| 361 |
+
script: { content: scriptContent, language: 'python' },
|
| 362 |
+
...(hasOutput ? { output: { content: String(tool.output), language: 'markdown' } } : {}),
|
| 363 |
+
parameters: { tool_call_id: tool.toolCallId },
|
| 364 |
+
},
|
| 365 |
+
hasOutput ? 'output' : 'script',
|
| 366 |
+
);
|
| 367 |
+
setRightPanelOpen(true);
|
| 368 |
+
setLeftSidebarOpen(false);
|
| 369 |
+
return;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
if ((tool.state === 'output-available' || tool.state === 'output-error') && tool.output) {
|
| 373 |
+
let language = 'text';
|
| 374 |
+
const content = String(tool.output);
|
| 375 |
+
if (content.trim().startsWith('{') || content.trim().startsWith('[')) language = 'json';
|
| 376 |
+
else if (content.includes('```')) language = 'markdown';
|
| 377 |
+
|
| 378 |
+
setPanel({ title: displayName, output: { content, language } }, 'output');
|
| 379 |
+
setRightPanelOpen(true);
|
| 380 |
+
} else if (args) {
|
| 381 |
+
const content = JSON.stringify(args, null, 2);
|
| 382 |
+
setPanel({ title: displayName, output: { content, language: 'json' } }, 'output');
|
| 383 |
+
setRightPanelOpen(true);
|
| 384 |
+
}
|
| 385 |
+
},
|
| 386 |
+
[toolDisplayMap, setPanel, getEditedScript, setRightPanelOpen, setLeftSidebarOpen],
|
| 387 |
+
);
|
| 388 |
+
|
| 389 |
+
// ── Parse hf_jobs metadata from output ────────────────────────────
|
| 390 |
+
function parseJobMeta(output: unknown): { jobUrl?: string; jobStatus?: string } {
|
| 391 |
+
if (typeof output !== 'string') return {};
|
| 392 |
+
const urlMatch = output.match(/\*\*View at:\*\*\s*(https:\/\/[^\s\n]+)/);
|
| 393 |
+
const statusMatch = output.match(/\*\*Final Status:\*\*\s*([^\n]+)/);
|
| 394 |
+
return {
|
| 395 |
+
jobUrl: urlMatch?.[1],
|
| 396 |
+
jobStatus: statusMatch?.[1]?.trim(),
|
| 397 |
+
};
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
// ── Render ────────────────────────────────────────────────────────
|
| 401 |
+
const decidedCount = pendingTools.filter(t => decisions[t.toolCallId]).length;
|
| 402 |
+
|
| 403 |
+
return (
|
| 404 |
+
<Box
|
| 405 |
+
sx={{
|
| 406 |
+
borderRadius: 2,
|
| 407 |
+
border: '1px solid var(--tool-border)',
|
| 408 |
+
bgcolor: 'var(--tool-bg)',
|
| 409 |
+
overflow: 'hidden',
|
| 410 |
+
my: 1,
|
| 411 |
+
}}
|
| 412 |
+
>
|
| 413 |
+
{/* Batch approval header — hidden once user starts deciding individually */}
|
| 414 |
+
{pendingTools.length > 1 && !isSubmitting && decidedCount === 0 && (
|
| 415 |
+
<Box
|
| 416 |
+
sx={{
|
| 417 |
+
display: 'flex',
|
| 418 |
+
alignItems: 'center',
|
| 419 |
+
gap: 1,
|
| 420 |
+
px: 1.5,
|
| 421 |
+
py: 1,
|
| 422 |
+
borderBottom: '1px solid var(--tool-border)',
|
| 423 |
+
}}
|
| 424 |
+
>
|
| 425 |
+
<Typography
|
| 426 |
+
variant="body2"
|
| 427 |
+
sx={{ fontSize: '0.72rem', color: 'var(--muted-text)', mr: 'auto', whiteSpace: 'nowrap' }}
|
| 428 |
+
>
|
| 429 |
+
{`${pendingTools.length} tool${pendingTools.length > 1 ? 's' : ''} pending`}
|
| 430 |
+
</Typography>
|
| 431 |
+
<Button
|
| 432 |
+
size="small"
|
| 433 |
+
onClick={handleRejectAll}
|
| 434 |
+
sx={{
|
| 435 |
+
textTransform: 'none',
|
| 436 |
+
color: 'var(--accent-red)',
|
| 437 |
+
border: '1px solid rgba(255,255,255,0.05)',
|
| 438 |
+
fontSize: '0.72rem',
|
| 439 |
+
py: 0.5,
|
| 440 |
+
px: 1.5,
|
| 441 |
+
borderRadius: '8px',
|
| 442 |
+
'&:hover': { bgcolor: 'rgba(224,90,79,0.05)', borderColor: 'var(--accent-red)' },
|
| 443 |
+
}}
|
| 444 |
+
>
|
| 445 |
+
Reject all
|
| 446 |
+
</Button>
|
| 447 |
+
<Button
|
| 448 |
+
size="small"
|
| 449 |
+
onClick={handleApproveAll}
|
| 450 |
+
sx={{
|
| 451 |
+
textTransform: 'none',
|
| 452 |
+
color: 'var(--accent-green)',
|
| 453 |
+
border: '1px solid var(--accent-green)',
|
| 454 |
+
fontSize: '0.72rem',
|
| 455 |
+
fontWeight: 600,
|
| 456 |
+
py: 0.5,
|
| 457 |
+
px: 1.5,
|
| 458 |
+
borderRadius: '8px',
|
| 459 |
+
'&:hover': { bgcolor: 'rgba(47,204,113,0.1)' },
|
| 460 |
+
}}
|
| 461 |
+
>
|
| 462 |
+
Approve all{pendingTools.length > 1 ? ` (${pendingTools.length})` : ''}
|
| 463 |
+
</Button>
|
| 464 |
+
</Box>
|
| 465 |
+
)}
|
| 466 |
+
|
| 467 |
+
{/* Tool list */}
|
| 468 |
+
<Stack divider={<Box sx={{ borderBottom: '1px solid var(--tool-border)' }} />}>
|
| 469 |
+
{tools.map((tool) => {
|
| 470 |
+
const state = tool.state;
|
| 471 |
+
const isPending = state === 'approval-requested';
|
| 472 |
+
const clickable =
|
| 473 |
+
state === 'output-available' ||
|
| 474 |
+
state === 'output-error' ||
|
| 475 |
+
!!tool.input;
|
| 476 |
+
const localDecision = decisions[tool.toolCallId];
|
| 477 |
+
|
| 478 |
+
const displayState = isPending && localDecision
|
| 479 |
+
? (localDecision.approved ? 'input-available' : 'output-denied')
|
| 480 |
+
: state;
|
| 481 |
+
const label = statusLabel(displayState as ToolPartState);
|
| 482 |
+
|
| 483 |
+
// Parse job metadata from hf_jobs output and store
|
| 484 |
+
const jobUrlFromStore = tool.toolName === 'hf_jobs' ? getJobUrl(tool.toolCallId) : undefined;
|
| 485 |
+
const jobMetaFromOutput = tool.toolName === 'hf_jobs' && tool.state === 'output-available'
|
| 486 |
+
? parseJobMeta(tool.output)
|
| 487 |
+
: {};
|
| 488 |
+
|
| 489 |
+
// Combine job URL from store (available immediately) with output metadata (available at completion)
|
| 490 |
+
const jobMeta = {
|
| 491 |
+
jobUrl: jobUrlFromStore || jobMetaFromOutput.jobUrl,
|
| 492 |
+
jobStatus: jobMetaFromOutput.jobStatus,
|
| 493 |
+
};
|
| 494 |
+
|
| 495 |
+
return (
|
| 496 |
+
<Box key={tool.toolCallId}>
|
| 497 |
+
{/* Main tool row */}
|
| 498 |
+
<Stack
|
| 499 |
+
direction="row"
|
| 500 |
+
alignItems="center"
|
| 501 |
+
spacing={1}
|
| 502 |
+
onClick={() => !isPending && handleClick(tool)}
|
| 503 |
+
sx={{
|
| 504 |
+
px: 1.5,
|
| 505 |
+
py: 1,
|
| 506 |
+
cursor: isPending ? 'default' : clickable ? 'pointer' : 'default',
|
| 507 |
+
transition: 'background-color 0.15s',
|
| 508 |
+
'&:hover': clickable && !isPending ? { bgcolor: 'var(--hover-bg)' } : {},
|
| 509 |
+
}}
|
| 510 |
+
>
|
| 511 |
+
<StatusIcon state={
|
| 512 |
+
(tool.toolName === 'hf_jobs' && jobMeta.jobStatus && ['ERROR', 'FAILED', 'CANCELLED'].includes(jobMeta.jobStatus) && displayState === 'output-available')
|
| 513 |
+
? 'output-error'
|
| 514 |
+
: displayState as ToolPartState
|
| 515 |
+
} />
|
| 516 |
+
|
| 517 |
+
<Typography
|
| 518 |
+
variant="body2"
|
| 519 |
+
sx={{
|
| 520 |
+
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, monospace',
|
| 521 |
+
fontWeight: 600,
|
| 522 |
+
fontSize: '0.78rem',
|
| 523 |
+
color: 'var(--text)',
|
| 524 |
+
flex: 1,
|
| 525 |
+
minWidth: 0,
|
| 526 |
+
overflow: 'hidden',
|
| 527 |
+
textOverflow: 'ellipsis',
|
| 528 |
+
whiteSpace: 'nowrap',
|
| 529 |
+
}}
|
| 530 |
+
>
|
| 531 |
+
{toolDisplayMap[tool.toolCallId] || tool.toolName}
|
| 532 |
+
</Typography>
|
| 533 |
+
|
| 534 |
+
{/* Status chip (non hf_jobs, or hf_jobs without final status) */}
|
| 535 |
+
{label && !(tool.toolName === 'hf_jobs' && jobMeta.jobStatus) && (
|
| 536 |
+
<Chip
|
| 537 |
+
label={label}
|
| 538 |
+
size="small"
|
| 539 |
+
sx={{
|
| 540 |
+
height: 20,
|
| 541 |
+
fontSize: '0.65rem',
|
| 542 |
+
fontWeight: 600,
|
| 543 |
+
bgcolor: displayState === 'output-error' ? 'rgba(224,90,79,0.12)'
|
| 544 |
+
: displayState === 'output-denied' ? 'rgba(255,255,255,0.05)'
|
| 545 |
+
: 'var(--accent-yellow-weak)',
|
| 546 |
+
color: statusColor(displayState as ToolPartState),
|
| 547 |
+
letterSpacing: '0.03em',
|
| 548 |
+
}}
|
| 549 |
+
/>
|
| 550 |
+
)}
|
| 551 |
+
|
| 552 |
+
{/* HF Jobs: final status chip from job metadata */}
|
| 553 |
+
{tool.toolName === 'hf_jobs' && jobMeta.jobStatus && (
|
| 554 |
+
<Chip
|
| 555 |
+
label={jobMeta.jobStatus}
|
| 556 |
+
size="small"
|
| 557 |
+
sx={{
|
| 558 |
+
height: 20,
|
| 559 |
+
fontSize: '0.65rem',
|
| 560 |
+
fontWeight: 600,
|
| 561 |
+
bgcolor: jobMeta.jobStatus === 'COMPLETED'
|
| 562 |
+
? 'rgba(47,204,113,0.12)'
|
| 563 |
+
: ['ERROR', 'FAILED', 'CANCELLED'].includes(jobMeta.jobStatus!)
|
| 564 |
+
? 'rgba(224,90,79,0.12)'
|
| 565 |
+
: 'rgba(255,193,59,0.12)',
|
| 566 |
+
color: jobMeta.jobStatus === 'COMPLETED'
|
| 567 |
+
? 'var(--accent-green)'
|
| 568 |
+
: ['ERROR', 'FAILED', 'CANCELLED'].includes(jobMeta.jobStatus!)
|
| 569 |
+
? 'var(--accent-red)'
|
| 570 |
+
: 'var(--accent-yellow)',
|
| 571 |
+
letterSpacing: '0.03em',
|
| 572 |
+
}}
|
| 573 |
+
/>
|
| 574 |
+
)}
|
| 575 |
+
|
| 576 |
+
{/* View on HF link — single place, shown whenever URL is available */}
|
| 577 |
+
{tool.toolName === 'hf_jobs' && jobMeta.jobUrl && (
|
| 578 |
+
<Link
|
| 579 |
+
href={jobMeta.jobUrl}
|
| 580 |
+
target="_blank"
|
| 581 |
+
rel="noopener noreferrer"
|
| 582 |
+
onClick={(e) => e.stopPropagation()}
|
| 583 |
+
sx={{
|
| 584 |
+
display: 'inline-flex',
|
| 585 |
+
alignItems: 'center',
|
| 586 |
+
gap: 0.5,
|
| 587 |
+
color: 'var(--accent-yellow)',
|
| 588 |
+
fontSize: '0.68rem',
|
| 589 |
+
textDecoration: 'none',
|
| 590 |
+
ml: 0.5,
|
| 591 |
+
'&:hover': { textDecoration: 'underline' },
|
| 592 |
+
}}
|
| 593 |
+
>
|
| 594 |
+
<LaunchIcon sx={{ fontSize: 12 }} />
|
| 595 |
+
View on HF
|
| 596 |
+
</Link>
|
| 597 |
+
)}
|
| 598 |
+
|
| 599 |
+
{clickable && !isPending && (
|
| 600 |
+
<OpenInNewIcon sx={{ fontSize: 14, color: 'var(--muted-text)', opacity: 0.6 }} />
|
| 601 |
+
)}
|
| 602 |
+
</Stack>
|
| 603 |
+
|
| 604 |
+
|
| 605 |
+
{/* Per-tool approval: undecided */}
|
| 606 |
+
{isPending && !localDecision && !isSubmitting && (
|
| 607 |
+
<InlineApproval
|
| 608 |
+
toolCallId={tool.toolCallId}
|
| 609 |
+
toolName={tool.toolName}
|
| 610 |
+
input={tool.input}
|
| 611 |
+
scriptLabel={scriptLabelMap[tool.toolCallId] || 'Script'}
|
| 612 |
+
onResolve={handleIndividualDecision}
|
| 613 |
+
/>
|
| 614 |
+
)}
|
| 615 |
+
|
| 616 |
+
{/* Per-tool approval: locally decided (undo available) */}
|
| 617 |
+
{isPending && localDecision && !isSubmitting && (
|
| 618 |
+
<Box
|
| 619 |
+
sx={{
|
| 620 |
+
display: 'flex',
|
| 621 |
+
alignItems: 'center',
|
| 622 |
+
justifyContent: 'space-between',
|
| 623 |
+
px: 1.5,
|
| 624 |
+
py: 0.75,
|
| 625 |
+
borderTop: '1px solid var(--tool-border)',
|
| 626 |
+
}}
|
| 627 |
+
>
|
| 628 |
+
<Typography variant="body2" sx={{ fontSize: '0.72rem', color: 'var(--muted-text)' }}>
|
| 629 |
+
{localDecision.approved
|
| 630 |
+
? 'Marked for approval'
|
| 631 |
+
: `Marked for rejection${localDecision.feedback ? `: ${localDecision.feedback}` : ''}`}
|
| 632 |
+
</Typography>
|
| 633 |
+
<Button
|
| 634 |
+
size="small"
|
| 635 |
+
onClick={() => undoDecision(tool.toolCallId)}
|
| 636 |
+
sx={{
|
| 637 |
+
textTransform: 'none',
|
| 638 |
+
fontSize: '0.7rem',
|
| 639 |
+
color: 'var(--muted-text)',
|
| 640 |
+
minWidth: 'auto',
|
| 641 |
+
px: 1,
|
| 642 |
+
'&:hover': { color: 'var(--text)' },
|
| 643 |
+
}}
|
| 644 |
+
>
|
| 645 |
+
Undo
|
| 646 |
+
</Button>
|
| 647 |
+
</Box>
|
| 648 |
+
)}
|
| 649 |
+
</Box>
|
| 650 |
+
);
|
| 651 |
+
})}
|
| 652 |
+
</Stack>
|
| 653 |
+
</Box>
|
| 654 |
+
);
|
| 655 |
+
}
|
frontend/src/components/Chat/UserMessage.tsx
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { Box, Stack, Typography, IconButton, Tooltip } from '@mui/material';
|
| 2 |
+
import CloseIcon from '@mui/icons-material/Close';
|
| 3 |
+
import type { UIMessage } from 'ai';
|
| 4 |
+
import type { MessageMeta } from '@/types/agent';
|
| 5 |
+
|
| 6 |
+
interface UserMessageProps {
|
| 7 |
+
message: UIMessage;
|
| 8 |
+
isLastTurn?: boolean;
|
| 9 |
+
onUndoTurn?: () => void;
|
| 10 |
+
isProcessing?: boolean;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
function extractText(message: UIMessage): string {
|
| 14 |
+
return message.parts
|
| 15 |
+
.filter((p): p is Extract<typeof p, { type: 'text' }> => p.type === 'text')
|
| 16 |
+
.map(p => p.text)
|
| 17 |
+
.join('');
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export default function UserMessage({
|
| 21 |
+
message,
|
| 22 |
+
isLastTurn = false,
|
| 23 |
+
onUndoTurn,
|
| 24 |
+
isProcessing = false,
|
| 25 |
+
}: UserMessageProps) {
|
| 26 |
+
const showUndo = isLastTurn && !isProcessing && !!onUndoTurn;
|
| 27 |
+
const text = extractText(message);
|
| 28 |
+
const meta = message.metadata as MessageMeta | undefined;
|
| 29 |
+
const timeStr = meta?.createdAt
|
| 30 |
+
? new Date(meta.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
| 31 |
+
: null;
|
| 32 |
+
return (
|
| 33 |
+
<Stack
|
| 34 |
+
direction="row"
|
| 35 |
+
spacing={1.5}
|
| 36 |
+
justifyContent="flex-end"
|
| 37 |
+
alignItems="flex-start"
|
| 38 |
+
sx={{
|
| 39 |
+
'& .undo-btn': {
|
| 40 |
+
opacity: 0,
|
| 41 |
+
transition: 'opacity 0.15s ease',
|
| 42 |
+
},
|
| 43 |
+
'&:hover .undo-btn': {
|
| 44 |
+
opacity: 1,
|
| 45 |
+
},
|
| 46 |
+
}}
|
| 47 |
+
>
|
| 48 |
+
{showUndo && (
|
| 49 |
+
<Box className="undo-btn" sx={{ display: 'flex', alignItems: 'center', mt: 0.75 }}>
|
| 50 |
+
<Tooltip title="Remove this turn" placement="left">
|
| 51 |
+
<IconButton
|
| 52 |
+
onClick={onUndoTurn}
|
| 53 |
+
size="small"
|
| 54 |
+
sx={{
|
| 55 |
+
width: 24,
|
| 56 |
+
height: 24,
|
| 57 |
+
color: 'var(--muted-text)',
|
| 58 |
+
'&:hover': {
|
| 59 |
+
color: 'var(--accent-red)',
|
| 60 |
+
bgcolor: 'rgba(244,67,54,0.08)',
|
| 61 |
+
},
|
| 62 |
+
}}
|
| 63 |
+
>
|
| 64 |
+
<CloseIcon sx={{ fontSize: 14 }} />
|
| 65 |
+
</IconButton>
|
| 66 |
+
</Tooltip>
|
| 67 |
+
</Box>
|
| 68 |
+
)}
|
| 69 |
+
|
| 70 |
+
<Box
|
| 71 |
+
sx={{
|
| 72 |
+
maxWidth: { xs: '88%', md: '72%' },
|
| 73 |
+
bgcolor: 'var(--surface)',
|
| 74 |
+
borderRadius: 1.5,
|
| 75 |
+
borderTopRightRadius: 4,
|
| 76 |
+
px: { xs: 1.5, md: 2.5 },
|
| 77 |
+
py: 1.5,
|
| 78 |
+
border: '1px solid var(--border)',
|
| 79 |
+
}}
|
| 80 |
+
>
|
| 81 |
+
<Typography
|
| 82 |
+
variant="body1"
|
| 83 |
+
sx={{
|
| 84 |
+
fontSize: '0.925rem',
|
| 85 |
+
lineHeight: 1.65,
|
| 86 |
+
color: 'var(--text)',
|
| 87 |
+
whiteSpace: 'pre-wrap',
|
| 88 |
+
wordBreak: 'break-word',
|
| 89 |
+
}}
|
| 90 |
+
>
|
| 91 |
+
{text}
|
| 92 |
+
</Typography>
|
| 93 |
+
|
| 94 |
+
{timeStr && (
|
| 95 |
+
<Typography
|
| 96 |
+
variant="caption"
|
| 97 |
+
sx={{ color: 'var(--muted-text)', mt: 0.5, display: 'block', textAlign: 'right', fontSize: '0.7rem' }}
|
| 98 |
+
>
|
| 99 |
+
{timeStr}
|
| 100 |
+
</Typography>
|
| 101 |
+
)}
|
| 102 |
+
</Box>
|
| 103 |
+
</Stack>
|
| 104 |
+
);
|
| 105 |
+
}
|
frontend/src/components/CodePanel/CodePanel.tsx
CHANGED
|
@@ -1,138 +1,463 @@
|
|
| 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';
|
| 6 |
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
|
| 7 |
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 {
|
| 20 |
-
|
|
|
|
| 21 |
const scrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
const displayContent = useMemo(() => {
|
| 28 |
-
if (!
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
return processLogs(currentContent.content);
|
| 32 |
}
|
| 33 |
-
return
|
| 34 |
-
}, [
|
| 35 |
|
| 36 |
useEffect(() => {
|
| 37 |
-
|
| 38 |
-
if (scrollRef.current && activePanelTab === 'logs') {
|
| 39 |
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
| 40 |
}
|
| 41 |
-
}, [displayContent,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
fontSize:
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
'
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
}}
|
| 92 |
-
>
|
| 93 |
-
{icon}
|
| 94 |
-
<span>{tab.title}</span>
|
| 95 |
-
<Box
|
| 96 |
-
component="span"
|
| 97 |
-
onClick={(e) => {
|
| 98 |
-
e.stopPropagation();
|
| 99 |
-
removePanelTab(tab.id);
|
| 100 |
-
}}
|
| 101 |
-
sx={{
|
| 102 |
-
display: 'flex',
|
| 103 |
-
alignItems: 'center',
|
| 104 |
-
justifyContent: 'center',
|
| 105 |
-
ml: 0.5,
|
| 106 |
-
width: 16,
|
| 107 |
-
height: 16,
|
| 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 |
-
✕
|
| 118 |
-
</Box>
|
| 119 |
</Box>
|
| 120 |
-
)
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
</Box>
|
| 132 |
|
| 133 |
-
{/* Main
|
| 134 |
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
| 135 |
-
{!
|
| 136 |
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', p: 4 }}>
|
| 137 |
<Typography variant="body2" color="text.secondary" sx={{ opacity: 0.5 }}>
|
| 138 |
NO DATA LOADED
|
|
@@ -144,174 +469,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, useState, useCallback } from 'react';
|
| 2 |
+
import { Box, Stack, Typography, IconButton, Button, Tooltip } 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';
|
| 6 |
import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline';
|
| 7 |
import CodeIcon from '@mui/icons-material/Code';
|
|
|
|
| 8 |
import ArticleIcon from '@mui/icons-material/Article';
|
| 9 |
+
import EditIcon from '@mui/icons-material/Edit';
|
| 10 |
+
import UndoIcon from '@mui/icons-material/Undo';
|
| 11 |
+
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
| 12 |
+
import CheckIcon from '@mui/icons-material/Check';
|
| 13 |
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
| 14 |
+
import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
| 15 |
import ReactMarkdown from 'react-markdown';
|
| 16 |
import remarkGfm from 'remark-gfm';
|
| 17 |
import { useAgentStore } from '@/store/agentStore';
|
| 18 |
import { useLayoutStore } from '@/store/layoutStore';
|
| 19 |
import { processLogs } from '@/utils/logProcessor';
|
| 20 |
+
import type { PanelView } from '@/store/agentStore';
|
| 21 |
+
|
| 22 |
+
// ── Helpers ──────────────────────────────────────────────────────
|
| 23 |
+
|
| 24 |
+
function PlanStatusIcon({ status }: { status: string }) {
|
| 25 |
+
if (status === 'completed') return <CheckCircleIcon sx={{ fontSize: 16, color: 'var(--accent-green)' }} />;
|
| 26 |
+
if (status === 'in_progress') return <PlayCircleOutlineIcon sx={{ fontSize: 16, color: 'var(--accent-yellow)' }} />;
|
| 27 |
+
return <RadioButtonUncheckedIcon sx={{ fontSize: 16, color: 'var(--muted-text)', opacity: 0.5 }} />;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// ── Markdown styles (adapts via CSS vars) ────────────────────────
|
| 31 |
+
const markdownSx = {
|
| 32 |
+
color: 'var(--text)',
|
| 33 |
+
fontSize: '13px',
|
| 34 |
+
lineHeight: 1.6,
|
| 35 |
+
'& p': { m: 0, mb: 1.5, '&:last-child': { mb: 0 } },
|
| 36 |
+
'& pre': {
|
| 37 |
+
bgcolor: 'var(--code-bg)',
|
| 38 |
+
p: 1.5,
|
| 39 |
+
borderRadius: 1,
|
| 40 |
+
overflow: 'auto',
|
| 41 |
+
fontSize: '12px',
|
| 42 |
+
border: '1px solid var(--tool-border)',
|
| 43 |
+
},
|
| 44 |
+
'& code': {
|
| 45 |
+
bgcolor: 'var(--hover-bg)',
|
| 46 |
+
px: 0.5,
|
| 47 |
+
py: 0.25,
|
| 48 |
+
borderRadius: 0.5,
|
| 49 |
+
fontSize: '12px',
|
| 50 |
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
|
| 51 |
+
},
|
| 52 |
+
'& pre code': { bgcolor: 'transparent', p: 0 },
|
| 53 |
+
'& a': {
|
| 54 |
+
color: 'var(--accent-yellow)',
|
| 55 |
+
textDecoration: 'none',
|
| 56 |
+
'&:hover': { textDecoration: 'underline' },
|
| 57 |
+
},
|
| 58 |
+
'& ul, & ol': { pl: 2.5, my: 1 },
|
| 59 |
+
'& li': { mb: 0.5 },
|
| 60 |
+
'& table': {
|
| 61 |
+
borderCollapse: 'collapse',
|
| 62 |
+
width: '100%',
|
| 63 |
+
my: 2,
|
| 64 |
+
fontSize: '12px',
|
| 65 |
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
|
| 66 |
+
},
|
| 67 |
+
'& th': {
|
| 68 |
+
borderBottom: '2px solid var(--border-hover)',
|
| 69 |
+
textAlign: 'left',
|
| 70 |
+
p: 1,
|
| 71 |
+
fontWeight: 600,
|
| 72 |
+
},
|
| 73 |
+
'& td': {
|
| 74 |
+
borderBottom: '1px solid var(--tool-border)',
|
| 75 |
+
p: 1,
|
| 76 |
+
},
|
| 77 |
+
'& h1, & h2, & h3, & h4': { mt: 2, mb: 1, fontWeight: 600 },
|
| 78 |
+
'& h1': { fontSize: '1.25rem' },
|
| 79 |
+
'& h2': { fontSize: '1.1rem' },
|
| 80 |
+
'& h3': { fontSize: '1rem' },
|
| 81 |
+
'& blockquote': {
|
| 82 |
+
borderLeft: '3px solid var(--accent-yellow)',
|
| 83 |
+
pl: 2,
|
| 84 |
+
ml: 0,
|
| 85 |
+
color: 'var(--muted-text)',
|
| 86 |
+
},
|
| 87 |
+
} as const;
|
| 88 |
+
|
| 89 |
+
// ── View toggle button ──────────────────────────────────────────
|
| 90 |
+
|
| 91 |
+
function ViewToggle({ view, icon, label, isActive, onClick }: {
|
| 92 |
+
view: PanelView;
|
| 93 |
+
icon: React.ReactNode;
|
| 94 |
+
label: string;
|
| 95 |
+
isActive: boolean;
|
| 96 |
+
onClick: (v: PanelView) => void;
|
| 97 |
+
}) {
|
| 98 |
+
return (
|
| 99 |
+
<Box
|
| 100 |
+
onClick={() => onClick(view)}
|
| 101 |
+
sx={{
|
| 102 |
+
display: 'flex',
|
| 103 |
+
alignItems: 'center',
|
| 104 |
+
gap: 0.5,
|
| 105 |
+
px: 1.5,
|
| 106 |
+
py: 0.75,
|
| 107 |
+
borderRadius: 1,
|
| 108 |
+
cursor: 'pointer',
|
| 109 |
+
fontSize: '0.7rem',
|
| 110 |
+
fontWeight: 600,
|
| 111 |
+
textTransform: 'uppercase',
|
| 112 |
+
letterSpacing: '0.05em',
|
| 113 |
+
whiteSpace: 'nowrap',
|
| 114 |
+
color: isActive ? 'var(--text)' : 'var(--muted-text)',
|
| 115 |
+
bgcolor: isActive ? 'var(--tab-active-bg)' : 'transparent',
|
| 116 |
+
border: '1px solid',
|
| 117 |
+
borderColor: isActive ? 'var(--tab-active-border)' : 'transparent',
|
| 118 |
+
transition: 'all 0.15s ease',
|
| 119 |
+
'&:hover': { bgcolor: 'var(--tab-hover-bg)' },
|
| 120 |
+
}}
|
| 121 |
+
>
|
| 122 |
+
{icon}
|
| 123 |
+
<span>{label}</span>
|
| 124 |
+
</Box>
|
| 125 |
+
);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// ── Component ────────────────────────────────────────────────────
|
| 129 |
|
| 130 |
export default function CodePanel() {
|
| 131 |
+
const { panelData, panelView, panelEditable, setPanelView, updatePanelScript, setEditedScript, plan } =
|
| 132 |
+
useAgentStore();
|
| 133 |
+
const { setRightPanelOpen, themeMode } = useLayoutStore();
|
| 134 |
const scrollRef = useRef<HTMLDivElement>(null);
|
| 135 |
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
| 136 |
+
const [isEditing, setIsEditing] = useState(false);
|
| 137 |
+
const [editedContent, setEditedContent] = useState('');
|
| 138 |
+
const [originalContent, setOriginalContent] = useState('');
|
| 139 |
+
const [copied, setCopied] = useState(false);
|
| 140 |
+
|
| 141 |
+
const isDark = themeMode === 'dark';
|
| 142 |
+
const syntaxTheme = isDark ? vscDarkPlus : vs;
|
| 143 |
+
|
| 144 |
+
const activeSection = panelView === 'script' ? panelData?.script : panelData?.output;
|
| 145 |
+
const hasScript = !!panelData?.script;
|
| 146 |
+
const hasOutput = !!panelData?.output;
|
| 147 |
+
const hasBothViews = hasScript && hasOutput;
|
| 148 |
+
|
| 149 |
+
const isEditableScript = panelView === 'script' && panelEditable;
|
| 150 |
+
const hasUnsavedChanges = isEditing && editedContent !== originalContent;
|
| 151 |
+
|
| 152 |
+
// Sync edited content when panel data changes
|
| 153 |
+
useEffect(() => {
|
| 154 |
+
if (panelData?.script?.content && panelView === 'script' && panelEditable) {
|
| 155 |
+
setOriginalContent(panelData.script.content);
|
| 156 |
+
if (!isEditing) {
|
| 157 |
+
setEditedContent(panelData.script.content);
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
}, [panelData?.script?.content, panelView, panelEditable, isEditing]);
|
| 161 |
+
|
| 162 |
+
// Exit editing when switching away from script view or losing editable
|
| 163 |
+
useEffect(() => {
|
| 164 |
+
if (!isEditableScript && isEditing) {
|
| 165 |
+
setIsEditing(false);
|
| 166 |
+
}
|
| 167 |
+
}, [isEditableScript, isEditing]);
|
| 168 |
+
|
| 169 |
+
const handleStartEdit = useCallback(() => {
|
| 170 |
+
if (panelData?.script?.content) {
|
| 171 |
+
setEditedContent(panelData.script.content);
|
| 172 |
+
setOriginalContent(panelData.script.content);
|
| 173 |
+
setIsEditing(true);
|
| 174 |
+
setTimeout(() => textareaRef.current?.focus(), 0);
|
| 175 |
+
}
|
| 176 |
+
}, [panelData?.script?.content]);
|
| 177 |
+
|
| 178 |
+
const handleCancelEdit = useCallback(() => {
|
| 179 |
+
setEditedContent(originalContent);
|
| 180 |
+
setIsEditing(false);
|
| 181 |
+
}, [originalContent]);
|
| 182 |
+
|
| 183 |
+
const handleSaveEdit = useCallback(() => {
|
| 184 |
+
if (editedContent !== originalContent) {
|
| 185 |
+
updatePanelScript(editedContent);
|
| 186 |
+
const toolCallId = panelData?.parameters?.tool_call_id as string | undefined;
|
| 187 |
+
if (toolCallId) {
|
| 188 |
+
setEditedScript(toolCallId, editedContent);
|
| 189 |
+
}
|
| 190 |
+
setOriginalContent(editedContent);
|
| 191 |
+
}
|
| 192 |
+
setIsEditing(false);
|
| 193 |
+
}, [panelData?.parameters?.tool_call_id, editedContent, originalContent, updatePanelScript, setEditedScript]);
|
| 194 |
|
| 195 |
+
const handleCopy = useCallback(async () => {
|
| 196 |
+
const contentToCopy = isEditing ? editedContent : (activeSection?.content || '');
|
| 197 |
+
if (contentToCopy) {
|
| 198 |
+
try {
|
| 199 |
+
await navigator.clipboard.writeText(contentToCopy);
|
| 200 |
+
setCopied(true);
|
| 201 |
+
setTimeout(() => setCopied(false), 2000);
|
| 202 |
+
} catch (err) {
|
| 203 |
+
console.error('Failed to copy:', err);
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
}, [isEditing, editedContent, activeSection?.content]);
|
| 207 |
|
| 208 |
const displayContent = useMemo(() => {
|
| 209 |
+
if (!activeSection?.content) return '';
|
| 210 |
+
if (!activeSection.language || activeSection.language === 'text') {
|
| 211 |
+
return processLogs(activeSection.content);
|
|
|
|
| 212 |
}
|
| 213 |
+
return activeSection.content;
|
| 214 |
+
}, [activeSection?.content, activeSection?.language]);
|
| 215 |
|
| 216 |
useEffect(() => {
|
| 217 |
+
if (scrollRef.current && panelView === 'output') {
|
|
|
|
| 218 |
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
| 219 |
}
|
| 220 |
+
}, [displayContent, panelView]);
|
| 221 |
+
|
| 222 |
+
// ── Syntax-highlighted code block (DRY) ────────────────────────
|
| 223 |
+
const renderSyntaxBlock = (language: string) => (
|
| 224 |
+
<SyntaxHighlighter
|
| 225 |
+
language={language}
|
| 226 |
+
style={syntaxTheme}
|
| 227 |
+
customStyle={{
|
| 228 |
+
margin: 0,
|
| 229 |
+
padding: 0,
|
| 230 |
+
background: 'transparent',
|
| 231 |
+
fontSize: '13px',
|
| 232 |
+
fontFamily: 'inherit',
|
| 233 |
+
}}
|
| 234 |
+
wrapLines
|
| 235 |
+
wrapLongLines
|
| 236 |
+
>
|
| 237 |
+
{displayContent}
|
| 238 |
+
</SyntaxHighlighter>
|
| 239 |
+
);
|
| 240 |
+
|
| 241 |
+
// ── Content renderer ───────────────────────────────────────────
|
| 242 |
+
const renderContent = () => {
|
| 243 |
+
if (!activeSection?.content) {
|
| 244 |
+
return (
|
| 245 |
+
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', opacity: 0.5 }}>
|
| 246 |
+
<Typography variant="caption">NO CONTENT TO DISPLAY</Typography>
|
| 247 |
+
</Box>
|
| 248 |
+
);
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
if (isEditing && isEditableScript) {
|
| 252 |
+
return (
|
| 253 |
+
<Box sx={{ position: 'relative', width: '100%', height: '100%' }}>
|
| 254 |
+
<SyntaxHighlighter
|
| 255 |
+
language={activeSection?.language === 'python' ? 'python' : activeSection?.language === 'json' ? 'json' : 'text'}
|
| 256 |
+
style={syntaxTheme}
|
| 257 |
+
customStyle={{
|
| 258 |
+
margin: 0,
|
| 259 |
+
padding: 0,
|
| 260 |
+
background: 'transparent',
|
| 261 |
+
fontSize: '13px',
|
| 262 |
+
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
|
| 263 |
+
lineHeight: 1.55,
|
| 264 |
+
pointerEvents: 'none',
|
| 265 |
+
}}
|
| 266 |
+
wrapLines
|
| 267 |
+
wrapLongLines
|
| 268 |
+
>
|
| 269 |
+
{editedContent || ' '}
|
| 270 |
+
</SyntaxHighlighter>
|
| 271 |
+
<textarea
|
| 272 |
+
ref={textareaRef}
|
| 273 |
+
value={editedContent}
|
| 274 |
+
onChange={(e) => setEditedContent(e.target.value)}
|
| 275 |
+
spellCheck={false}
|
| 276 |
+
style={{
|
| 277 |
+
position: 'absolute',
|
| 278 |
+
top: 0,
|
| 279 |
+
left: 0,
|
| 280 |
+
width: '100%',
|
| 281 |
+
height: '100%',
|
| 282 |
+
background: 'transparent',
|
| 283 |
+
border: 'none',
|
| 284 |
+
outline: 'none',
|
| 285 |
+
resize: 'none',
|
| 286 |
+
color: 'transparent',
|
| 287 |
+
caretColor: 'var(--text)',
|
| 288 |
+
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
|
| 289 |
+
fontSize: '13px',
|
| 290 |
+
lineHeight: 1.55,
|
| 291 |
+
overflow: 'hidden',
|
| 292 |
+
}}
|
| 293 |
+
/>
|
| 294 |
+
</Box>
|
| 295 |
+
);
|
| 296 |
+
}
|
| 297 |
|
| 298 |
+
const lang = activeSection.language;
|
| 299 |
+
if (lang === 'python') return renderSyntaxBlock('python');
|
| 300 |
+
if (lang === 'json') return renderSyntaxBlock('json');
|
| 301 |
+
|
| 302 |
+
if (lang === 'markdown') {
|
| 303 |
+
return (
|
| 304 |
+
<Box sx={markdownSx}>
|
| 305 |
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>{displayContent}</ReactMarkdown>
|
| 306 |
+
</Box>
|
| 307 |
+
);
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
return (
|
| 311 |
+
<Box
|
| 312 |
+
component="pre"
|
| 313 |
+
sx={{ m: 0, fontFamily: 'inherit', color: 'var(--text)', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
|
| 314 |
+
>
|
| 315 |
+
<code>{displayContent}</code>
|
| 316 |
+
</Box>
|
| 317 |
+
);
|
| 318 |
+
};
|
| 319 |
|
| 320 |
return (
|
| 321 |
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', bgcolor: 'var(--panel)' }}>
|
| 322 |
+
{/* ── Header ─────────────────────────────────────────────── */}
|
| 323 |
+
<Box
|
| 324 |
+
sx={{
|
| 325 |
+
height: 60,
|
| 326 |
+
display: 'flex',
|
| 327 |
+
alignItems: 'center',
|
| 328 |
+
justifyContent: 'space-between',
|
| 329 |
+
px: 2,
|
| 330 |
+
borderBottom: '1px solid var(--border)',
|
| 331 |
+
flexShrink: 0,
|
| 332 |
+
}}
|
| 333 |
+
>
|
| 334 |
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, minWidth: 0 }}>
|
| 335 |
+
{panelData ? (
|
| 336 |
+
<>
|
| 337 |
+
<Typography
|
| 338 |
+
variant="caption"
|
| 339 |
+
sx={{
|
| 340 |
+
fontWeight: 600,
|
| 341 |
+
color: 'var(--muted-text)',
|
| 342 |
+
textTransform: 'uppercase',
|
| 343 |
+
letterSpacing: '0.05em',
|
| 344 |
+
fontSize: '0.7rem',
|
| 345 |
+
flexShrink: 0,
|
| 346 |
+
}}
|
| 347 |
+
>
|
| 348 |
+
{panelData.title}
|
| 349 |
+
</Typography>
|
| 350 |
+
{hasBothViews && (
|
| 351 |
+
<Box sx={{ display: 'flex', gap: 0.5, ml: 1 }}>
|
| 352 |
+
<ViewToggle
|
| 353 |
+
view="script"
|
| 354 |
+
icon={<CodeIcon sx={{ fontSize: 14 }} />}
|
| 355 |
+
label="Script"
|
| 356 |
+
isActive={panelView === 'script'}
|
| 357 |
+
onClick={setPanelView}
|
| 358 |
+
/>
|
| 359 |
+
<ViewToggle
|
| 360 |
+
view="output"
|
| 361 |
+
icon={<ArticleIcon sx={{ fontSize: 14 }} />}
|
| 362 |
+
label="Result"
|
| 363 |
+
isActive={panelView === 'output'}
|
| 364 |
+
onClick={setPanelView}
|
| 365 |
+
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 366 |
</Box>
|
| 367 |
+
)}
|
| 368 |
+
</>
|
| 369 |
+
) : (
|
| 370 |
+
<Typography
|
| 371 |
+
variant="caption"
|
| 372 |
+
sx={{ fontWeight: 600, color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
| 373 |
+
>
|
| 374 |
+
Code Panel
|
| 375 |
+
</Typography>
|
| 376 |
+
)}
|
| 377 |
+
</Box>
|
| 378 |
+
|
| 379 |
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
| 380 |
+
{activeSection?.content && (
|
| 381 |
+
<Tooltip title={copied ? 'Copied!' : 'Copy'} placement="top">
|
| 382 |
+
<IconButton
|
| 383 |
+
size="small"
|
| 384 |
+
onClick={handleCopy}
|
| 385 |
+
sx={{
|
| 386 |
+
color: copied ? 'var(--accent-green)' : 'var(--muted-text)',
|
| 387 |
+
'&:hover': { color: 'var(--accent-yellow)', bgcolor: 'var(--hover-bg)' },
|
| 388 |
+
}}
|
| 389 |
+
>
|
| 390 |
+
{copied ? <CheckIcon sx={{ fontSize: 18 }} /> : <ContentCopyIcon sx={{ fontSize: 18 }} />}
|
| 391 |
+
</IconButton>
|
| 392 |
+
</Tooltip>
|
| 393 |
+
)}
|
| 394 |
+
{isEditableScript && !isEditing && (
|
| 395 |
+
<Button
|
| 396 |
+
size="small"
|
| 397 |
+
startIcon={<EditIcon sx={{ fontSize: 14 }} />}
|
| 398 |
+
onClick={handleStartEdit}
|
| 399 |
+
sx={{
|
| 400 |
+
textTransform: 'none',
|
| 401 |
+
color: 'var(--muted-text)',
|
| 402 |
+
fontSize: '0.75rem',
|
| 403 |
+
py: 0.5,
|
| 404 |
+
'&:hover': { color: 'var(--accent-yellow)', bgcolor: 'var(--hover-bg)' },
|
| 405 |
+
}}
|
| 406 |
+
>
|
| 407 |
+
Edit
|
| 408 |
+
</Button>
|
| 409 |
+
)}
|
| 410 |
+
{isEditing && (
|
| 411 |
+
<>
|
| 412 |
+
<Button
|
| 413 |
+
size="small"
|
| 414 |
+
startIcon={<UndoIcon sx={{ fontSize: 14 }} />}
|
| 415 |
+
onClick={handleCancelEdit}
|
| 416 |
+
sx={{
|
| 417 |
+
textTransform: 'none',
|
| 418 |
+
color: 'var(--muted-text)',
|
| 419 |
+
fontSize: '0.75rem',
|
| 420 |
+
py: 0.5,
|
| 421 |
+
'&:hover': { color: 'var(--accent-red)', bgcolor: 'var(--hover-bg)' },
|
| 422 |
+
}}
|
| 423 |
+
>
|
| 424 |
+
Cancel
|
| 425 |
+
</Button>
|
| 426 |
+
<Button
|
| 427 |
+
size="small"
|
| 428 |
+
variant="contained"
|
| 429 |
+
onClick={handleSaveEdit}
|
| 430 |
+
disabled={!hasUnsavedChanges}
|
| 431 |
+
sx={{
|
| 432 |
+
textTransform: 'none',
|
| 433 |
+
fontSize: '0.75rem',
|
| 434 |
+
py: 0.5,
|
| 435 |
+
bgcolor: hasUnsavedChanges ? 'var(--accent-yellow)' : 'var(--hover-bg)',
|
| 436 |
+
color: hasUnsavedChanges ? '#000' : 'var(--muted-text)',
|
| 437 |
+
'&:hover': {
|
| 438 |
+
bgcolor: hasUnsavedChanges ? 'var(--accent-yellow)' : 'var(--hover-bg)',
|
| 439 |
+
opacity: 0.9,
|
| 440 |
+
},
|
| 441 |
+
'&.Mui-disabled': {
|
| 442 |
+
bgcolor: 'var(--hover-bg)',
|
| 443 |
+
color: 'var(--muted-text)',
|
| 444 |
+
opacity: 0.5,
|
| 445 |
+
},
|
| 446 |
+
}}
|
| 447 |
+
>
|
| 448 |
+
Save
|
| 449 |
+
</Button>
|
| 450 |
+
</>
|
| 451 |
+
)}
|
| 452 |
+
<IconButton size="small" onClick={() => setRightPanelOpen(false)} sx={{ color: 'var(--muted-text)' }}>
|
| 453 |
+
<CloseIcon fontSize="small" />
|
| 454 |
+
</IconButton>
|
| 455 |
+
</Box>
|
| 456 |
</Box>
|
| 457 |
|
| 458 |
+
{/* ── Main content area ─────────────────────────────────── */}
|
| 459 |
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
| 460 |
+
{!panelData ? (
|
| 461 |
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', p: 4 }}>
|
| 462 |
<Typography variant="body2" color="text.secondary" sx={{ opacity: 0.5 }}>
|
| 463 |
NO DATA LOADED
|
|
|
|
| 469 |
ref={scrollRef}
|
| 470 |
className="code-panel"
|
| 471 |
sx={{
|
| 472 |
+
bgcolor: 'var(--code-panel-bg)',
|
| 473 |
borderRadius: 'var(--radius-md)',
|
| 474 |
+
p: '18px',
|
| 475 |
+
border: '1px solid var(--border)',
|
| 476 |
+
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
|
| 477 |
fontSize: '13px',
|
| 478 |
lineHeight: 1.55,
|
| 479 |
height: '100%',
|
| 480 |
overflow: 'auto',
|
| 481 |
}}
|
| 482 |
>
|
| 483 |
+
{renderContent()}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
</Box>
|
| 485 |
</Box>
|
| 486 |
)}
|
| 487 |
</Box>
|
| 488 |
|
| 489 |
+
{/* ── Plan display (bottom) ─────────────────────────────── */}
|
| 490 |
{plan && plan.length > 0 && (
|
| 491 |
+
<Box
|
| 492 |
+
sx={{
|
| 493 |
+
borderTop: '1px solid var(--border)',
|
| 494 |
+
bgcolor: 'var(--plan-bg)',
|
| 495 |
maxHeight: '30%',
|
| 496 |
display: 'flex',
|
| 497 |
+
flexDirection: 'column',
|
| 498 |
+
}}
|
| 499 |
+
>
|
| 500 |
+
<Box
|
| 501 |
+
sx={{
|
| 502 |
+
p: 1.5,
|
| 503 |
+
borderBottom: '1px solid var(--border)',
|
| 504 |
+
display: 'flex',
|
| 505 |
+
alignItems: 'center',
|
| 506 |
+
gap: 1,
|
| 507 |
+
}}
|
| 508 |
+
>
|
| 509 |
+
<Typography
|
| 510 |
+
variant="caption"
|
| 511 |
+
sx={{ fontWeight: 600, color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em' }}
|
| 512 |
+
>
|
| 513 |
+
CURRENT PLAN
|
| 514 |
+
</Typography>
|
| 515 |
+
</Box>
|
| 516 |
+
|
| 517 |
+
<Stack spacing={1} sx={{ p: 2, overflow: 'auto' }}>
|
| 518 |
+
{plan.map((item) => (
|
| 519 |
+
<Stack key={item.id} direction="row" alignItems="flex-start" spacing={1.5}>
|
| 520 |
+
<Box sx={{ mt: 0.2 }}>
|
| 521 |
+
<PlanStatusIcon status={item.status} />
|
| 522 |
+
</Box>
|
| 523 |
+
<Typography
|
| 524 |
+
variant="body2"
|
| 525 |
+
sx={{
|
| 526 |
+
fontSize: '13px',
|
| 527 |
+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
|
| 528 |
+
color: item.status === 'completed' ? 'var(--muted-text)' : 'var(--text)',
|
| 529 |
+
textDecoration: item.status === 'completed' ? 'line-through' : 'none',
|
| 530 |
+
opacity: item.status === 'pending' ? 0.7 : 1,
|
| 531 |
+
}}
|
| 532 |
+
>
|
| 533 |
+
{item.content}
|
| 534 |
</Typography>
|
| 535 |
+
</Stack>
|
| 536 |
+
))}
|
| 537 |
+
</Stack>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
</Box>
|
| 539 |
)}
|
| 540 |
</Box>
|
frontend/src/components/Layout/AppLayout.tsx
CHANGED
|
@@ -1,65 +1,83 @@
|
|
| 1 |
-
import { useCallback, useRef, useEffect } from 'react';
|
| 2 |
import {
|
|
|
|
| 3 |
Box,
|
| 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';
|
| 14 |
import { useLayoutStore } from '@/store/layoutStore';
|
| 15 |
-
import {
|
| 16 |
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
|
|
|
|
| 21 |
|
| 22 |
const DRAWER_WIDTH = 260;
|
| 23 |
|
| 24 |
export default function AppLayout() {
|
| 25 |
-
const { activeSessionId } = useSessionStore();
|
| 26 |
-
const { isConnected, isProcessing,
|
| 27 |
const {
|
| 28 |
isLeftSidebarOpen,
|
| 29 |
isRightPanelOpen,
|
| 30 |
rightPanelWidth,
|
|
|
|
| 31 |
setRightPanelWidth,
|
|
|
|
| 32 |
toggleLeftSidebar,
|
| 33 |
-
|
| 34 |
} = useLayoutStore();
|
| 35 |
|
| 36 |
-
const
|
|
|
|
| 37 |
|
| 38 |
-
const
|
| 39 |
-
|
| 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,75 +85,157 @@ export default function AppLayout() {
|
|
| 67 |
};
|
| 68 |
}, [handleMouseMove, stopResizing]);
|
| 69 |
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
-
|
| 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',
|
| 85 |
-
content: text.trim(),
|
| 86 |
-
timestamp: new Date().toISOString(),
|
| 87 |
-
};
|
| 88 |
-
addMessage(activeSessionId, userMsg);
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
method: 'POST',
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
| 101 |
}
|
| 102 |
},
|
| 103 |
-
[activeSessionId,
|
| 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 +243,226 @@ 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 |
-
<
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
}
|
|
|
|
| 1 |
+
import { useCallback, useRef, useEffect, useState } from 'react';
|
| 2 |
import {
|
| 3 |
+
Avatar,
|
| 4 |
Box,
|
| 5 |
Drawer,
|
| 6 |
Typography,
|
| 7 |
IconButton,
|
| 8 |
+
Alert,
|
| 9 |
+
AlertTitle,
|
| 10 |
+
Snackbar,
|
| 11 |
+
useMediaQuery,
|
| 12 |
+
useTheme,
|
| 13 |
} from '@mui/material';
|
| 14 |
import MenuIcon from '@mui/icons-material/Menu';
|
| 15 |
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
| 16 |
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
| 17 |
+
import DarkModeOutlinedIcon from '@mui/icons-material/DarkModeOutlined';
|
| 18 |
+
import LightModeOutlinedIcon from '@mui/icons-material/LightModeOutlined';
|
| 19 |
+
import { logger } from '@/utils/logger';
|
| 20 |
|
| 21 |
import { useSessionStore } from '@/store/sessionStore';
|
| 22 |
import { useAgentStore } from '@/store/agentStore';
|
| 23 |
import { useLayoutStore } from '@/store/layoutStore';
|
| 24 |
+
import { useAgentChat } from '@/hooks/useAgentChat';
|
| 25 |
import SessionSidebar from '@/components/SessionSidebar/SessionSidebar';
|
| 26 |
import CodePanel from '@/components/CodePanel/CodePanel';
|
| 27 |
import ChatInput from '@/components/Chat/ChatInput';
|
| 28 |
import MessageList from '@/components/Chat/MessageList';
|
| 29 |
+
import WelcomeScreen from '@/components/WelcomeScreen/WelcomeScreen';
|
| 30 |
+
import { apiFetch } from '@/utils/api';
|
| 31 |
|
| 32 |
const DRAWER_WIDTH = 260;
|
| 33 |
|
| 34 |
export default function AppLayout() {
|
| 35 |
+
const { sessions, activeSessionId, deleteSession, updateSessionTitle } = useSessionStore();
|
| 36 |
+
const { isConnected, isProcessing, setProcessing, activityStatus, llmHealthError, setLlmHealthError, user } = useAgentStore();
|
| 37 |
const {
|
| 38 |
isLeftSidebarOpen,
|
| 39 |
isRightPanelOpen,
|
| 40 |
rightPanelWidth,
|
| 41 |
+
themeMode,
|
| 42 |
setRightPanelWidth,
|
| 43 |
+
setLeftSidebarOpen,
|
| 44 |
toggleLeftSidebar,
|
| 45 |
+
toggleTheme,
|
| 46 |
} = useLayoutStore();
|
| 47 |
|
| 48 |
+
const theme = useTheme();
|
| 49 |
+
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
| 50 |
|
| 51 |
+
const [showExpiredToast, setShowExpiredToast] = useState(false);
|
| 52 |
+
const disconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
+
const isResizing = useRef(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
const handleMouseMove = useCallback((e: MouseEvent) => {
|
| 57 |
if (!isResizing.current) return;
|
| 58 |
const newWidth = window.innerWidth - e.clientX;
|
| 59 |
+
const maxWidth = window.innerWidth * 0.6;
|
| 60 |
const minWidth = 300;
|
| 61 |
if (newWidth > minWidth && newWidth < maxWidth) {
|
| 62 |
setRightPanelWidth(newWidth);
|
| 63 |
}
|
| 64 |
}, [setRightPanelWidth]);
|
| 65 |
|
| 66 |
+
const stopResizing = useCallback(() => {
|
| 67 |
+
isResizing.current = false;
|
| 68 |
+
document.removeEventListener('mousemove', handleMouseMove);
|
| 69 |
+
document.removeEventListener('mouseup', stopResizing);
|
| 70 |
+
document.body.style.cursor = 'default';
|
| 71 |
+
}, [handleMouseMove]);
|
| 72 |
+
|
| 73 |
+
const startResizing = useCallback((e: React.MouseEvent) => {
|
| 74 |
+
e.preventDefault();
|
| 75 |
+
isResizing.current = true;
|
| 76 |
+
document.addEventListener('mousemove', handleMouseMove);
|
| 77 |
+
document.addEventListener('mouseup', stopResizing);
|
| 78 |
+
document.body.style.cursor = 'col-resize';
|
| 79 |
+
}, [handleMouseMove, stopResizing]);
|
| 80 |
+
|
| 81 |
useEffect(() => {
|
| 82 |
return () => {
|
| 83 |
document.removeEventListener('mousemove', handleMouseMove);
|
|
|
|
| 85 |
};
|
| 86 |
}, [handleMouseMove, stopResizing]);
|
| 87 |
|
| 88 |
+
// ── LLM health check on mount ───────────────────────────────────
|
| 89 |
+
useEffect(() => {
|
| 90 |
+
let cancelled = false;
|
| 91 |
+
(async () => {
|
| 92 |
+
try {
|
| 93 |
+
const res = await apiFetch('/api/health/llm');
|
| 94 |
+
const data = await res.json();
|
| 95 |
+
if (!cancelled && data.status === 'error') {
|
| 96 |
+
setLlmHealthError({
|
| 97 |
+
error: data.error || 'Unknown LLM error',
|
| 98 |
+
errorType: data.error_type || 'unknown',
|
| 99 |
+
model: data.model,
|
| 100 |
+
});
|
| 101 |
+
} else if (!cancelled) {
|
| 102 |
+
setLlmHealthError(null);
|
| 103 |
+
}
|
| 104 |
+
} catch {
|
| 105 |
+
// Backend unreachable — not an LLM issue, ignore
|
| 106 |
+
}
|
| 107 |
+
})();
|
| 108 |
+
return () => { cancelled = true; };
|
| 109 |
+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
| 110 |
+
|
| 111 |
+
const hasAnySessions = sessions.length > 0;
|
| 112 |
|
| 113 |
+
const { messages, sendMessage, undoLastTurn, approveTools } = useAgentChat({
|
| 114 |
sessionId: activeSessionId,
|
| 115 |
+
onReady: () => logger.log('Agent ready'),
|
| 116 |
+
onError: (error) => logger.error('Agent error:', error),
|
| 117 |
+
onSessionDead: (deadSessionId) => {
|
| 118 |
+
logger.log('Removing dead session:', deadSessionId);
|
| 119 |
+
deleteSession(deadSessionId);
|
| 120 |
+
},
|
| 121 |
});
|
| 122 |
|
| 123 |
+
// Debounced "session expired" toast — only fires after 2s of sustained disconnect
|
| 124 |
+
useEffect(() => {
|
| 125 |
+
if (!isConnected && messages.length > 0 && activeSessionId) {
|
| 126 |
+
disconnectTimer.current = setTimeout(() => setShowExpiredToast(true), 2000);
|
| 127 |
+
} else {
|
| 128 |
+
if (disconnectTimer.current) clearTimeout(disconnectTimer.current);
|
| 129 |
+
disconnectTimer.current = null;
|
| 130 |
+
setShowExpiredToast(false);
|
| 131 |
+
}
|
| 132 |
+
return () => {
|
| 133 |
+
if (disconnectTimer.current) clearTimeout(disconnectTimer.current);
|
| 134 |
+
};
|
| 135 |
+
}, [isConnected, messages.length, activeSessionId]);
|
| 136 |
+
|
| 137 |
const handleSendMessage = useCallback(
|
| 138 |
async (text: string) => {
|
| 139 |
+
if (!activeSessionId || !text.trim() || isProcessing) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
+
setProcessing(true);
|
| 142 |
+
sendMessage({ text: text.trim(), metadata: { createdAt: new Date().toISOString() } });
|
| 143 |
+
|
| 144 |
+
// Auto-title the session from the first user message (async, non-blocking)
|
| 145 |
+
const isFirstMessage = messages.filter((m) => m.role === 'user').length <= 1;
|
| 146 |
+
if (isFirstMessage) {
|
| 147 |
+
const sessionId = activeSessionId;
|
| 148 |
+
apiFetch('/api/title', {
|
| 149 |
method: 'POST',
|
| 150 |
+
body: JSON.stringify({ session_id: sessionId, text: text.trim() }),
|
| 151 |
+
})
|
| 152 |
+
.then((res) => res.json())
|
| 153 |
+
.then((data) => {
|
| 154 |
+
if (data.title) updateSessionTitle(sessionId, data.title);
|
| 155 |
+
})
|
| 156 |
+
.catch(() => {
|
| 157 |
+
const raw = text.trim();
|
| 158 |
+
updateSessionTitle(sessionId, raw.length > 40 ? raw.slice(0, 40) + '…' : raw);
|
| 159 |
+
});
|
| 160 |
}
|
| 161 |
},
|
| 162 |
+
[activeSessionId, sendMessage, messages, updateSessionTitle, isProcessing, setProcessing],
|
| 163 |
);
|
| 164 |
|
| 165 |
+
// Close sidebar on mobile after selecting a session
|
| 166 |
+
const handleSidebarClose = useCallback(() => {
|
| 167 |
+
if (isMobile) setLeftSidebarOpen(false);
|
| 168 |
+
}, [isMobile, setLeftSidebarOpen]);
|
| 169 |
+
|
| 170 |
+
// ── LLM error toast helper ──────────────────────────────────────────
|
| 171 |
+
const llmErrorTitle = llmHealthError
|
| 172 |
+
? llmHealthError.errorType === 'credits'
|
| 173 |
+
? 'API Credits Exhausted'
|
| 174 |
+
: llmHealthError.errorType === 'auth'
|
| 175 |
+
? 'Invalid API Key'
|
| 176 |
+
: llmHealthError.errorType === 'rate_limit'
|
| 177 |
+
? 'Rate Limited'
|
| 178 |
+
: llmHealthError.errorType === 'network'
|
| 179 |
+
? 'LLM Provider Unreachable'
|
| 180 |
+
: 'LLM Error'
|
| 181 |
+
: '';
|
| 182 |
+
|
| 183 |
+
// ── Welcome screen: no sessions at all ────────────────────────────
|
| 184 |
+
if (!hasAnySessions) {
|
| 185 |
+
return (
|
| 186 |
+
<Box sx={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }}>
|
| 187 |
+
<WelcomeScreen />
|
| 188 |
+
</Box>
|
| 189 |
+
);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
// ── Sidebar drawer ────────────────────────────────────────────────
|
| 193 |
+
const sidebarDrawer = (
|
| 194 |
+
<Drawer
|
| 195 |
+
variant={isMobile ? 'temporary' : 'persistent'}
|
| 196 |
+
anchor="left"
|
| 197 |
+
open={isLeftSidebarOpen}
|
| 198 |
+
onClose={() => setLeftSidebarOpen(false)}
|
| 199 |
+
ModalProps={{ keepMounted: true }} // Better mobile perf
|
| 200 |
+
sx={{
|
| 201 |
+
'& .MuiDrawer-paper': {
|
| 202 |
+
boxSizing: 'border-box',
|
| 203 |
+
width: DRAWER_WIDTH,
|
| 204 |
+
borderRight: '1px solid',
|
| 205 |
+
borderColor: 'divider',
|
| 206 |
+
top: 0,
|
| 207 |
+
height: '100%',
|
| 208 |
+
bgcolor: 'var(--panel)',
|
| 209 |
+
},
|
| 210 |
+
}}
|
| 211 |
+
>
|
| 212 |
+
<SessionSidebar onClose={handleSidebarClose} />
|
| 213 |
+
</Drawer>
|
| 214 |
+
);
|
| 215 |
+
|
| 216 |
+
// ── Main chat interface ───────────────────────────────────────────
|
| 217 |
return (
|
| 218 |
<Box sx={{ display: 'flex', width: '100%', height: '100%' }}>
|
| 219 |
+
{/* ── Left Sidebar ─────────────────────────────────────────── */}
|
| 220 |
+
{isMobile ? (
|
| 221 |
+
// Mobile: temporary overlay drawer (no reserved width)
|
| 222 |
+
sidebarDrawer
|
| 223 |
+
) : (
|
| 224 |
+
// Desktop: persistent drawer with reserved width
|
| 225 |
+
<Box
|
| 226 |
+
component="nav"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
sx={{
|
| 228 |
+
width: isLeftSidebarOpen ? DRAWER_WIDTH : 0,
|
| 229 |
+
flexShrink: 0,
|
| 230 |
+
transition: isResizing.current ? 'none' : 'width 0.2s',
|
| 231 |
+
overflow: 'hidden',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
}}
|
|
|
|
| 233 |
>
|
| 234 |
+
{sidebarDrawer}
|
| 235 |
+
</Box>
|
| 236 |
+
)}
|
| 237 |
|
| 238 |
+
{/* ── Main Content (header + chat + code panel) ────────────── */}
|
| 239 |
<Box
|
| 240 |
sx={{
|
| 241 |
flexGrow: 1,
|
|
|
|
| 243 |
display: 'flex',
|
| 244 |
flexDirection: 'column',
|
| 245 |
transition: isResizing.current ? 'none' : 'width 0.2s',
|
|
|
|
| 246 |
overflow: 'hidden',
|
| 247 |
+
minWidth: 0,
|
| 248 |
}}
|
| 249 |
>
|
| 250 |
+
{/* ── Top Header Bar ─────────────────────────────────────── */}
|
| 251 |
<Box sx={{
|
| 252 |
+
height: { xs: 52, md: 60 },
|
| 253 |
+
px: { xs: 1, md: 2 },
|
| 254 |
display: 'flex',
|
| 255 |
alignItems: 'center',
|
| 256 |
borderBottom: 1,
|
| 257 |
borderColor: 'divider',
|
| 258 |
bgcolor: 'background.default',
|
| 259 |
zIndex: 1200,
|
| 260 |
+
flexShrink: 0,
|
| 261 |
}}>
|
| 262 |
<IconButton onClick={toggleLeftSidebar} size="small">
|
| 263 |
+
{isLeftSidebarOpen && !isMobile ? <ChevronLeftIcon /> : <MenuIcon />}
|
| 264 |
</IconButton>
|
| 265 |
|
| 266 |
+
<Box sx={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: 0.75 }}>
|
| 267 |
+
<Box
|
| 268 |
+
component="img"
|
| 269 |
+
src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg"
|
| 270 |
+
alt="HF"
|
| 271 |
+
sx={{ width: { xs: 20, md: 22 }, height: { xs: 20, md: 22 } }}
|
| 272 |
/>
|
| 273 |
+
<Typography
|
| 274 |
+
variant="subtitle1"
|
| 275 |
+
sx={{
|
| 276 |
+
fontWeight: 700,
|
| 277 |
+
color: 'var(--text)',
|
| 278 |
+
letterSpacing: '-0.01em',
|
| 279 |
+
fontSize: { xs: '0.88rem', md: '0.95rem' },
|
| 280 |
+
}}
|
| 281 |
+
>
|
| 282 |
+
HF Agent
|
| 283 |
+
</Typography>
|
| 284 |
</Box>
|
| 285 |
|
| 286 |
+
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
| 287 |
+
<IconButton
|
| 288 |
+
onClick={toggleTheme}
|
| 289 |
+
size="small"
|
| 290 |
+
sx={{
|
| 291 |
+
color: 'text.secondary',
|
| 292 |
+
'&:hover': { color: 'primary.main' },
|
| 293 |
+
}}
|
| 294 |
+
>
|
| 295 |
+
{themeMode === 'dark' ? <LightModeOutlinedIcon fontSize="small" /> : <DarkModeOutlinedIcon fontSize="small" />}
|
| 296 |
+
</IconButton>
|
| 297 |
+
|
| 298 |
+
{user?.picture ? (
|
| 299 |
+
<Avatar
|
| 300 |
+
src={user.picture}
|
| 301 |
+
alt={user.username || 'User'}
|
| 302 |
+
sx={{ width: 28, height: 28, ml: 0.5 }}
|
| 303 |
+
/>
|
| 304 |
+
) : user?.username ? (
|
| 305 |
+
<Avatar
|
| 306 |
+
sx={{
|
| 307 |
+
width: 28,
|
| 308 |
+
height: 28,
|
| 309 |
+
ml: 0.5,
|
| 310 |
+
bgcolor: 'primary.main',
|
| 311 |
+
fontSize: '0.75rem',
|
| 312 |
+
fontWeight: 700,
|
| 313 |
+
}}
|
| 314 |
+
>
|
| 315 |
+
{user.username[0].toUpperCase()}
|
| 316 |
+
</Avatar>
|
| 317 |
+
) : null}
|
| 318 |
+
</Box>
|
| 319 |
</Box>
|
| 320 |
|
| 321 |
+
{/* ── Chat + Code Panel ─────────────────────────���────────── */}
|
| 322 |
<Box
|
|
|
|
|
|
|
| 323 |
sx={{
|
| 324 |
flexGrow: 1,
|
| 325 |
display: 'flex',
|
|
|
|
| 326 |
overflow: 'hidden',
|
|
|
|
|
|
|
| 327 |
}}
|
| 328 |
>
|
| 329 |
+
{/* Chat area */}
|
| 330 |
+
<Box
|
| 331 |
+
component="main"
|
| 332 |
+
className="chat-pane"
|
| 333 |
+
sx={{
|
| 334 |
+
flexGrow: 1,
|
| 335 |
+
display: 'flex',
|
| 336 |
+
flexDirection: 'column',
|
| 337 |
+
overflow: 'hidden',
|
| 338 |
+
background: 'var(--body-gradient)',
|
| 339 |
+
p: { xs: 1.5, sm: 2, md: 3 },
|
| 340 |
+
minWidth: 0,
|
| 341 |
+
}}
|
| 342 |
+
>
|
| 343 |
+
{activeSessionId ? (
|
| 344 |
+
<>
|
| 345 |
+
<MessageList messages={messages} isProcessing={isProcessing} approveTools={approveTools} onUndoLastTurn={undoLastTurn} />
|
| 346 |
+
<ChatInput
|
| 347 |
+
onSend={handleSendMessage}
|
| 348 |
+
disabled={isProcessing || !isConnected || activityStatus.type === 'waiting-approval'}
|
| 349 |
+
placeholder={activityStatus.type === 'waiting-approval' ? 'Approve or reject pending tools first...' : undefined}
|
| 350 |
+
/>
|
| 351 |
+
</>
|
| 352 |
+
) : (
|
| 353 |
+
<Box
|
| 354 |
+
sx={{
|
| 355 |
+
flex: 1,
|
| 356 |
+
display: 'flex',
|
| 357 |
+
alignItems: 'center',
|
| 358 |
+
justifyContent: 'center',
|
| 359 |
+
flexDirection: 'column',
|
| 360 |
+
gap: 2,
|
| 361 |
+
px: 2,
|
| 362 |
+
}}
|
| 363 |
+
>
|
| 364 |
+
<Typography variant="h5" color="text.secondary" sx={{ fontFamily: 'monospace', fontSize: { xs: '1rem', md: '1.5rem' } }}>
|
| 365 |
+
NO SESSION SELECTED
|
| 366 |
+
</Typography>
|
| 367 |
+
<Typography variant="body2" color="text.secondary" sx={{ fontFamily: 'monospace', fontSize: { xs: '0.75rem', md: '0.875rem' } }}>
|
| 368 |
+
Initialize a session via the sidebar
|
| 369 |
+
</Typography>
|
| 370 |
+
</Box>
|
| 371 |
+
)}
|
| 372 |
+
</Box>
|
| 373 |
+
|
| 374 |
+
{/* Code panel — inline on desktop, overlay drawer on mobile */}
|
| 375 |
+
{isRightPanelOpen && !isMobile && (
|
| 376 |
<>
|
| 377 |
+
<Box
|
| 378 |
+
onMouseDown={startResizing}
|
| 379 |
+
sx={{
|
| 380 |
+
width: '4px',
|
| 381 |
+
cursor: 'col-resize',
|
| 382 |
+
bgcolor: 'divider',
|
| 383 |
+
display: 'flex',
|
| 384 |
+
alignItems: 'center',
|
| 385 |
+
justifyContent: 'center',
|
| 386 |
+
transition: 'background-color 0.2s',
|
| 387 |
+
flexShrink: 0,
|
| 388 |
+
'&:hover': { bgcolor: 'primary.main' },
|
| 389 |
+
}}
|
| 390 |
+
>
|
| 391 |
+
<DragIndicatorIcon
|
| 392 |
+
sx={{ fontSize: '0.8rem', color: 'text.secondary', pointerEvents: 'none' }}
|
| 393 |
+
/>
|
| 394 |
+
</Box>
|
| 395 |
+
<Box
|
| 396 |
+
sx={{
|
| 397 |
+
width: rightPanelWidth,
|
| 398 |
+
flexShrink: 0,
|
| 399 |
+
height: '100%',
|
| 400 |
+
overflow: 'hidden',
|
| 401 |
+
borderLeft: '1px solid',
|
| 402 |
+
borderColor: 'divider',
|
| 403 |
+
bgcolor: 'var(--panel)',
|
| 404 |
+
}}
|
| 405 |
+
>
|
| 406 |
+
<CodePanel />
|
| 407 |
+
</Box>
|
| 408 |
</>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
)}
|
| 410 |
</Box>
|
| 411 |
</Box>
|
| 412 |
|
| 413 |
+
{/* Code panel — drawer overlay on mobile */}
|
| 414 |
+
{isMobile && (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
<Drawer
|
| 416 |
+
anchor="bottom"
|
| 417 |
+
open={isRightPanelOpen}
|
| 418 |
+
onClose={() => useLayoutStore.getState().setRightPanelOpen(false)}
|
| 419 |
sx={{
|
|
|
|
| 420 |
'& .MuiDrawer-paper': {
|
| 421 |
+
height: '75vh',
|
| 422 |
+
borderTopLeftRadius: 16,
|
| 423 |
+
borderTopRightRadius: 16,
|
|
|
|
|
|
|
| 424 |
bgcolor: 'var(--panel)',
|
| 425 |
},
|
| 426 |
}}
|
|
|
|
| 427 |
>
|
| 428 |
<CodePanel />
|
| 429 |
</Drawer>
|
| 430 |
+
)}
|
| 431 |
+
<Snackbar
|
| 432 |
+
open={showExpiredToast}
|
| 433 |
+
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
| 434 |
+
onClose={() => setShowExpiredToast(false)}
|
| 435 |
+
>
|
| 436 |
+
<Alert
|
| 437 |
+
severity="warning"
|
| 438 |
+
variant="filled"
|
| 439 |
+
onClose={() => setShowExpiredToast(false)}
|
| 440 |
+
sx={{ fontFamily: 'monospace', fontSize: '0.8rem' }}
|
| 441 |
+
>
|
| 442 |
+
Session expired — create a new session to continue.
|
| 443 |
+
</Alert>
|
| 444 |
+
</Snackbar>
|
| 445 |
+
<Snackbar
|
| 446 |
+
open={!!llmHealthError}
|
| 447 |
+
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
|
| 448 |
+
onClose={() => setLlmHealthError(null)}
|
| 449 |
+
>
|
| 450 |
+
<Alert
|
| 451 |
+
severity="error"
|
| 452 |
+
variant="filled"
|
| 453 |
+
onClose={() => setLlmHealthError(null)}
|
| 454 |
+
sx={{ fontSize: '0.8rem', maxWidth: 480 }}
|
| 455 |
+
>
|
| 456 |
+
<AlertTitle sx={{ fontWeight: 700, fontSize: '0.85rem' }}>
|
| 457 |
+
{llmErrorTitle}
|
| 458 |
+
</AlertTitle>
|
| 459 |
+
{llmHealthError && (
|
| 460 |
+
<Typography variant="body2" sx={{ fontSize: '0.78rem', opacity: 0.9 }}>
|
| 461 |
+
{llmHealthError.model} — {llmHealthError.error.slice(0, 150)}
|
| 462 |
+
</Typography>
|
| 463 |
+
)}
|
| 464 |
+
</Alert>
|
| 465 |
+
</Snackbar>
|
| 466 |
</Box>
|
| 467 |
);
|
| 468 |
}
|
frontend/src/components/SessionSidebar/SessionSidebar.tsx
CHANGED
|
@@ -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 |
-
|
| 59 |
onClose?.();
|
| 60 |
-
} catch
|
| 61 |
-
|
|
|
|
|
|
|
| 62 |
}
|
| 63 |
-
}, [createSession, setPlan,
|
| 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 |
-
|
| 85 |
onClose?.();
|
| 86 |
},
|
| 87 |
-
[switchSession, setPlan,
|
| 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, clearPanel } =
|
| 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 |
+
clearPanel();
|
| 60 |
onClose?.();
|
| 61 |
+
} catch {
|
| 62 |
+
setCapacityError('Failed to create session.');
|
| 63 |
+
} finally {
|
| 64 |
+
setIsCreatingSession(false);
|
| 65 |
}
|
| 66 |
+
}, [isCreatingSession, createSession, setPlan, clearPanel, 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 |
+
clearPanel();
|
| 87 |
onClose?.();
|
| 88 |
},
|
| 89 |
+
[switchSession, setPlan, clearPanel, 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>
|
frontend/src/components/WelcomeScreen/WelcomeScreen.tsx
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useCallback } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
Box,
|
| 4 |
+
Typography,
|
| 5 |
+
Button,
|
| 6 |
+
CircularProgress,
|
| 7 |
+
Alert,
|
| 8 |
+
} from '@mui/material';
|
| 9 |
+
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
| 10 |
+
import { useSessionStore } from '@/store/sessionStore';
|
| 11 |
+
import { useAgentStore } from '@/store/agentStore';
|
| 12 |
+
import { apiFetch } from '@/utils/api';
|
| 13 |
+
import { isInIframe, triggerLogin } from '@/hooks/useAuth';
|
| 14 |
+
|
| 15 |
+
/** HF brand orange */
|
| 16 |
+
const HF_ORANGE = '#FF9D00';
|
| 17 |
+
|
| 18 |
+
export default function WelcomeScreen() {
|
| 19 |
+
const { createSession } = useSessionStore();
|
| 20 |
+
const { setPlan, clearPanel, user } = useAgentStore();
|
| 21 |
+
const [isCreating, setIsCreating] = useState(false);
|
| 22 |
+
const [error, setError] = useState<string | null>(null);
|
| 23 |
+
|
| 24 |
+
const inIframe = isInIframe();
|
| 25 |
+
const isAuthenticated = user?.authenticated;
|
| 26 |
+
const isDevUser = user?.username === 'dev';
|
| 27 |
+
|
| 28 |
+
const handleStart = useCallback(async () => {
|
| 29 |
+
if (isCreating) return;
|
| 30 |
+
|
| 31 |
+
// Not authenticated and not dev → need to login
|
| 32 |
+
if (!isAuthenticated && !isDevUser) {
|
| 33 |
+
// In iframe: can't redirect (cookies blocked) — user needs to open in new tab
|
| 34 |
+
// This shouldn't happen because we show a different button in iframe
|
| 35 |
+
// But just in case:
|
| 36 |
+
if (inIframe) return;
|
| 37 |
+
triggerLogin();
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
setIsCreating(true);
|
| 42 |
+
setError(null);
|
| 43 |
+
|
| 44 |
+
try {
|
| 45 |
+
const response = await apiFetch('/api/session', { method: 'POST' });
|
| 46 |
+
if (response.status === 503) {
|
| 47 |
+
const data = await response.json();
|
| 48 |
+
setError(data.detail || 'Server is at capacity. Please try again later.');
|
| 49 |
+
return;
|
| 50 |
+
}
|
| 51 |
+
if (response.status === 401) {
|
| 52 |
+
triggerLogin();
|
| 53 |
+
return;
|
| 54 |
+
}
|
| 55 |
+
if (!response.ok) {
|
| 56 |
+
setError('Failed to create session. Please try again.');
|
| 57 |
+
return;
|
| 58 |
+
}
|
| 59 |
+
const data = await response.json();
|
| 60 |
+
createSession(data.session_id);
|
| 61 |
+
setPlan([]);
|
| 62 |
+
clearPanel();
|
| 63 |
+
} catch {
|
| 64 |
+
// Redirect may throw — ignore
|
| 65 |
+
} finally {
|
| 66 |
+
setIsCreating(false);
|
| 67 |
+
}
|
| 68 |
+
}, [isCreating, createSession, setPlan, clearPanel, isAuthenticated, isDevUser, inIframe]);
|
| 69 |
+
|
| 70 |
+
// Build the direct Space URL for the "open in new tab" link
|
| 71 |
+
const spaceHost = typeof window !== 'undefined'
|
| 72 |
+
? window.location.hostname.includes('.hf.space')
|
| 73 |
+
? window.location.origin
|
| 74 |
+
: `https://smolagents-ml-agent.hf.space`
|
| 75 |
+
: '';
|
| 76 |
+
|
| 77 |
+
return (
|
| 78 |
+
<Box
|
| 79 |
+
sx={{
|
| 80 |
+
width: '100%',
|
| 81 |
+
height: '100%',
|
| 82 |
+
display: 'flex',
|
| 83 |
+
flexDirection: 'column',
|
| 84 |
+
alignItems: 'center',
|
| 85 |
+
justifyContent: 'center',
|
| 86 |
+
background: 'var(--body-gradient)',
|
| 87 |
+
py: 8,
|
| 88 |
+
}}
|
| 89 |
+
>
|
| 90 |
+
{/* HF Logo */}
|
| 91 |
+
<Box
|
| 92 |
+
component="img"
|
| 93 |
+
src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg"
|
| 94 |
+
alt="Hugging Face"
|
| 95 |
+
sx={{ width: 96, height: 96, mb: 3, display: 'block' }}
|
| 96 |
+
/>
|
| 97 |
+
|
| 98 |
+
{/* Title */}
|
| 99 |
+
<Typography
|
| 100 |
+
variant="h2"
|
| 101 |
+
sx={{
|
| 102 |
+
fontWeight: 800,
|
| 103 |
+
color: 'var(--text)',
|
| 104 |
+
mb: 1.5,
|
| 105 |
+
letterSpacing: '-0.02em',
|
| 106 |
+
fontSize: { xs: '2rem', md: '2.8rem' },
|
| 107 |
+
}}
|
| 108 |
+
>
|
| 109 |
+
HF Agent
|
| 110 |
+
</Typography>
|
| 111 |
+
|
| 112 |
+
{/* Description */}
|
| 113 |
+
<Typography
|
| 114 |
+
variant="body1"
|
| 115 |
+
sx={{
|
| 116 |
+
color: 'var(--muted-text)',
|
| 117 |
+
maxWidth: 520,
|
| 118 |
+
mb: 5,
|
| 119 |
+
lineHeight: 1.8,
|
| 120 |
+
fontSize: '0.95rem',
|
| 121 |
+
textAlign: 'center',
|
| 122 |
+
px: 2,
|
| 123 |
+
'& strong': { color: 'var(--text)', fontWeight: 600 },
|
| 124 |
+
}}
|
| 125 |
+
>
|
| 126 |
+
A general-purpose AI agent for <strong>machine learning engineering</strong>.
|
| 127 |
+
It browses <strong>Hugging Face documentation</strong>, manages{' '}
|
| 128 |
+
<strong>repositories</strong>, launches <strong>training jobs</strong>,
|
| 129 |
+
and explores <strong>datasets</strong> — all through natural conversation.
|
| 130 |
+
</Typography>
|
| 131 |
+
|
| 132 |
+
{/* Action button — depends on context */}
|
| 133 |
+
{inIframe && !isAuthenticated && !isDevUser ? (
|
| 134 |
+
// In iframe + not logged in → link to open Space directly
|
| 135 |
+
<Button
|
| 136 |
+
variant="contained"
|
| 137 |
+
size="large"
|
| 138 |
+
component="a"
|
| 139 |
+
href={spaceHost}
|
| 140 |
+
target="_blank"
|
| 141 |
+
rel="noopener noreferrer"
|
| 142 |
+
endIcon={<OpenInNewIcon />}
|
| 143 |
+
sx={{
|
| 144 |
+
px: 5,
|
| 145 |
+
py: 1.5,
|
| 146 |
+
fontSize: '1rem',
|
| 147 |
+
fontWeight: 700,
|
| 148 |
+
textTransform: 'none',
|
| 149 |
+
borderRadius: '12px',
|
| 150 |
+
bgcolor: HF_ORANGE,
|
| 151 |
+
color: '#000',
|
| 152 |
+
boxShadow: '0 4px 24px rgba(255, 157, 0, 0.3)',
|
| 153 |
+
textDecoration: 'none',
|
| 154 |
+
'&:hover': {
|
| 155 |
+
bgcolor: '#FFB340',
|
| 156 |
+
boxShadow: '0 6px 32px rgba(255, 157, 0, 0.45)',
|
| 157 |
+
},
|
| 158 |
+
}}
|
| 159 |
+
>
|
| 160 |
+
Open HF Agent
|
| 161 |
+
</Button>
|
| 162 |
+
) : !isAuthenticated && !isDevUser ? (
|
| 163 |
+
// Direct access + not logged in → sign in button
|
| 164 |
+
<Button
|
| 165 |
+
variant="contained"
|
| 166 |
+
size="large"
|
| 167 |
+
onClick={() => triggerLogin()}
|
| 168 |
+
sx={{
|
| 169 |
+
px: 5,
|
| 170 |
+
py: 1.5,
|
| 171 |
+
fontSize: '1rem',
|
| 172 |
+
fontWeight: 700,
|
| 173 |
+
textTransform: 'none',
|
| 174 |
+
borderRadius: '12px',
|
| 175 |
+
bgcolor: HF_ORANGE,
|
| 176 |
+
color: '#000',
|
| 177 |
+
boxShadow: '0 4px 24px rgba(255, 157, 0, 0.3)',
|
| 178 |
+
'&:hover': {
|
| 179 |
+
bgcolor: '#FFB340',
|
| 180 |
+
boxShadow: '0 6px 32px rgba(255, 157, 0, 0.45)',
|
| 181 |
+
},
|
| 182 |
+
}}
|
| 183 |
+
>
|
| 184 |
+
Sign in with Hugging Face
|
| 185 |
+
</Button>
|
| 186 |
+
) : (
|
| 187 |
+
// Authenticated or dev → start session
|
| 188 |
+
<Button
|
| 189 |
+
variant="contained"
|
| 190 |
+
size="large"
|
| 191 |
+
onClick={handleStart}
|
| 192 |
+
disabled={isCreating}
|
| 193 |
+
startIcon={
|
| 194 |
+
isCreating ? <CircularProgress size={20} color="inherit" /> : null
|
| 195 |
+
}
|
| 196 |
+
sx={{
|
| 197 |
+
px: 5,
|
| 198 |
+
py: 1.5,
|
| 199 |
+
fontSize: '1rem',
|
| 200 |
+
fontWeight: 700,
|
| 201 |
+
textTransform: 'none',
|
| 202 |
+
borderRadius: '12px',
|
| 203 |
+
bgcolor: HF_ORANGE,
|
| 204 |
+
color: '#000',
|
| 205 |
+
boxShadow: '0 4px 24px rgba(255, 157, 0, 0.3)',
|
| 206 |
+
'&:hover': {
|
| 207 |
+
bgcolor: '#FFB340',
|
| 208 |
+
boxShadow: '0 6px 32px rgba(255, 157, 0, 0.45)',
|
| 209 |
+
},
|
| 210 |
+
'&.Mui-disabled': {
|
| 211 |
+
bgcolor: 'rgba(255, 157, 0, 0.35)',
|
| 212 |
+
color: 'rgba(0,0,0,0.45)',
|
| 213 |
+
},
|
| 214 |
+
}}
|
| 215 |
+
>
|
| 216 |
+
{isCreating ? 'Initializing...' : 'Start Session'}
|
| 217 |
+
</Button>
|
| 218 |
+
)}
|
| 219 |
+
|
| 220 |
+
{/* Error */}
|
| 221 |
+
{error && (
|
| 222 |
+
<Alert
|
| 223 |
+
severity="warning"
|
| 224 |
+
variant="outlined"
|
| 225 |
+
onClose={() => setError(null)}
|
| 226 |
+
sx={{
|
| 227 |
+
mt: 3,
|
| 228 |
+
maxWidth: 400,
|
| 229 |
+
fontSize: '0.8rem',
|
| 230 |
+
borderColor: HF_ORANGE,
|
| 231 |
+
color: 'var(--text)',
|
| 232 |
+
}}
|
| 233 |
+
>
|
| 234 |
+
{error}
|
| 235 |
+
</Alert>
|
| 236 |
+
)}
|
| 237 |
+
|
| 238 |
+
{/* Footnote */}
|
| 239 |
+
<Typography
|
| 240 |
+
variant="caption"
|
| 241 |
+
sx={{ mt: 5, color: 'var(--muted-text)', opacity: 0.5, fontSize: '0.7rem' }}
|
| 242 |
+
>
|
| 243 |
+
Conversations are stored locally in your browser.
|
| 244 |
+
</Typography>
|
| 245 |
+
</Box>
|
| 246 |
+
);
|
| 247 |
+
}
|
frontend/src/hooks/useAgentChat.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Central hook wiring the Vercel AI SDK's useChat with our custom
|
| 3 |
+
* WebSocketChatTransport. Replaces the old useAgentWebSocket + agentStore
|
| 4 |
+
* message management.
|
| 5 |
+
*/
|
| 6 |
+
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
| 7 |
+
import { useChat } from '@ai-sdk/react';
|
| 8 |
+
import type { UIMessage } from 'ai';
|
| 9 |
+
import { WebSocketChatTransport, type SideChannelCallbacks } from '@/lib/ws-chat-transport';
|
| 10 |
+
import { loadMessages, saveMessages } from '@/lib/chat-message-store';
|
| 11 |
+
import { apiFetch } from '@/utils/api';
|
| 12 |
+
import { useAgentStore } from '@/store/agentStore';
|
| 13 |
+
import { useSessionStore } from '@/store/sessionStore';
|
| 14 |
+
import { useLayoutStore } from '@/store/layoutStore';
|
| 15 |
+
import { logger } from '@/utils/logger';
|
| 16 |
+
|
| 17 |
+
interface UseAgentChatOptions {
|
| 18 |
+
sessionId: string | null;
|
| 19 |
+
onReady?: () => void;
|
| 20 |
+
onError?: (error: string) => void;
|
| 21 |
+
onSessionDead?: (sessionId: string) => void;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export function useAgentChat({ sessionId, onReady, onError, onSessionDead }: UseAgentChatOptions) {
|
| 25 |
+
const callbacksRef = useRef({ onReady, onError, onSessionDead });
|
| 26 |
+
callbacksRef.current = { onReady, onError, onSessionDead };
|
| 27 |
+
|
| 28 |
+
const {
|
| 29 |
+
setProcessing,
|
| 30 |
+
setConnected,
|
| 31 |
+
setActivityStatus,
|
| 32 |
+
setError,
|
| 33 |
+
setPanel,
|
| 34 |
+
setPanelOutput,
|
| 35 |
+
} = useAgentStore();
|
| 36 |
+
|
| 37 |
+
const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
|
| 38 |
+
const { setSessionActive } = useSessionStore();
|
| 39 |
+
|
| 40 |
+
// ── Build side-channel callbacks (stable ref) ────────────────────
|
| 41 |
+
const sideChannel = useMemo<SideChannelCallbacks>(
|
| 42 |
+
() => ({
|
| 43 |
+
onReady: () => {
|
| 44 |
+
setConnected(true);
|
| 45 |
+
setProcessing(false);
|
| 46 |
+
if (sessionId) setSessionActive(sessionId, true);
|
| 47 |
+
callbacksRef.current.onReady?.();
|
| 48 |
+
},
|
| 49 |
+
onShutdown: () => {
|
| 50 |
+
setConnected(false);
|
| 51 |
+
setProcessing(false);
|
| 52 |
+
},
|
| 53 |
+
onError: (error: string) => {
|
| 54 |
+
setError(error);
|
| 55 |
+
setProcessing(false);
|
| 56 |
+
callbacksRef.current.onError?.(error);
|
| 57 |
+
},
|
| 58 |
+
onProcessing: () => {
|
| 59 |
+
setProcessing(true);
|
| 60 |
+
setActivityStatus({ type: 'thinking' });
|
| 61 |
+
},
|
| 62 |
+
onProcessingDone: () => {
|
| 63 |
+
setProcessing(false);
|
| 64 |
+
},
|
| 65 |
+
onUndoComplete: () => {
|
| 66 |
+
setProcessing(false);
|
| 67 |
+
// Remove the last turn (user msg + assistant response) from useChat state
|
| 68 |
+
const setMsgs = chatActionsRef.current.setMessages;
|
| 69 |
+
const msgs = chatActionsRef.current.messages;
|
| 70 |
+
if (setMsgs && msgs.length > 0) {
|
| 71 |
+
let lastUserIdx = -1;
|
| 72 |
+
for (let i = msgs.length - 1; i >= 0; i--) {
|
| 73 |
+
if (msgs[i].role === 'user') { lastUserIdx = i; break; }
|
| 74 |
+
}
|
| 75 |
+
const updated = lastUserIdx > 0 ? msgs.slice(0, lastUserIdx) : [];
|
| 76 |
+
setMsgs(updated);
|
| 77 |
+
if (sessionId) saveMessages(sessionId, updated);
|
| 78 |
+
}
|
| 79 |
+
},
|
| 80 |
+
onCompacted: (oldTokens: number, newTokens: number) => {
|
| 81 |
+
logger.log(`Context compacted: ${oldTokens} → ${newTokens} tokens`);
|
| 82 |
+
},
|
| 83 |
+
onPlanUpdate: (plan) => {
|
| 84 |
+
useAgentStore.getState().setPlan(plan as Array<{ id: string; content: string; status: 'pending' | 'in_progress' | 'completed' }>);
|
| 85 |
+
if (!useLayoutStore.getState().isRightPanelOpen) {
|
| 86 |
+
setRightPanelOpen(true);
|
| 87 |
+
}
|
| 88 |
+
},
|
| 89 |
+
onToolLog: (tool: string, log: string) => {
|
| 90 |
+
if (tool === 'hf_jobs') {
|
| 91 |
+
const state = useAgentStore.getState();
|
| 92 |
+
const existingOutput = state.panelData?.output?.content || '';
|
| 93 |
+
const newContent = existingOutput
|
| 94 |
+
? existingOutput + '\n' + log
|
| 95 |
+
: '--- Job execution started ---\n' + log;
|
| 96 |
+
|
| 97 |
+
setPanelOutput({ content: newContent, language: 'text' });
|
| 98 |
+
|
| 99 |
+
if (!useLayoutStore.getState().isRightPanelOpen) {
|
| 100 |
+
setRightPanelOpen(true);
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
},
|
| 104 |
+
onConnectionChange: (connected: boolean) => {
|
| 105 |
+
setConnected(connected);
|
| 106 |
+
},
|
| 107 |
+
onSessionDead: (deadSessionId: string) => {
|
| 108 |
+
logger.warn(`Session ${deadSessionId} dead, removing`);
|
| 109 |
+
callbacksRef.current.onSessionDead?.(deadSessionId);
|
| 110 |
+
},
|
| 111 |
+
onApprovalRequired: (tools) => {
|
| 112 |
+
if (!tools.length) return;
|
| 113 |
+
setActivityStatus({ type: 'waiting-approval' });
|
| 114 |
+
const firstTool = tools[0];
|
| 115 |
+
const args = firstTool.arguments as Record<string, string | undefined>;
|
| 116 |
+
|
| 117 |
+
if (firstTool.tool === 'hf_jobs' && args.script) {
|
| 118 |
+
setPanel(
|
| 119 |
+
{ title: 'Script', script: { content: args.script, language: 'python' }, parameters: firstTool.arguments as Record<string, unknown> },
|
| 120 |
+
'script',
|
| 121 |
+
true,
|
| 122 |
+
);
|
| 123 |
+
} else if (firstTool.tool === 'hf_repo_files' && args.content) {
|
| 124 |
+
const filename = args.path || 'file';
|
| 125 |
+
setPanel({
|
| 126 |
+
title: filename.split('/').pop() || 'Content',
|
| 127 |
+
script: { content: args.content, language: filename.endsWith('.py') ? 'python' : 'text' },
|
| 128 |
+
parameters: firstTool.arguments as Record<string, unknown>,
|
| 129 |
+
});
|
| 130 |
+
} else {
|
| 131 |
+
setPanel({
|
| 132 |
+
title: firstTool.tool,
|
| 133 |
+
output: { content: JSON.stringify(firstTool.arguments, null, 2), language: 'json' },
|
| 134 |
+
}, 'output');
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
setRightPanelOpen(true);
|
| 138 |
+
setLeftSidebarOpen(false);
|
| 139 |
+
},
|
| 140 |
+
onToolCallPanel: (toolName: string, args: Record<string, unknown>) => {
|
| 141 |
+
if (toolName === 'hf_jobs' && args.operation && args.script) {
|
| 142 |
+
setPanel(
|
| 143 |
+
{ title: 'Script', script: { content: String(args.script), language: 'python' }, parameters: args },
|
| 144 |
+
'script',
|
| 145 |
+
);
|
| 146 |
+
setRightPanelOpen(true);
|
| 147 |
+
setLeftSidebarOpen(false);
|
| 148 |
+
} else if (toolName === 'hf_repo_files' && args.operation === 'upload' && args.content) {
|
| 149 |
+
setPanel({
|
| 150 |
+
title: `File Upload: ${String(args.path || 'unnamed')}`,
|
| 151 |
+
script: { content: String(args.content), language: String(args.path || '').endsWith('.py') ? 'python' : 'text' },
|
| 152 |
+
parameters: args,
|
| 153 |
+
});
|
| 154 |
+
setRightPanelOpen(true);
|
| 155 |
+
setLeftSidebarOpen(false);
|
| 156 |
+
}
|
| 157 |
+
},
|
| 158 |
+
onToolOutputPanel: (toolName: string, _toolCallId: string, output: string, success: boolean) => {
|
| 159 |
+
if (toolName === 'hf_jobs' && output) {
|
| 160 |
+
setPanelOutput({ content: output, language: 'markdown' });
|
| 161 |
+
if (!success) useAgentStore.getState().setPanelView('output');
|
| 162 |
+
}
|
| 163 |
+
},
|
| 164 |
+
onStreaming: () => {
|
| 165 |
+
setActivityStatus({ type: 'streaming' });
|
| 166 |
+
},
|
| 167 |
+
onToolRunning: (toolName: string) => {
|
| 168 |
+
setActivityStatus({ type: 'tool', toolName });
|
| 169 |
+
},
|
| 170 |
+
}),
|
| 171 |
+
// Zustand setters are stable
|
| 172 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 173 |
+
[sessionId],
|
| 174 |
+
);
|
| 175 |
+
|
| 176 |
+
// ── Create transport (single stable instance for the lifetime of this hook) ──
|
| 177 |
+
const transportRef = useRef<WebSocketChatTransport | null>(null);
|
| 178 |
+
if (!transportRef.current) {
|
| 179 |
+
transportRef.current = new WebSocketChatTransport({ sideChannel });
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
// Keep side-channel callbacks in sync (they capture sessionId)
|
| 183 |
+
useEffect(() => {
|
| 184 |
+
transportRef.current?.updateSideChannel(sideChannel);
|
| 185 |
+
}, [sideChannel]);
|
| 186 |
+
|
| 187 |
+
// Connect / disconnect WebSocket when session changes
|
| 188 |
+
useEffect(() => {
|
| 189 |
+
transportRef.current?.connectToSession(sessionId);
|
| 190 |
+
return () => {
|
| 191 |
+
transportRef.current?.connectToSession(null);
|
| 192 |
+
};
|
| 193 |
+
}, [sessionId]);
|
| 194 |
+
|
| 195 |
+
// ── Restore persisted messages for this session ─────────────────
|
| 196 |
+
const initialMessages = useMemo(
|
| 197 |
+
() => (sessionId ? loadMessages(sessionId) : []),
|
| 198 |
+
[sessionId],
|
| 199 |
+
);
|
| 200 |
+
|
| 201 |
+
// ── Ref for chat actions (used by sideChannel callbacks created before chat) ──
|
| 202 |
+
const chatActionsRef = useRef<{
|
| 203 |
+
setMessages: ((msgs: UIMessage[]) => void) | null;
|
| 204 |
+
messages: UIMessage[];
|
| 205 |
+
}>({ setMessages: null, messages: [] });
|
| 206 |
+
|
| 207 |
+
// ── useChat from Vercel AI SDK ───────────────────────────────────
|
| 208 |
+
const chat = useChat({
|
| 209 |
+
id: sessionId || '__no_session__',
|
| 210 |
+
messages: initialMessages,
|
| 211 |
+
transport: transportRef.current!,
|
| 212 |
+
experimental_throttle: 80,
|
| 213 |
+
onFinish: ({ messages, isAbort, isError }) => {
|
| 214 |
+
if (isAbort || isError) return;
|
| 215 |
+
if (sessionId && messages.length > 0) {
|
| 216 |
+
saveMessages(sessionId, messages);
|
| 217 |
+
}
|
| 218 |
+
},
|
| 219 |
+
onError: (error) => {
|
| 220 |
+
logger.error('useChat error:', error);
|
| 221 |
+
setError(error.message);
|
| 222 |
+
setProcessing(false);
|
| 223 |
+
},
|
| 224 |
+
});
|
| 225 |
+
|
| 226 |
+
// Keep chatActionsRef in sync every render
|
| 227 |
+
chatActionsRef.current.setMessages = chat.setMessages;
|
| 228 |
+
chatActionsRef.current.messages = chat.messages;
|
| 229 |
+
|
| 230 |
+
// ── Persist messages on every user send (onFinish covers assistant turns) ──
|
| 231 |
+
const prevLenRef = useRef(initialMessages.length);
|
| 232 |
+
useEffect(() => {
|
| 233 |
+
if (!sessionId || chat.messages.length === 0) return;
|
| 234 |
+
if (chat.messages.length !== prevLenRef.current) {
|
| 235 |
+
prevLenRef.current = chat.messages.length;
|
| 236 |
+
saveMessages(sessionId, chat.messages);
|
| 237 |
+
}
|
| 238 |
+
}, [sessionId, chat.messages]);
|
| 239 |
+
|
| 240 |
+
// ── Undo last turn (calls backend + syncs useChat + localStorage) ──
|
| 241 |
+
const undoLastTurn = useCallback(async () => {
|
| 242 |
+
if (!sessionId) return;
|
| 243 |
+
try {
|
| 244 |
+
const res = await apiFetch(`/api/undo/${sessionId}`, { method: 'POST' });
|
| 245 |
+
if (!res.ok) {
|
| 246 |
+
logger.error('Undo API returned', res.status);
|
| 247 |
+
return;
|
| 248 |
+
}
|
| 249 |
+
} catch (e) {
|
| 250 |
+
logger.error('Undo failed:', e);
|
| 251 |
+
}
|
| 252 |
+
// Backend will also send undo_complete, but we apply optimistically
|
| 253 |
+
// so the UI updates immediately.
|
| 254 |
+
}, [sessionId]);
|
| 255 |
+
|
| 256 |
+
// ── Convenience: approve tools via transport ─────────────────────
|
| 257 |
+
const approveTools = useCallback(
|
| 258 |
+
async (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null; edited_script?: string | null }>) => {
|
| 259 |
+
if (!sessionId || !transportRef.current) return false;
|
| 260 |
+
const ok = await transportRef.current.approveTools(sessionId, approvals);
|
| 261 |
+
if (ok) {
|
| 262 |
+
const hasApproved = approvals.some(a => a.approved);
|
| 263 |
+
if (hasApproved) setProcessing(true);
|
| 264 |
+
}
|
| 265 |
+
return ok;
|
| 266 |
+
},
|
| 267 |
+
[sessionId, setProcessing],
|
| 268 |
+
);
|
| 269 |
+
|
| 270 |
+
return {
|
| 271 |
+
messages: chat.messages,
|
| 272 |
+
sendMessage: chat.sendMessage,
|
| 273 |
+
status: chat.status,
|
| 274 |
+
undoLastTurn,
|
| 275 |
+
approveTools,
|
| 276 |
+
transport: transportRef.current,
|
| 277 |
+
};
|
| 278 |
+
}
|
frontend/src/hooks/useAgentWebSocket.ts
DELETED
|
@@ -1,503 +0,0 @@
|
|
| 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,
|
| 35 |
-
clearTraceLogs,
|
| 36 |
-
setPanelContent,
|
| 37 |
-
setPanelTab,
|
| 38 |
-
setActivePanelTab,
|
| 39 |
-
clearPanelTabs,
|
| 40 |
-
setPlan,
|
| 41 |
-
setCurrentTurnMessageId,
|
| 42 |
-
updateCurrentTurnTrace,
|
| 43 |
-
} = useAgentStore();
|
| 44 |
-
|
| 45 |
-
const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
|
| 46 |
-
|
| 47 |
-
const { setSessionActive } = useSessionStore();
|
| 48 |
-
|
| 49 |
-
const handleEvent = useCallback(
|
| 50 |
-
(event: AgentEvent) => {
|
| 51 |
-
if (!sessionId) return;
|
| 52 |
-
|
| 53 |
-
switch (event.event_type) {
|
| 54 |
-
case 'ready':
|
| 55 |
-
setConnected(true);
|
| 56 |
-
setProcessing(false);
|
| 57 |
-
setSessionActive(sessionId, true);
|
| 58 |
-
onReady?.();
|
| 59 |
-
break;
|
| 60 |
-
|
| 61 |
-
case 'processing':
|
| 62 |
-
setProcessing(true);
|
| 63 |
-
clearTraceLogs();
|
| 64 |
-
// Don't clear panel tabs here - they should persist during approval flow
|
| 65 |
-
// Tabs will be cleared when a new tool_call sets up new content
|
| 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;
|
| 72 |
-
const currentTurnMsgId = useAgentStore.getState().currentTurnMessageId;
|
| 73 |
-
|
| 74 |
-
if (currentTurnMsgId) {
|
| 75 |
-
// Update existing message - add segments chronologically
|
| 76 |
-
const messages = useAgentStore.getState().getMessages(sessionId);
|
| 77 |
-
const existingMsg = messages.find(m => m.id === currentTurnMsgId);
|
| 78 |
-
|
| 79 |
-
if (existingMsg) {
|
| 80 |
-
const segments = existingMsg.segments ? [...existingMsg.segments] : [];
|
| 81 |
-
|
| 82 |
-
// If there are pending traces, add them as a tools segment first
|
| 83 |
-
if (currentTrace.length > 0) {
|
| 84 |
-
segments.push({ type: 'tools', tools: [...currentTrace] });
|
| 85 |
-
clearTraceLogs();
|
| 86 |
-
}
|
| 87 |
-
|
| 88 |
-
// Add the new text segment
|
| 89 |
-
if (content) {
|
| 90 |
-
segments.push({ type: 'text', content });
|
| 91 |
-
}
|
| 92 |
-
|
| 93 |
-
updateMessage(sessionId, currentTurnMsgId, {
|
| 94 |
-
content: existingMsg.content + '\n\n' + content,
|
| 95 |
-
segments,
|
| 96 |
-
});
|
| 97 |
-
}
|
| 98 |
-
} else {
|
| 99 |
-
// Create new message
|
| 100 |
-
const messageId = `msg_${Date.now()}`;
|
| 101 |
-
const segments: Array<{ type: 'text' | 'tools'; content?: string; tools?: typeof currentTrace }> = [];
|
| 102 |
-
|
| 103 |
-
// Add any pending traces first
|
| 104 |
-
if (currentTrace.length > 0) {
|
| 105 |
-
segments.push({ type: 'tools', tools: [...currentTrace] });
|
| 106 |
-
clearTraceLogs();
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
// Add the text
|
| 110 |
-
if (content) {
|
| 111 |
-
segments.push({ type: 'text', content });
|
| 112 |
-
}
|
| 113 |
-
|
| 114 |
-
const message: Message = {
|
| 115 |
-
id: messageId,
|
| 116 |
-
role: 'assistant',
|
| 117 |
-
content,
|
| 118 |
-
timestamp: new Date().toISOString(),
|
| 119 |
-
segments,
|
| 120 |
-
};
|
| 121 |
-
addMessage(sessionId, message);
|
| 122 |
-
setCurrentTurnMessageId(messageId);
|
| 123 |
-
}
|
| 124 |
-
break;
|
| 125 |
-
}
|
| 126 |
-
|
| 127 |
-
case 'tool_call': {
|
| 128 |
-
const toolName = (event.data?.tool as string) || 'unknown';
|
| 129 |
-
const args = (event.data?.arguments as Record<string, any>) || {};
|
| 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 |
-
// Store args for auto-exec message creation later
|
| 141 |
-
args: toolName === 'hf_jobs' ? args : undefined,
|
| 142 |
-
};
|
| 143 |
-
addTraceLog(log);
|
| 144 |
-
// Update the current turn message's trace in real-time
|
| 145 |
-
updateCurrentTurnTrace(sessionId);
|
| 146 |
-
}
|
| 147 |
-
|
| 148 |
-
// Auto-expand Right Panel for specific tools
|
| 149 |
-
if (toolName === 'hf_jobs' && (args.operation === 'run' || args.operation === 'scheduled run') && args.script) {
|
| 150 |
-
// Clear any existing tabs from previous jobs before setting new script
|
| 151 |
-
clearPanelTabs();
|
| 152 |
-
// Use tab system for jobs - add script tab immediately
|
| 153 |
-
setPanelTab({
|
| 154 |
-
id: 'script',
|
| 155 |
-
title: 'Script',
|
| 156 |
-
content: args.script,
|
| 157 |
-
language: 'python',
|
| 158 |
-
parameters: args
|
| 159 |
-
});
|
| 160 |
-
setActivePanelTab('script');
|
| 161 |
-
setRightPanelOpen(true);
|
| 162 |
-
setLeftSidebarOpen(false);
|
| 163 |
-
} else if (toolName === 'hf_repo_files' && args.operation === 'upload' && args.content) {
|
| 164 |
-
setPanelContent({
|
| 165 |
-
title: `File Upload: ${args.path || 'unnamed'}`,
|
| 166 |
-
content: args.content,
|
| 167 |
-
parameters: args,
|
| 168 |
-
language: args.path?.endsWith('.py') ? 'python' : undefined
|
| 169 |
-
});
|
| 170 |
-
setRightPanelOpen(true);
|
| 171 |
-
setLeftSidebarOpen(false);
|
| 172 |
-
}
|
| 173 |
-
|
| 174 |
-
console.log('Tool call:', toolName, args);
|
| 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 |
-
updateTraceLog(toolName, { completed: true, output, success });
|
| 185 |
-
// Update the current turn message's trace in real-time
|
| 186 |
-
updateCurrentTurnTrace(sessionId);
|
| 187 |
-
|
| 188 |
-
// Special handling for hf_jobs - update or create job message with output
|
| 189 |
-
if (toolName === 'hf_jobs') {
|
| 190 |
-
const messages = useAgentStore.getState().getMessages(sessionId);
|
| 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 |
-
if (!jobMsg) {
|
| 197 |
-
// No approval message exists - this was an auto-executed job
|
| 198 |
-
// Create a job execution message so user can see results
|
| 199 |
-
const jobTrace = [...traceLogs].reverse().find(t => t.tool === 'hf_jobs');
|
| 200 |
-
const args = jobTrace?.args || {};
|
| 201 |
-
|
| 202 |
-
const autoExecMessage: Message = {
|
| 203 |
-
id: `msg_auto_${Date.now()}`,
|
| 204 |
-
role: 'assistant',
|
| 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 |
-
useAgentStore.getState().updateMessage(sessionId, jobMsg.id, {
|
| 228 |
-
toolOutput: newOutput
|
| 229 |
-
});
|
| 230 |
-
console.log('Updated job message with tool output:', toolName);
|
| 231 |
-
}
|
| 232 |
-
}
|
| 233 |
-
|
| 234 |
-
// Don't create message bubbles for tool outputs - they only show in trace logs
|
| 235 |
-
console.log('Tool output:', toolName, success);
|
| 236 |
-
break;
|
| 237 |
-
}
|
| 238 |
-
|
| 239 |
-
case 'tool_log': {
|
| 240 |
-
const toolName = (event.data?.tool as string) || 'unknown';
|
| 241 |
-
const log = (event.data?.log as string) || '';
|
| 242 |
-
|
| 243 |
-
if (toolName === 'hf_jobs') {
|
| 244 |
-
const currentTabs = useAgentStore.getState().panelTabs;
|
| 245 |
-
const logsTab = currentTabs.find(t => t.id === 'logs');
|
| 246 |
-
|
| 247 |
-
// Append to existing logs tab or create new one
|
| 248 |
-
const newContent = logsTab
|
| 249 |
-
? logsTab.content + '\n' + log
|
| 250 |
-
: '--- Job execution started ---\n' + log;
|
| 251 |
-
|
| 252 |
-
setPanelTab({
|
| 253 |
-
id: 'logs',
|
| 254 |
-
title: 'Logs',
|
| 255 |
-
content: newContent,
|
| 256 |
-
language: 'text'
|
| 257 |
-
});
|
| 258 |
-
|
| 259 |
-
// Auto-switch to logs tab when logs start streaming
|
| 260 |
-
setActivePanelTab('logs');
|
| 261 |
-
|
| 262 |
-
if (!useLayoutStore.getState().isRightPanelOpen) {
|
| 263 |
-
setRightPanelOpen(true);
|
| 264 |
-
}
|
| 265 |
-
}
|
| 266 |
-
break;
|
| 267 |
-
}
|
| 268 |
-
|
| 269 |
-
case 'plan_update': {
|
| 270 |
-
const plan = (event.data?.plan as any[]) || [];
|
| 271 |
-
setPlan(plan);
|
| 272 |
-
if (!useLayoutStore.getState().isRightPanelOpen) {
|
| 273 |
-
setRightPanelOpen(true);
|
| 274 |
-
}
|
| 275 |
-
break;
|
| 276 |
-
}
|
| 277 |
-
|
| 278 |
-
case 'approval_required': {
|
| 279 |
-
const tools = event.data?.tools as Array<{
|
| 280 |
-
tool: string;
|
| 281 |
-
arguments: Record<string, unknown>;
|
| 282 |
-
tool_call_id: string;
|
| 283 |
-
}>;
|
| 284 |
-
const count = (event.data?.count as number) || 0;
|
| 285 |
-
|
| 286 |
-
// Create a persistent message for the approval request
|
| 287 |
-
const message: Message = {
|
| 288 |
-
id: `msg_approval_${Date.now()}`,
|
| 289 |
-
role: 'assistant',
|
| 290 |
-
content: '', // Content is handled by the approval UI
|
| 291 |
-
timestamp: new Date().toISOString(),
|
| 292 |
-
approval: {
|
| 293 |
-
status: 'pending',
|
| 294 |
-
batch: { tools, count }
|
| 295 |
-
}
|
| 296 |
-
};
|
| 297 |
-
addMessage(sessionId, message);
|
| 298 |
-
|
| 299 |
-
// Show the first tool's content in the panel so users see what they're approving
|
| 300 |
-
if (tools && tools.length > 0) {
|
| 301 |
-
const firstTool = tools[0];
|
| 302 |
-
const args = firstTool.arguments as Record<string, any>;
|
| 303 |
-
|
| 304 |
-
clearPanelTabs();
|
| 305 |
-
|
| 306 |
-
if (firstTool.tool === 'hf_jobs' && args.script) {
|
| 307 |
-
setPanelTab({
|
| 308 |
-
id: 'script',
|
| 309 |
-
title: 'Script',
|
| 310 |
-
content: args.script,
|
| 311 |
-
language: 'python',
|
| 312 |
-
parameters: args
|
| 313 |
-
});
|
| 314 |
-
setActivePanelTab('script');
|
| 315 |
-
} else if (firstTool.tool === 'hf_repo_files' && args.content) {
|
| 316 |
-
const filename = args.path || 'file';
|
| 317 |
-
const isPython = filename.endsWith('.py');
|
| 318 |
-
setPanelTab({
|
| 319 |
-
id: 'content',
|
| 320 |
-
title: filename.split('/').pop() || 'Content',
|
| 321 |
-
content: args.content,
|
| 322 |
-
language: isPython ? 'python' : 'text',
|
| 323 |
-
parameters: args
|
| 324 |
-
});
|
| 325 |
-
setActivePanelTab('content');
|
| 326 |
-
} else {
|
| 327 |
-
// For other tools, show args as JSON
|
| 328 |
-
setPanelTab({
|
| 329 |
-
id: 'args',
|
| 330 |
-
title: firstTool.tool,
|
| 331 |
-
content: JSON.stringify(args, null, 2),
|
| 332 |
-
language: 'json',
|
| 333 |
-
parameters: args
|
| 334 |
-
});
|
| 335 |
-
setActivePanelTab('args');
|
| 336 |
-
}
|
| 337 |
-
|
| 338 |
-
setRightPanelOpen(true);
|
| 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 |
-
}
|
| 350 |
-
|
| 351 |
-
case 'turn_complete':
|
| 352 |
-
setProcessing(false);
|
| 353 |
-
setCurrentTurnMessageId(null); // Clear the current turn
|
| 354 |
-
break;
|
| 355 |
-
|
| 356 |
-
case 'compacted': {
|
| 357 |
-
const oldTokens = event.data?.old_tokens as number;
|
| 358 |
-
const newTokens = event.data?.new_tokens as number;
|
| 359 |
-
console.log(`Context compacted: ${oldTokens} -> ${newTokens} tokens`);
|
| 360 |
-
break;
|
| 361 |
-
}
|
| 362 |
-
|
| 363 |
-
case 'error': {
|
| 364 |
-
const errorMsg = (event.data?.error as string) || 'Unknown error';
|
| 365 |
-
setError(errorMsg);
|
| 366 |
-
setProcessing(false);
|
| 367 |
-
onError?.(errorMsg);
|
| 368 |
-
break;
|
| 369 |
-
}
|
| 370 |
-
|
| 371 |
-
case 'shutdown':
|
| 372 |
-
setConnected(false);
|
| 373 |
-
setProcessing(false);
|
| 374 |
-
break;
|
| 375 |
-
|
| 376 |
-
case 'interrupted':
|
| 377 |
-
setProcessing(false);
|
| 378 |
-
break;
|
| 379 |
-
|
| 380 |
-
case 'undo_complete':
|
| 381 |
-
// Could remove last messages from store
|
| 382 |
-
break;
|
| 383 |
-
|
| 384 |
-
default:
|
| 385 |
-
console.log('Unknown event:', event);
|
| 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(() => {
|
| 394 |
-
if (!sessionId) return;
|
| 395 |
-
|
| 396 |
-
// Don't connect if already connected or connecting
|
| 397 |
-
if (wsRef.current?.readyState === WebSocket.OPEN ||
|
| 398 |
-
wsRef.current?.readyState === WebSocket.CONNECTING) {
|
| 399 |
-
return;
|
| 400 |
-
}
|
| 401 |
-
|
| 402 |
-
// Connect directly to backend (Vite doesn't proxy WebSockets)
|
| 403 |
-
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 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 |
-
console.log('Connecting to WebSocket:', wsUrl);
|
| 411 |
-
const ws = new WebSocket(wsUrl);
|
| 412 |
-
|
| 413 |
-
ws.onopen = () => {
|
| 414 |
-
console.log('WebSocket connected');
|
| 415 |
-
setConnected(true);
|
| 416 |
-
reconnectDelayRef.current = WS_RECONNECT_DELAY;
|
| 417 |
-
};
|
| 418 |
-
|
| 419 |
-
ws.onmessage = (event) => {
|
| 420 |
-
try {
|
| 421 |
-
const data = JSON.parse(event.data) as AgentEvent;
|
| 422 |
-
handleEvent(data);
|
| 423 |
-
} catch (e) {
|
| 424 |
-
console.error('Failed to parse WebSocket message:', e);
|
| 425 |
-
}
|
| 426 |
-
};
|
| 427 |
-
|
| 428 |
-
ws.onerror = (error) => {
|
| 429 |
-
console.error('WebSocket error:', error);
|
| 430 |
-
};
|
| 431 |
-
|
| 432 |
-
ws.onclose = (event) => {
|
| 433 |
-
console.log('WebSocket closed', event.code, event.reason);
|
| 434 |
-
setConnected(false);
|
| 435 |
-
|
| 436 |
-
// Only reconnect if it wasn't a normal closure and session still exists
|
| 437 |
-
if (event.code !== 1000 && sessionId) {
|
| 438 |
-
// Attempt to reconnect with exponential backoff
|
| 439 |
-
if (reconnectTimeoutRef.current) {
|
| 440 |
-
clearTimeout(reconnectTimeoutRef.current);
|
| 441 |
-
}
|
| 442 |
-
reconnectTimeoutRef.current = window.setTimeout(() => {
|
| 443 |
-
reconnectDelayRef.current = Math.min(
|
| 444 |
-
reconnectDelayRef.current * 2,
|
| 445 |
-
WS_MAX_RECONNECT_DELAY
|
| 446 |
-
);
|
| 447 |
-
connect();
|
| 448 |
-
}, reconnectDelayRef.current);
|
| 449 |
-
}
|
| 450 |
-
};
|
| 451 |
-
|
| 452 |
-
wsRef.current = ws;
|
| 453 |
-
}, [sessionId, handleEvent]);
|
| 454 |
-
|
| 455 |
-
const disconnect = useCallback(() => {
|
| 456 |
-
if (reconnectTimeoutRef.current) {
|
| 457 |
-
clearTimeout(reconnectTimeoutRef.current);
|
| 458 |
-
reconnectTimeoutRef.current = null;
|
| 459 |
-
}
|
| 460 |
-
if (wsRef.current) {
|
| 461 |
-
wsRef.current.close();
|
| 462 |
-
wsRef.current = null;
|
| 463 |
-
}
|
| 464 |
-
setConnected(false);
|
| 465 |
-
}, []);
|
| 466 |
-
|
| 467 |
-
const sendPing = useCallback(() => {
|
| 468 |
-
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
| 469 |
-
wsRef.current.send(JSON.stringify({ type: 'ping' }));
|
| 470 |
-
}
|
| 471 |
-
}, []);
|
| 472 |
-
|
| 473 |
-
// Connect when sessionId changes (with a small delay to ensure session is ready)
|
| 474 |
-
useEffect(() => {
|
| 475 |
-
if (!sessionId) {
|
| 476 |
-
disconnect();
|
| 477 |
-
return;
|
| 478 |
-
}
|
| 479 |
-
|
| 480 |
-
// Small delay to ensure session is fully created on backend
|
| 481 |
-
const timeoutId = setTimeout(() => {
|
| 482 |
-
connect();
|
| 483 |
-
}, 100);
|
| 484 |
-
|
| 485 |
-
return () => {
|
| 486 |
-
clearTimeout(timeoutId);
|
| 487 |
-
disconnect();
|
| 488 |
-
};
|
| 489 |
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 490 |
-
}, [sessionId]);
|
| 491 |
-
|
| 492 |
-
// Heartbeat
|
| 493 |
-
useEffect(() => {
|
| 494 |
-
const interval = setInterval(sendPing, 30000);
|
| 495 |
-
return () => clearInterval(interval);
|
| 496 |
-
}, [sendPing]);
|
| 497 |
-
|
| 498 |
-
return {
|
| 499 |
-
isConnected: wsRef.current?.readyState === WebSocket.OPEN,
|
| 500 |
-
connect,
|
| 501 |
-
disconnect,
|
| 502 |
-
};
|
| 503 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/src/hooks/useAuth.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Authentication hook — simple server-side OAuth.
|
| 3 |
+
*
|
| 4 |
+
* - Hors iframe: /auth/login redirect (cookies work fine)
|
| 5 |
+
* - Dans iframe: show "Open in full page" link
|
| 6 |
+
*
|
| 7 |
+
* Token is stored via HttpOnly cookie by the backend.
|
| 8 |
+
* In dev mode (no OAUTH_CLIENT_ID), auth is bypassed.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { useEffect } from 'react';
|
| 12 |
+
import { useAgentStore } from '@/store/agentStore';
|
| 13 |
+
import { logger } from '@/utils/logger';
|
| 14 |
+
|
| 15 |
+
/** Check if we're running inside an iframe. */
|
| 16 |
+
export function isInIframe(): boolean {
|
| 17 |
+
try {
|
| 18 |
+
return window.top !== window.self;
|
| 19 |
+
} catch {
|
| 20 |
+
return true; // SecurityError = cross-origin iframe
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/** Redirect to the server-side OAuth login. */
|
| 25 |
+
export function triggerLogin(): void {
|
| 26 |
+
window.location.href = '/auth/login';
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* Hook: on mount, check if user is authenticated.
|
| 31 |
+
* Sets user in the agent store.
|
| 32 |
+
*/
|
| 33 |
+
export function useAuth() {
|
| 34 |
+
const setUser = useAgentStore((s) => s.setUser);
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
let cancelled = false;
|
| 38 |
+
|
| 39 |
+
async function checkAuth() {
|
| 40 |
+
try {
|
| 41 |
+
// Check if user is already authenticated (cookie-based)
|
| 42 |
+
const response = await fetch('/auth/me', { credentials: 'include' });
|
| 43 |
+
if (response.ok) {
|
| 44 |
+
const data = await response.json();
|
| 45 |
+
if (!cancelled && data.authenticated) {
|
| 46 |
+
setUser({
|
| 47 |
+
authenticated: true,
|
| 48 |
+
username: data.username,
|
| 49 |
+
name: data.name,
|
| 50 |
+
picture: data.picture,
|
| 51 |
+
});
|
| 52 |
+
logger.log('Authenticated as', data.username);
|
| 53 |
+
return;
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// Not authenticated — check if auth is enabled
|
| 58 |
+
const statusRes = await fetch('/auth/status', { credentials: 'include' });
|
| 59 |
+
const statusData = await statusRes.json();
|
| 60 |
+
if (!statusData.auth_enabled) {
|
| 61 |
+
// Dev mode — no OAuth configured
|
| 62 |
+
if (!cancelled) setUser({ authenticated: true, username: 'dev' });
|
| 63 |
+
return;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
// Auth enabled but not logged in — welcome screen will handle it
|
| 67 |
+
if (!cancelled) setUser(null);
|
| 68 |
+
} catch {
|
| 69 |
+
// Backend unreachable — assume dev mode
|
| 70 |
+
if (!cancelled) setUser({ authenticated: true, username: 'dev' });
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
checkAuth();
|
| 75 |
+
return () => { cancelled = true; };
|
| 76 |
+
}, [setUser]);
|
| 77 |
+
}
|
frontend/src/lib/chat-message-store.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Lightweight localStorage persistence for UIMessage arrays,
|
| 3 |
+
* keyed by session ID.
|
| 4 |
+
*
|
| 5 |
+
* Uses the same storage namespace (`hf-agent-messages`) that the
|
| 6 |
+
* old Zustand-based store used, so existing data is compatible.
|
| 7 |
+
*/
|
| 8 |
+
import type { UIMessage } from 'ai';
|
| 9 |
+
import { logger } from '@/utils/logger';
|
| 10 |
+
|
| 11 |
+
const STORAGE_KEY = 'hf-agent-messages';
|
| 12 |
+
const MAX_SESSIONS = 50;
|
| 13 |
+
|
| 14 |
+
type MessagesMap = Record<string, UIMessage[]>;
|
| 15 |
+
|
| 16 |
+
function readAll(): MessagesMap {
|
| 17 |
+
try {
|
| 18 |
+
const raw = localStorage.getItem(STORAGE_KEY);
|
| 19 |
+
if (!raw) return {};
|
| 20 |
+
const parsed = JSON.parse(raw);
|
| 21 |
+
// Legacy format was { messagesBySession: {...} }
|
| 22 |
+
if (parsed.messagesBySession) return parsed.messagesBySession;
|
| 23 |
+
// New flat format
|
| 24 |
+
if (typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
|
| 25 |
+
return {};
|
| 26 |
+
} catch {
|
| 27 |
+
return {};
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function writeAll(map: MessagesMap): void {
|
| 32 |
+
try {
|
| 33 |
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
| 34 |
+
} catch (e) {
|
| 35 |
+
logger.warn('Failed to persist messages:', e);
|
| 36 |
+
}
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export function loadMessages(sessionId: string): UIMessage[] {
|
| 40 |
+
const map = readAll();
|
| 41 |
+
return map[sessionId] ?? [];
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export function saveMessages(sessionId: string, messages: UIMessage[]): void {
|
| 45 |
+
const map = readAll();
|
| 46 |
+
map[sessionId] = messages;
|
| 47 |
+
|
| 48 |
+
// Evict oldest sessions if we exceed the cap
|
| 49 |
+
const keys = Object.keys(map);
|
| 50 |
+
if (keys.length > MAX_SESSIONS) {
|
| 51 |
+
const toRemove = keys.slice(0, keys.length - MAX_SESSIONS);
|
| 52 |
+
for (const k of toRemove) delete map[k];
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
writeAll(map);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
export function deleteMessages(sessionId: string): void {
|
| 59 |
+
const map = readAll();
|
| 60 |
+
delete map[sessionId];
|
| 61 |
+
writeAll(map);
|
| 62 |
+
}
|
frontend/src/lib/ws-chat-transport.ts
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Custom ChatTransport that bridges our WebSocket-based backend protocol
|
| 3 |
+
* to the Vercel AI SDK's UIMessageChunk streaming interface.
|
| 4 |
+
*
|
| 5 |
+
* The backend stays unchanged — this adapter translates WebSocket events
|
| 6 |
+
* into the chunk types that useChat() expects.
|
| 7 |
+
*/
|
| 8 |
+
import type { ChatTransport, UIMessage, UIMessageChunk, ChatRequestOptions } from 'ai';
|
| 9 |
+
import { apiFetch, getWebSocketUrl } from '@/utils/api';
|
| 10 |
+
import { logger } from '@/utils/logger';
|
| 11 |
+
import type { AgentEvent } from '@/types/events';
|
| 12 |
+
import { useAgentStore } from '@/store/agentStore';
|
| 13 |
+
|
| 14 |
+
// ---------------------------------------------------------------------------
|
| 15 |
+
// Side-channel callback interface (non-chat events forwarded to the store)
|
| 16 |
+
// ---------------------------------------------------------------------------
|
| 17 |
+
export interface SideChannelCallbacks {
|
| 18 |
+
onReady: () => void;
|
| 19 |
+
onShutdown: () => void;
|
| 20 |
+
onError: (error: string) => void;
|
| 21 |
+
onProcessing: () => void;
|
| 22 |
+
onProcessingDone: () => void;
|
| 23 |
+
onUndoComplete: () => void;
|
| 24 |
+
onCompacted: (oldTokens: number, newTokens: number) => void;
|
| 25 |
+
onPlanUpdate: (plan: Array<{ id: string; content: string; status: string }>) => void;
|
| 26 |
+
onToolLog: (tool: string, log: string) => void;
|
| 27 |
+
onConnectionChange: (connected: boolean) => void;
|
| 28 |
+
onSessionDead: (sessionId: string) => void;
|
| 29 |
+
/** Called when approval_required arrives — lets the store manage panels */
|
| 30 |
+
onApprovalRequired: (tools: Array<{ tool: string; arguments: Record<string, unknown>; tool_call_id: string }>) => void;
|
| 31 |
+
/** Called when a tool_call arrives with panel-relevant args */
|
| 32 |
+
onToolCallPanel: (tool: string, args: Record<string, unknown>) => void;
|
| 33 |
+
/** Called when tool_output arrives with panel-relevant data */
|
| 34 |
+
onToolOutputPanel: (tool: string, toolCallId: string, output: string, success: boolean) => void;
|
| 35 |
+
/** Called when assistant text starts streaming */
|
| 36 |
+
onStreaming: () => void;
|
| 37 |
+
/** Called when a tool starts running (non-plan) */
|
| 38 |
+
onToolRunning: (toolName: string) => void;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
// ---------------------------------------------------------------------------
|
| 42 |
+
// Transport options
|
| 43 |
+
// ---------------------------------------------------------------------------
|
| 44 |
+
export interface WebSocketChatTransportOptions {
|
| 45 |
+
sideChannel: SideChannelCallbacks;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// ---------------------------------------------------------------------------
|
| 49 |
+
// Constants
|
| 50 |
+
// ---------------------------------------------------------------------------
|
| 51 |
+
const WS_RECONNECT_DELAY = 1000;
|
| 52 |
+
const WS_MAX_RECONNECT_DELAY = 30000;
|
| 53 |
+
const WS_MAX_RETRIES = 5;
|
| 54 |
+
const WS_PING_INTERVAL = 30000;
|
| 55 |
+
|
| 56 |
+
let partIdCounter = 0;
|
| 57 |
+
function nextPartId(prefix: string): string {
|
| 58 |
+
return `${prefix}-${Date.now()}-${++partIdCounter}`;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// ---------------------------------------------------------------------------
|
| 62 |
+
// Transport implementation
|
| 63 |
+
// ---------------------------------------------------------------------------
|
| 64 |
+
export class WebSocketChatTransport implements ChatTransport<UIMessage> {
|
| 65 |
+
private ws: WebSocket | null = null;
|
| 66 |
+
private currentSessionId: string | null = null;
|
| 67 |
+
private sideChannel: SideChannelCallbacks;
|
| 68 |
+
|
| 69 |
+
private streamController: ReadableStreamDefaultController<UIMessageChunk> | null = null;
|
| 70 |
+
private streamGeneration = 0;
|
| 71 |
+
private abortedGeneration = 0;
|
| 72 |
+
private textPartId: string | null = null;
|
| 73 |
+
private awaitingProcessing = false;
|
| 74 |
+
|
| 75 |
+
private connectTimeout: ReturnType<typeof setTimeout> | null = null;
|
| 76 |
+
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
| 77 |
+
private reconnectDelay = WS_RECONNECT_DELAY;
|
| 78 |
+
private retries = 0;
|
| 79 |
+
private pingInterval: ReturnType<typeof setInterval> | null = null;
|
| 80 |
+
private boundVisibilityHandler: (() => void) | null = null;
|
| 81 |
+
private wasHidden = false;
|
| 82 |
+
|
| 83 |
+
constructor({ sideChannel }: WebSocketChatTransportOptions) {
|
| 84 |
+
this.sideChannel = sideChannel;
|
| 85 |
+
this.setupVisibilityHandler();
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
private setupVisibilityHandler(): void {
|
| 89 |
+
this.boundVisibilityHandler = () => {
|
| 90 |
+
if (document.visibilityState === 'hidden') {
|
| 91 |
+
this.wasHidden = true;
|
| 92 |
+
return;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
if (document.visibilityState === 'visible' && this.currentSessionId) {
|
| 96 |
+
const wsState = this.ws?.readyState;
|
| 97 |
+
if (wsState !== WebSocket.OPEN && wsState !== WebSocket.CONNECTING) {
|
| 98 |
+
logger.log('Tab visible: WS is dead, reconnecting immediately');
|
| 99 |
+
this.retries = 0;
|
| 100 |
+
this.reconnectDelay = WS_RECONNECT_DELAY;
|
| 101 |
+
this.createWebSocket(this.currentSessionId);
|
| 102 |
+
|
| 103 |
+
if (this.wasHidden) {
|
| 104 |
+
const store = useAgentStore.getState();
|
| 105 |
+
if (store.isProcessing) {
|
| 106 |
+
logger.log('Tab visible after WS drop: resetting stale processing state');
|
| 107 |
+
store.setProcessing(false);
|
| 108 |
+
this.closeActiveStream();
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
} else if (wsState === WebSocket.OPEN) {
|
| 112 |
+
this.ws!.send(JSON.stringify({ type: 'ping' }));
|
| 113 |
+
}
|
| 114 |
+
this.wasHidden = false;
|
| 115 |
+
}
|
| 116 |
+
};
|
| 117 |
+
document.addEventListener('visibilitychange', this.boundVisibilityHandler);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/** Update side-channel callbacks (e.g. when sessionId changes). */
|
| 121 |
+
updateSideChannel(sideChannel: SideChannelCallbacks): void {
|
| 122 |
+
this.sideChannel = sideChannel;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// ── Public API ──────────────────────────────────────────────────────
|
| 126 |
+
|
| 127 |
+
/** Connect (or reconnect) to a session's WebSocket. */
|
| 128 |
+
connectToSession(sessionId: string | null): void {
|
| 129 |
+
if (this.connectTimeout) {
|
| 130 |
+
clearTimeout(this.connectTimeout);
|
| 131 |
+
this.connectTimeout = null;
|
| 132 |
+
}
|
| 133 |
+
this.disconnectWebSocket();
|
| 134 |
+
this.currentSessionId = sessionId;
|
| 135 |
+
if (sessionId) {
|
| 136 |
+
this.retries = 0;
|
| 137 |
+
this.reconnectDelay = WS_RECONNECT_DELAY;
|
| 138 |
+
this.connectTimeout = setTimeout(() => {
|
| 139 |
+
this.connectTimeout = null;
|
| 140 |
+
if (this.currentSessionId === sessionId) {
|
| 141 |
+
this.createWebSocket(sessionId);
|
| 142 |
+
}
|
| 143 |
+
}, 100);
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
/** Approve / reject tools. Called directly from the UI. */
|
| 148 |
+
async approveTools(
|
| 149 |
+
sessionId: string,
|
| 150 |
+
approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null; edited_script?: string | null }>,
|
| 151 |
+
): Promise<boolean> {
|
| 152 |
+
try {
|
| 153 |
+
const res = await apiFetch('/api/approve', {
|
| 154 |
+
method: 'POST',
|
| 155 |
+
body: JSON.stringify({ session_id: sessionId, approvals }),
|
| 156 |
+
});
|
| 157 |
+
return res.ok;
|
| 158 |
+
} catch (e) {
|
| 159 |
+
logger.error('Approval request failed:', e);
|
| 160 |
+
return false;
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/** Clean up everything. */
|
| 165 |
+
destroy(): void {
|
| 166 |
+
if (this.connectTimeout) {
|
| 167 |
+
clearTimeout(this.connectTimeout);
|
| 168 |
+
this.connectTimeout = null;
|
| 169 |
+
}
|
| 170 |
+
if (this.boundVisibilityHandler) {
|
| 171 |
+
document.removeEventListener('visibilitychange', this.boundVisibilityHandler);
|
| 172 |
+
this.boundVisibilityHandler = null;
|
| 173 |
+
}
|
| 174 |
+
this.disconnectWebSocket();
|
| 175 |
+
this.closeActiveStream();
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
// ── ChatTransport interface ─────────────────────────────────────────
|
| 179 |
+
|
| 180 |
+
async sendMessages(
|
| 181 |
+
options: {
|
| 182 |
+
trigger: 'submit-message' | 'regenerate-message';
|
| 183 |
+
chatId: string;
|
| 184 |
+
messageId: string | undefined;
|
| 185 |
+
messages: UIMessage[];
|
| 186 |
+
abortSignal: AbortSignal | undefined;
|
| 187 |
+
} & ChatRequestOptions,
|
| 188 |
+
): Promise<ReadableStream<UIMessageChunk>> {
|
| 189 |
+
const sessionId = options.chatId;
|
| 190 |
+
|
| 191 |
+
// Close any previously active stream (e.g. user sent new msg during approval)
|
| 192 |
+
this.closeActiveStream();
|
| 193 |
+
|
| 194 |
+
// Track generation to protect against late cancel from a stale stream
|
| 195 |
+
const gen = ++this.streamGeneration;
|
| 196 |
+
logger.log(`sendMessages: gen=${gen}, awaitingProcessing=${this.awaitingProcessing}, abortedGen=${this.abortedGeneration}`);
|
| 197 |
+
|
| 198 |
+
// Wire up abort signal to interrupt the backend and close the stream
|
| 199 |
+
if (options.abortSignal) {
|
| 200 |
+
const onAbort = () => {
|
| 201 |
+
if (this.streamGeneration !== gen) return;
|
| 202 |
+
logger.log(`Stream aborted by user (gen=${gen})`);
|
| 203 |
+
this.interruptBackend(sessionId);
|
| 204 |
+
this.endTextPart();
|
| 205 |
+
if (this.streamController) {
|
| 206 |
+
this.enqueue({ type: 'finish-step' });
|
| 207 |
+
this.enqueue({ type: 'finish', finishReason: 'stop' });
|
| 208 |
+
this.closeActiveStream();
|
| 209 |
+
}
|
| 210 |
+
this.awaitingProcessing = true;
|
| 211 |
+
this.abortedGeneration = this.streamGeneration;
|
| 212 |
+
logger.log(`Abort complete: awaitingProcessing=true, abortedGen=${this.abortedGeneration}`);
|
| 213 |
+
this.sideChannel.onProcessingDone();
|
| 214 |
+
};
|
| 215 |
+
if (options.abortSignal.aborted) {
|
| 216 |
+
onAbort();
|
| 217 |
+
} else {
|
| 218 |
+
options.abortSignal.addEventListener('abort', onAbort, { once: true });
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
// Create the stream BEFORE the POST so WebSocket events arriving
|
| 223 |
+
// while the HTTP request is in-flight are captured immediately.
|
| 224 |
+
const stream = new ReadableStream<UIMessageChunk>({
|
| 225 |
+
start: (controller) => {
|
| 226 |
+
this.streamController = controller;
|
| 227 |
+
this.textPartId = null;
|
| 228 |
+
},
|
| 229 |
+
cancel: () => {
|
| 230 |
+
if (this.streamGeneration === gen) {
|
| 231 |
+
this.streamController = null;
|
| 232 |
+
this.textPartId = null;
|
| 233 |
+
}
|
| 234 |
+
},
|
| 235 |
+
});
|
| 236 |
+
|
| 237 |
+
// Extract the latest user text from the messages array
|
| 238 |
+
const lastUserMsg = [...options.messages].reverse().find(m => m.role === 'user');
|
| 239 |
+
const text = lastUserMsg
|
| 240 |
+
? lastUserMsg.parts
|
| 241 |
+
.filter((p): p is Extract<typeof p, { type: 'text' }> => p.type === 'text')
|
| 242 |
+
.map(p => p.text)
|
| 243 |
+
.join('')
|
| 244 |
+
: '';
|
| 245 |
+
|
| 246 |
+
// POST to the existing backend endpoint
|
| 247 |
+
try {
|
| 248 |
+
await apiFetch('/api/submit', {
|
| 249 |
+
method: 'POST',
|
| 250 |
+
body: JSON.stringify({ session_id: sessionId, text }),
|
| 251 |
+
});
|
| 252 |
+
} catch (e) {
|
| 253 |
+
logger.error('Submit failed:', e);
|
| 254 |
+
this.enqueue({ type: 'error', errorText: 'Failed to send message' });
|
| 255 |
+
this.closeActiveStream();
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
return stream;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
async reconnectToStream(): Promise<ReadableStream<UIMessageChunk> | null> {
|
| 262 |
+
return null;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
/** Ask the backend to interrupt the current generation. Fire-and-forget. */
|
| 266 |
+
private interruptBackend(sessionId: string): void {
|
| 267 |
+
apiFetch(`/api/interrupt/${sessionId}`, { method: 'POST' }).catch((e) =>
|
| 268 |
+
logger.warn('Interrupt request failed:', e),
|
| 269 |
+
);
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
// ── WebSocket lifecycle ─────────────────────────────────────────────
|
| 273 |
+
|
| 274 |
+
private createWebSocket(sessionId: string): void {
|
| 275 |
+
if (this.ws?.readyState === WebSocket.OPEN || this.ws?.readyState === WebSocket.CONNECTING) {
|
| 276 |
+
return;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
const wsUrl = getWebSocketUrl(sessionId);
|
| 280 |
+
logger.log('WS transport connecting:', wsUrl);
|
| 281 |
+
const ws = new WebSocket(wsUrl);
|
| 282 |
+
|
| 283 |
+
ws.onopen = () => {
|
| 284 |
+
logger.log('WS transport connected');
|
| 285 |
+
this.sideChannel.onConnectionChange(true);
|
| 286 |
+
this.reconnectDelay = WS_RECONNECT_DELAY;
|
| 287 |
+
this.retries = 0;
|
| 288 |
+
this.startPing();
|
| 289 |
+
};
|
| 290 |
+
|
| 291 |
+
ws.onmessage = (evt) => {
|
| 292 |
+
try {
|
| 293 |
+
const raw = JSON.parse(evt.data);
|
| 294 |
+
if (raw.type === 'pong') return;
|
| 295 |
+
this.handleEvent(raw as AgentEvent);
|
| 296 |
+
} catch (e) {
|
| 297 |
+
logger.error('WS parse error:', e);
|
| 298 |
+
}
|
| 299 |
+
};
|
| 300 |
+
|
| 301 |
+
ws.onerror = (err) => logger.error('WS error:', err);
|
| 302 |
+
|
| 303 |
+
ws.onclose = (evt) => {
|
| 304 |
+
logger.log('WS closed', evt.code, evt.reason);
|
| 305 |
+
this.sideChannel.onConnectionChange(false);
|
| 306 |
+
this.stopPing();
|
| 307 |
+
|
| 308 |
+
const noRetry = [1000, 4001, 4003, 4004];
|
| 309 |
+
if (evt.code === 4004 && sessionId) {
|
| 310 |
+
this.sideChannel.onSessionDead(sessionId);
|
| 311 |
+
return;
|
| 312 |
+
}
|
| 313 |
+
if (!noRetry.includes(evt.code) && this.currentSessionId === sessionId) {
|
| 314 |
+
this.retries += 1;
|
| 315 |
+
if (this.retries > WS_MAX_RETRIES) {
|
| 316 |
+
logger.warn('WS max retries reached');
|
| 317 |
+
this.sideChannel.onSessionDead(sessionId);
|
| 318 |
+
return;
|
| 319 |
+
}
|
| 320 |
+
this.reconnectTimeout = setTimeout(() => {
|
| 321 |
+
this.reconnectDelay = Math.min(this.reconnectDelay * 2, WS_MAX_RECONNECT_DELAY);
|
| 322 |
+
this.createWebSocket(sessionId);
|
| 323 |
+
}, this.reconnectDelay);
|
| 324 |
+
}
|
| 325 |
+
};
|
| 326 |
+
|
| 327 |
+
this.ws = ws;
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
private disconnectWebSocket(): void {
|
| 331 |
+
if (this.reconnectTimeout) {
|
| 332 |
+
clearTimeout(this.reconnectTimeout);
|
| 333 |
+
this.reconnectTimeout = null;
|
| 334 |
+
}
|
| 335 |
+
this.stopPing();
|
| 336 |
+
if (this.ws) {
|
| 337 |
+
this.ws.close();
|
| 338 |
+
this.ws = null;
|
| 339 |
+
}
|
| 340 |
+
this.sideChannel.onConnectionChange(false);
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
private startPing(): void {
|
| 344 |
+
this.stopPing();
|
| 345 |
+
this.pingInterval = setInterval(() => {
|
| 346 |
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
| 347 |
+
this.ws.send(JSON.stringify({ type: 'ping' }));
|
| 348 |
+
}
|
| 349 |
+
}, WS_PING_INTERVAL);
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
private stopPing(): void {
|
| 353 |
+
if (this.pingInterval) {
|
| 354 |
+
clearInterval(this.pingInterval);
|
| 355 |
+
this.pingInterval = null;
|
| 356 |
+
}
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
// ── Stream helpers ──────────────────────────────────────────────────
|
| 360 |
+
|
| 361 |
+
private closeActiveStream(): void {
|
| 362 |
+
if (this.streamController) {
|
| 363 |
+
try {
|
| 364 |
+
this.streamController.close();
|
| 365 |
+
} catch {
|
| 366 |
+
// already closed
|
| 367 |
+
}
|
| 368 |
+
this.streamController = null;
|
| 369 |
+
this.textPartId = null;
|
| 370 |
+
}
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
private enqueue(chunk: UIMessageChunk): void {
|
| 374 |
+
try {
|
| 375 |
+
this.streamController?.enqueue(chunk);
|
| 376 |
+
} catch {
|
| 377 |
+
// stream already closed
|
| 378 |
+
}
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
private endTextPart(): void {
|
| 382 |
+
if (this.textPartId) {
|
| 383 |
+
this.enqueue({ type: 'text-end', id: this.textPartId });
|
| 384 |
+
this.textPartId = null;
|
| 385 |
+
}
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
// ── Event → UIMessageChunk mapping ──────────────────────────────────
|
| 389 |
+
|
| 390 |
+
private static readonly STREAM_EVENTS = new Set([
|
| 391 |
+
'assistant_chunk', 'assistant_stream_end', 'assistant_message',
|
| 392 |
+
'tool_call', 'tool_output', 'approval_required', 'tool_state_change',
|
| 393 |
+
'turn_complete', 'error',
|
| 394 |
+
]);
|
| 395 |
+
|
| 396 |
+
private handleEvent(event: AgentEvent): void {
|
| 397 |
+
// After an abort, ignore stale stream events until the next 'processing'
|
| 398 |
+
if (this.awaitingProcessing && WebSocketChatTransport.STREAM_EVENTS.has(event.event_type)) {
|
| 399 |
+
logger.log(`Filtering stale "${event.event_type}" (gen=${this.streamGeneration}, aborted=${this.abortedGeneration})`);
|
| 400 |
+
return;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
switch (event.event_type) {
|
| 404 |
+
// ── Side-channel only events ────────────────────────────────
|
| 405 |
+
case 'ready':
|
| 406 |
+
this.sideChannel.onReady();
|
| 407 |
+
break;
|
| 408 |
+
|
| 409 |
+
case 'shutdown':
|
| 410 |
+
this.sideChannel.onShutdown();
|
| 411 |
+
this.closeActiveStream();
|
| 412 |
+
break;
|
| 413 |
+
|
| 414 |
+
case 'interrupted':
|
| 415 |
+
// Don't close the stream here — the abort handler already did, and
|
| 416 |
+
// a new stream for the next user message may already exist.
|
| 417 |
+
// Closing here would destroy the NEWER stream, causing the next
|
| 418 |
+
// response to be silently dropped.
|
| 419 |
+
this.sideChannel.onProcessingDone();
|
| 420 |
+
break;
|
| 421 |
+
|
| 422 |
+
case 'undo_complete':
|
| 423 |
+
this.endTextPart();
|
| 424 |
+
this.closeActiveStream();
|
| 425 |
+
this.sideChannel.onUndoComplete();
|
| 426 |
+
break;
|
| 427 |
+
|
| 428 |
+
case 'compacted':
|
| 429 |
+
this.sideChannel.onCompacted(
|
| 430 |
+
(event.data?.old_tokens as number) || 0,
|
| 431 |
+
(event.data?.new_tokens as number) || 0,
|
| 432 |
+
);
|
| 433 |
+
break;
|
| 434 |
+
|
| 435 |
+
case 'plan_update':
|
| 436 |
+
this.sideChannel.onPlanUpdate(
|
| 437 |
+
(event.data?.plan as Array<{ id: string; content: string; status: string }>) || [],
|
| 438 |
+
);
|
| 439 |
+
break;
|
| 440 |
+
|
| 441 |
+
case 'tool_log':
|
| 442 |
+
this.sideChannel.onToolLog(
|
| 443 |
+
(event.data?.tool as string) || '',
|
| 444 |
+
(event.data?.log as string) || '',
|
| 445 |
+
);
|
| 446 |
+
break;
|
| 447 |
+
|
| 448 |
+
// ── Chat stream events ──────────────────────────────────────
|
| 449 |
+
case 'processing':
|
| 450 |
+
if (this.awaitingProcessing) {
|
| 451 |
+
if (this.streamGeneration <= this.abortedGeneration) {
|
| 452 |
+
logger.log(`Ignoring stale "processing" (gen=${this.streamGeneration} <= aborted=${this.abortedGeneration})`);
|
| 453 |
+
break;
|
| 454 |
+
}
|
| 455 |
+
logger.log(`Accepting "processing" for new generation (gen=${this.streamGeneration}, aborted=${this.abortedGeneration})`);
|
| 456 |
+
this.awaitingProcessing = false;
|
| 457 |
+
}
|
| 458 |
+
this.sideChannel.onProcessing();
|
| 459 |
+
if (this.streamController) {
|
| 460 |
+
this.enqueue({
|
| 461 |
+
type: 'start',
|
| 462 |
+
messageMetadata: { createdAt: new Date().toISOString() },
|
| 463 |
+
});
|
| 464 |
+
this.enqueue({ type: 'start-step' });
|
| 465 |
+
}
|
| 466 |
+
break;
|
| 467 |
+
|
| 468 |
+
case 'assistant_chunk': {
|
| 469 |
+
const delta = (event.data?.content as string) || '';
|
| 470 |
+
if (!delta || !this.streamController) break;
|
| 471 |
+
|
| 472 |
+
if (!this.textPartId) {
|
| 473 |
+
this.textPartId = nextPartId('text');
|
| 474 |
+
this.enqueue({ type: 'text-start', id: this.textPartId });
|
| 475 |
+
this.sideChannel.onStreaming();
|
| 476 |
+
}
|
| 477 |
+
this.enqueue({ type: 'text-delta', id: this.textPartId, delta });
|
| 478 |
+
break;
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
case 'assistant_stream_end':
|
| 482 |
+
this.endTextPart();
|
| 483 |
+
break;
|
| 484 |
+
|
| 485 |
+
case 'assistant_message': {
|
| 486 |
+
const content = (event.data?.content as string) || '';
|
| 487 |
+
if (!content || !this.streamController) break;
|
| 488 |
+
const id = nextPartId('text');
|
| 489 |
+
this.enqueue({ type: 'text-start', id });
|
| 490 |
+
this.enqueue({ type: 'text-delta', id, delta: content });
|
| 491 |
+
this.enqueue({ type: 'text-end', id });
|
| 492 |
+
break;
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
case 'tool_call': {
|
| 496 |
+
if (!this.streamController) break;
|
| 497 |
+
const toolName = (event.data?.tool as string) || 'unknown';
|
| 498 |
+
const toolCallId = (event.data?.tool_call_id as string) || '';
|
| 499 |
+
const args = (event.data?.arguments as Record<string, unknown>) || {};
|
| 500 |
+
|
| 501 |
+
if (toolName === 'plan_tool') break;
|
| 502 |
+
|
| 503 |
+
this.endTextPart();
|
| 504 |
+
this.enqueue({ type: 'tool-input-start', toolCallId, toolName, dynamic: true });
|
| 505 |
+
this.enqueue({ type: 'tool-input-available', toolCallId, toolName, input: args, dynamic: true });
|
| 506 |
+
|
| 507 |
+
this.sideChannel.onToolRunning(toolName);
|
| 508 |
+
this.sideChannel.onToolCallPanel(toolName, args as Record<string, unknown>);
|
| 509 |
+
break;
|
| 510 |
+
}
|
| 511 |
+
|
| 512 |
+
case 'tool_output': {
|
| 513 |
+
if (!this.streamController) break;
|
| 514 |
+
const toolCallId = (event.data?.tool_call_id as string) || '';
|
| 515 |
+
const output = (event.data?.output as string) || '';
|
| 516 |
+
const success = event.data?.success as boolean;
|
| 517 |
+
const toolName = (event.data?.tool as string) || '';
|
| 518 |
+
|
| 519 |
+
if (toolName === 'plan_tool' || toolCallId.startsWith('plan_tool')) break;
|
| 520 |
+
|
| 521 |
+
if (success) {
|
| 522 |
+
this.enqueue({ type: 'tool-output-available', toolCallId, output, dynamic: true });
|
| 523 |
+
} else {
|
| 524 |
+
this.enqueue({ type: 'tool-output-error', toolCallId, errorText: output, dynamic: true });
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
this.sideChannel.onToolOutputPanel(toolName, toolCallId, output, success);
|
| 528 |
+
break;
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
case 'approval_required': {
|
| 532 |
+
const tools = event.data?.tools as Array<{
|
| 533 |
+
tool: string;
|
| 534 |
+
arguments: Record<string, unknown>;
|
| 535 |
+
tool_call_id: string;
|
| 536 |
+
}>;
|
| 537 |
+
if (!tools || !this.streamController) break;
|
| 538 |
+
|
| 539 |
+
this.endTextPart();
|
| 540 |
+
|
| 541 |
+
for (const t of tools) {
|
| 542 |
+
this.enqueue({ type: 'tool-input-start', toolCallId: t.tool_call_id, toolName: t.tool, dynamic: true });
|
| 543 |
+
this.enqueue({ type: 'tool-input-available', toolCallId: t.tool_call_id, toolName: t.tool, input: t.arguments, dynamic: true });
|
| 544 |
+
this.enqueue({ type: 'tool-approval-request', approvalId: `approval-${t.tool_call_id}`, toolCallId: t.tool_call_id });
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
this.sideChannel.onApprovalRequired(tools);
|
| 548 |
+
this.sideChannel.onProcessingDone();
|
| 549 |
+
break;
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
case 'tool_state_change': {
|
| 553 |
+
const tcId = (event.data?.tool_call_id as string) || '';
|
| 554 |
+
const state = (event.data?.state as string) || '';
|
| 555 |
+
const jobUrl = (event.data?.jobUrl as string) || undefined;
|
| 556 |
+
|
| 557 |
+
if (tcId.startsWith('plan_tool')) break;
|
| 558 |
+
|
| 559 |
+
if (jobUrl && tcId) {
|
| 560 |
+
useAgentStore.getState().setJobUrl(tcId, jobUrl);
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
if (this.streamController && (state === 'rejected' || state === 'abandoned')) {
|
| 564 |
+
this.enqueue({ type: 'tool-output-denied', toolCallId: tcId });
|
| 565 |
+
}
|
| 566 |
+
break;
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
case 'turn_complete':
|
| 570 |
+
this.endTextPart();
|
| 571 |
+
if (this.streamController) {
|
| 572 |
+
this.enqueue({ type: 'finish-step' });
|
| 573 |
+
this.enqueue({ type: 'finish', finishReason: 'stop' });
|
| 574 |
+
this.closeActiveStream();
|
| 575 |
+
}
|
| 576 |
+
this.sideChannel.onProcessingDone();
|
| 577 |
+
break;
|
| 578 |
+
|
| 579 |
+
case 'error': {
|
| 580 |
+
const errorMsg = (event.data?.error as string) || 'Unknown error';
|
| 581 |
+
this.sideChannel.onError(errorMsg);
|
| 582 |
+
if (this.streamController) {
|
| 583 |
+
this.enqueue({ type: 'error', errorText: errorMsg });
|
| 584 |
+
}
|
| 585 |
+
this.sideChannel.onProcessingDone();
|
| 586 |
+
break;
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
default:
|
| 590 |
+
logger.log('WS transport: unknown event', event);
|
| 591 |
+
}
|
| 592 |
+
}
|
| 593 |
+
}
|
frontend/src/main.tsx
CHANGED
|
@@ -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 |
);
|
frontend/src/store/agentStore.ts
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { create } from 'zustand';
|
| 2 |
-
import type {
|
| 3 |
|
| 4 |
export interface PlanItem {
|
| 5 |
id: string;
|
|
@@ -7,254 +18,158 @@ export interface PlanItem {
|
|
| 7 |
status: 'pending' | 'in_progress' | 'completed';
|
| 8 |
}
|
| 9 |
|
| 10 |
-
interface
|
| 11 |
-
id: string;
|
| 12 |
-
title: string;
|
| 13 |
content: string;
|
| 14 |
-
language
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
}
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
interface AgentStore {
|
| 19 |
-
//
|
| 20 |
-
messagesBySession: Record<string, Message[]>;
|
| 21 |
isProcessing: boolean;
|
| 22 |
isConnected: boolean;
|
| 23 |
-
|
| 24 |
user: User | null;
|
| 25 |
error: string | null;
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
plan: PlanItem[];
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
// Actions
|
| 34 |
-
addMessage: (sessionId: string, message: Message) => void;
|
| 35 |
-
updateMessage: (sessionId: string, messageId: string, updates: Partial<Message>) => void;
|
| 36 |
-
clearMessages: (sessionId: string) => void;
|
| 37 |
setProcessing: (isProcessing: boolean) => void;
|
| 38 |
setConnected: (isConnected: boolean) => void;
|
| 39 |
-
|
| 40 |
setUser: (user: User | null) => void;
|
| 41 |
setError: (error: string | null) => void;
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
setPlan: (plan: PlanItem[]) => void;
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
-
export const useAgentStore = create<AgentStore>((set, get) => ({
|
| 58 |
-
messagesBySession: {},
|
| 59 |
isProcessing: false,
|
| 60 |
isConnected: false,
|
| 61 |
-
|
| 62 |
user: null,
|
| 63 |
error: null,
|
| 64 |
-
|
| 65 |
-
panelContent: null,
|
| 66 |
-
panelTabs: [],
|
| 67 |
-
activePanelTab: null,
|
| 68 |
-
plan: [],
|
| 69 |
-
currentTurnMessageId: null,
|
| 70 |
-
|
| 71 |
-
addMessage: (sessionId: string, message: Message) => {
|
| 72 |
-
set((state) => {
|
| 73 |
-
const currentMessages = state.messagesBySession[sessionId] || [];
|
| 74 |
-
return {
|
| 75 |
-
messagesBySession: {
|
| 76 |
-
...state.messagesBySession,
|
| 77 |
-
[sessionId]: [...currentMessages, message],
|
| 78 |
-
},
|
| 79 |
-
};
|
| 80 |
-
});
|
| 81 |
-
},
|
| 82 |
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
const updatedMessages = currentMessages.map((msg) =>
|
| 87 |
-
msg.id === messageId ? { ...msg, ...updates } : msg
|
| 88 |
-
);
|
| 89 |
-
return {
|
| 90 |
-
messagesBySession: {
|
| 91 |
-
...state.messagesBySession,
|
| 92 |
-
[sessionId]: updatedMessages,
|
| 93 |
-
},
|
| 94 |
-
};
|
| 95 |
-
});
|
| 96 |
-
},
|
| 97 |
|
| 98 |
-
|
| 99 |
-
set((state) => ({
|
| 100 |
-
messagesBySession: {
|
| 101 |
-
...state.messagesBySession,
|
| 102 |
-
[sessionId]: [],
|
| 103 |
-
},
|
| 104 |
-
}));
|
| 105 |
-
},
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
},
|
| 110 |
|
| 111 |
-
|
| 112 |
-
set({ isConnected });
|
| 113 |
-
},
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
| 117 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
-
|
| 120 |
-
set({ user });
|
| 121 |
-
},
|
| 122 |
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
|
|
|
|
|
|
| 126 |
|
| 127 |
-
|
| 128 |
-
return get().messagesBySession[sessionId] || [];
|
| 129 |
-
},
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
}));
|
| 135 |
-
},
|
| 136 |
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
if (traceLogs[i].tool === toolName && traceLogs[i].type === 'call') {
|
| 143 |
-
traceLogs[i] = { ...traceLogs[i], ...updates };
|
| 144 |
-
break;
|
| 145 |
-
}
|
| 146 |
-
}
|
| 147 |
-
return { traceLogs };
|
| 148 |
-
});
|
| 149 |
-
},
|
| 150 |
|
| 151 |
-
|
| 152 |
-
set({ traceLogs: [] });
|
| 153 |
-
},
|
| 154 |
|
| 155 |
-
|
| 156 |
-
set({ panelContent: content });
|
| 157 |
-
},
|
| 158 |
|
| 159 |
-
|
| 160 |
-
set((state) => {
|
| 161 |
-
const existingIndex = state.panelTabs.findIndex(t => t.id === tab.id);
|
| 162 |
-
let newTabs: PanelTab[];
|
| 163 |
-
if (existingIndex >= 0) {
|
| 164 |
-
// Update existing tab
|
| 165 |
-
newTabs = [...state.panelTabs];
|
| 166 |
-
newTabs[existingIndex] = tab;
|
| 167 |
-
} else {
|
| 168 |
-
// Add new tab
|
| 169 |
-
newTabs = [...state.panelTabs, tab];
|
| 170 |
-
}
|
| 171 |
-
return {
|
| 172 |
-
panelTabs: newTabs,
|
| 173 |
-
activePanelTab: state.activePanelTab || tab.id, // Auto-select first tab
|
| 174 |
-
};
|
| 175 |
-
});
|
| 176 |
-
},
|
| 177 |
|
| 178 |
-
|
| 179 |
-
set({ activePanelTab: tabId });
|
| 180 |
-
},
|
| 181 |
|
| 182 |
-
|
| 183 |
-
set({ panelTabs: [], activePanelTab: null });
|
| 184 |
-
},
|
| 185 |
|
| 186 |
-
|
| 187 |
-
set((state) => {
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
let newActiveTab = state.activePanelTab;
|
| 191 |
-
if (state.activePanelTab === tabId) {
|
| 192 |
-
newActiveTab = newTabs.length > 0 ? newTabs[newTabs.length - 1].id : null;
|
| 193 |
-
}
|
| 194 |
-
return {
|
| 195 |
-
panelTabs: newTabs,
|
| 196 |
-
activePanelTab: newActiveTab,
|
| 197 |
-
};
|
| 198 |
-
});
|
| 199 |
},
|
| 200 |
|
| 201 |
-
|
| 202 |
-
set({ plan });
|
| 203 |
-
},
|
| 204 |
|
| 205 |
-
|
| 206 |
-
set({ currentTurnMessageId: id });
|
| 207 |
-
},
|
| 208 |
|
| 209 |
-
|
| 210 |
-
const state = get();
|
| 211 |
-
if (state.currentTurnMessageId) {
|
| 212 |
-
const currentMessages = state.messagesBySession[sessionId] || [];
|
| 213 |
-
const updatedMessages = currentMessages.map((msg) =>
|
| 214 |
-
msg.id === state.currentTurnMessageId
|
| 215 |
-
? { ...msg, trace: state.traceLogs.length > 0 ? [...state.traceLogs] : undefined }
|
| 216 |
-
: msg
|
| 217 |
-
);
|
| 218 |
-
set({
|
| 219 |
-
messagesBySession: {
|
| 220 |
-
...state.messagesBySession,
|
| 221 |
-
[sessionId]: updatedMessages,
|
| 222 |
-
},
|
| 223 |
-
});
|
| 224 |
-
}
|
| 225 |
-
},
|
| 226 |
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
// Determine language based on content
|
| 232 |
-
let language = 'text';
|
| 233 |
-
const content = log.output || '';
|
| 234 |
-
|
| 235 |
-
// Check if content looks like JSON
|
| 236 |
-
if (content.trim().startsWith('{') || content.trim().startsWith('[') || content.includes('```json')) {
|
| 237 |
-
language = 'json';
|
| 238 |
-
}
|
| 239 |
-
// Check if content has markdown tables or formatting
|
| 240 |
-
else if (content.includes('|') && content.includes('---') || content.includes('```')) {
|
| 241 |
-
language = 'markdown';
|
| 242 |
-
}
|
| 243 |
-
|
| 244 |
-
// Remove any existing tool output tab (only keep one)
|
| 245 |
-
const otherTabs = state.panelTabs.filter(t => t.id !== 'tool_output');
|
| 246 |
-
|
| 247 |
-
// Create/replace the single tool output tab
|
| 248 |
-
const newTab = {
|
| 249 |
-
id: 'tool_output',
|
| 250 |
-
title: log.tool,
|
| 251 |
-
content: content || 'No output available',
|
| 252 |
-
language,
|
| 253 |
-
};
|
| 254 |
-
|
| 255 |
-
set({
|
| 256 |
-
panelTabs: [...otherTabs, newTab],
|
| 257 |
-
activePanelTab: 'tool_output',
|
| 258 |
-
});
|
| 259 |
},
|
|
|
|
|
|
|
| 260 |
}));
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Agent store — manages UI state that is NOT handled by the Vercel AI SDK.
|
| 3 |
+
*
|
| 4 |
+
* Message state (messages, streaming, tool calls) is now managed by useChat().
|
| 5 |
+
* This store only handles:
|
| 6 |
+
* - Connection / processing flags
|
| 7 |
+
* - Panel state (right panel — single-artifact pattern)
|
| 8 |
+
* - Plan state
|
| 9 |
+
* - User info / error banners
|
| 10 |
+
* - Edited scripts (for hf_jobs code editing)
|
| 11 |
+
*/
|
| 12 |
import { create } from 'zustand';
|
| 13 |
+
import type { User } from '@/types/agent';
|
| 14 |
|
| 15 |
export interface PlanItem {
|
| 16 |
id: string;
|
|
|
|
| 18 |
status: 'pending' | 'in_progress' | 'completed';
|
| 19 |
}
|
| 20 |
|
| 21 |
+
export interface PanelSection {
|
|
|
|
|
|
|
| 22 |
content: string;
|
| 23 |
+
language: string;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export interface PanelData {
|
| 27 |
+
title: string;
|
| 28 |
+
script?: PanelSection;
|
| 29 |
+
output?: PanelSection;
|
| 30 |
+
parameters?: Record<string, unknown>;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export type PanelView = 'script' | 'output';
|
| 34 |
+
|
| 35 |
+
export interface LLMHealthError {
|
| 36 |
+
error: string;
|
| 37 |
+
errorType: 'auth' | 'credits' | 'rate_limit' | 'network' | 'unknown';
|
| 38 |
+
model: string;
|
| 39 |
}
|
| 40 |
|
| 41 |
+
export type ActivityStatus =
|
| 42 |
+
| { type: 'idle' }
|
| 43 |
+
| { type: 'thinking' }
|
| 44 |
+
| { type: 'tool'; toolName: string }
|
| 45 |
+
| { type: 'waiting-approval' }
|
| 46 |
+
| { type: 'streaming' };
|
| 47 |
+
|
| 48 |
interface AgentStore {
|
| 49 |
+
// Global UI flags
|
|
|
|
| 50 |
isProcessing: boolean;
|
| 51 |
isConnected: boolean;
|
| 52 |
+
activityStatus: ActivityStatus;
|
| 53 |
user: User | null;
|
| 54 |
error: string | null;
|
| 55 |
+
llmHealthError: LLMHealthError | null;
|
| 56 |
+
|
| 57 |
+
// Right panel (single-artifact pattern)
|
| 58 |
+
panelData: PanelData | null;
|
| 59 |
+
panelView: PanelView;
|
| 60 |
+
panelEditable: boolean;
|
| 61 |
+
|
| 62 |
+
// Plan
|
| 63 |
plan: PlanItem[];
|
| 64 |
+
|
| 65 |
+
// Edited scripts (tool_call_id -> edited content)
|
| 66 |
+
editedScripts: Record<string, string>;
|
| 67 |
+
|
| 68 |
+
// Job URLs (tool_call_id -> job URL) for HF jobs
|
| 69 |
+
jobUrls: Record<string, string>;
|
| 70 |
|
| 71 |
// Actions
|
|
|
|
|
|
|
|
|
|
| 72 |
setProcessing: (isProcessing: boolean) => void;
|
| 73 |
setConnected: (isConnected: boolean) => void;
|
| 74 |
+
setActivityStatus: (status: ActivityStatus) => void;
|
| 75 |
setUser: (user: User | null) => void;
|
| 76 |
setError: (error: string | null) => void;
|
| 77 |
+
setLlmHealthError: (error: LLMHealthError | null) => void;
|
| 78 |
+
|
| 79 |
+
setPanel: (data: PanelData, view?: PanelView, editable?: boolean) => void;
|
| 80 |
+
setPanelView: (view: PanelView) => void;
|
| 81 |
+
setPanelOutput: (output: PanelSection) => void;
|
| 82 |
+
updatePanelScript: (content: string) => void;
|
| 83 |
+
lockPanel: () => void;
|
| 84 |
+
clearPanel: () => void;
|
| 85 |
+
|
| 86 |
setPlan: (plan: PlanItem[]) => void;
|
| 87 |
+
|
| 88 |
+
setEditedScript: (toolCallId: string, content: string) => void;
|
| 89 |
+
getEditedScript: (toolCallId: string) => string | undefined;
|
| 90 |
+
clearEditedScripts: () => void;
|
| 91 |
+
|
| 92 |
+
setJobUrl: (toolCallId: string, jobUrl: string) => void;
|
| 93 |
+
getJobUrl: (toolCallId: string) => string | undefined;
|
| 94 |
}
|
| 95 |
|
| 96 |
+
export const useAgentStore = create<AgentStore>()((set, get) => ({
|
|
|
|
| 97 |
isProcessing: false,
|
| 98 |
isConnected: false,
|
| 99 |
+
activityStatus: { type: 'idle' },
|
| 100 |
user: null,
|
| 101 |
error: null,
|
| 102 |
+
llmHealthError: null,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
+
panelData: null,
|
| 105 |
+
panelView: 'script',
|
| 106 |
+
panelEditable: false,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
+
plan: [],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
+
editedScripts: {},
|
| 111 |
+
jobUrls: {},
|
|
|
|
| 112 |
|
| 113 |
+
// ── Global flags ──────────────────────────────────────────────────
|
|
|
|
|
|
|
| 114 |
|
| 115 |
+
setProcessing: (isProcessing) => {
|
| 116 |
+
const current = get().activityStatus;
|
| 117 |
+
const preserveStatus = current.type === 'waiting-approval';
|
| 118 |
+
set({ isProcessing, ...(!isProcessing && !preserveStatus ? { activityStatus: { type: 'idle' } } : {}) });
|
| 119 |
},
|
| 120 |
+
setConnected: (isConnected) => set({ isConnected }),
|
| 121 |
+
setActivityStatus: (status) => set({ activityStatus: status }),
|
| 122 |
+
setUser: (user) => set({ user }),
|
| 123 |
+
setError: (error) => set({ error }),
|
| 124 |
+
setLlmHealthError: (error) => set({ llmHealthError: error }),
|
| 125 |
|
| 126 |
+
// ── Panel (single-artifact) ───────────────────────────────────────
|
|
|
|
|
|
|
| 127 |
|
| 128 |
+
setPanel: (data, view, editable) => set({
|
| 129 |
+
panelData: data,
|
| 130 |
+
panelView: view ?? (data.script ? 'script' : 'output'),
|
| 131 |
+
panelEditable: editable ?? false,
|
| 132 |
+
}),
|
| 133 |
|
| 134 |
+
setPanelView: (view) => set({ panelView: view }),
|
|
|
|
|
|
|
| 135 |
|
| 136 |
+
setPanelOutput: (output) => set((state) => ({
|
| 137 |
+
panelData: state.panelData ? { ...state.panelData, output } : null,
|
| 138 |
+
})),
|
|
|
|
|
|
|
| 139 |
|
| 140 |
+
updatePanelScript: (content) => set((state) => ({
|
| 141 |
+
panelData: state.panelData?.script
|
| 142 |
+
? { ...state.panelData, script: { ...state.panelData.script, content } }
|
| 143 |
+
: state.panelData,
|
| 144 |
+
})),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
+
lockPanel: () => set({ panelEditable: false }),
|
|
|
|
|
|
|
| 147 |
|
| 148 |
+
clearPanel: () => set({ panelData: null, panelView: 'script', panelEditable: false }),
|
|
|
|
|
|
|
| 149 |
|
| 150 |
+
// ── Plan ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
+
setPlan: (plan) => set({ plan }),
|
|
|
|
|
|
|
| 153 |
|
| 154 |
+
// ── Edited scripts ────────────────────────────────────────────────
|
|
|
|
|
|
|
| 155 |
|
| 156 |
+
setEditedScript: (toolCallId, content) => {
|
| 157 |
+
set((state) => ({
|
| 158 |
+
editedScripts: { ...state.editedScripts, [toolCallId]: content },
|
| 159 |
+
}));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
},
|
| 161 |
|
| 162 |
+
getEditedScript: (toolCallId) => get().editedScripts[toolCallId],
|
|
|
|
|
|
|
| 163 |
|
| 164 |
+
clearEditedScripts: () => set({ editedScripts: {} }),
|
|
|
|
|
|
|
| 165 |
|
| 166 |
+
// ── Job URLs ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
+
setJobUrl: (toolCallId, jobUrl) => {
|
| 169 |
+
set((state) => ({
|
| 170 |
+
jobUrls: { ...state.jobUrls, [toolCallId]: jobUrl },
|
| 171 |
+
}));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
},
|
| 173 |
+
|
| 174 |
+
getJobUrl: (toolCallId) => get().jobUrls[toolCallId],
|
| 175 |
}));
|
frontend/src/store/layoutStore.ts
CHANGED
|
@@ -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 |
+
);
|
frontend/src/store/sessionStore.ts
CHANGED
|
@@ -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,7 @@ 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 +53,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 { deleteMessages } from '@/lib/chat-message-store';
|
| 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 |
+
deleteMessages(id);
|
| 39 |
set((state) => {
|
| 40 |
const newSessions = state.sessions.filter((s) => s.id !== id);
|
| 41 |
const newActiveId =
|
|
|
|
| 53 |
set({ activeSessionId: id });
|
| 54 |
},
|
| 55 |
|
| 56 |
+
setSessionActive: (id: string, isActive: boolean) => {
|
| 57 |
set((state) => ({
|
| 58 |
sessions: state.sessions.map((s) =>
|
| 59 |
+
s.id === id ? { ...s, isActive } : s
|
| 60 |
),
|
| 61 |
}));
|
| 62 |
},
|
| 63 |
|
| 64 |
+
updateSessionTitle: (id: string, title: string) => {
|
| 65 |
set((state) => ({
|
| 66 |
sessions: state.sessions.map((s) =>
|
| 67 |
+
s.id === id ? { ...s, title } : s
|
| 68 |
),
|
| 69 |
}));
|
| 70 |
},
|
frontend/src/theme.ts
CHANGED
|
@@ -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' },
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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', light: '#FFB740', dark: '#E08C00', contrastText: '#fff' },
|
| 180 |
+
secondary: { main: '#FF9D00' },
|
| 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', light: '#FFB740', dark: '#E08C00', contrastText: '#fff' },
|
| 203 |
+
secondary: { main: '#E08C00' },
|
| 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;
|
frontend/src/types/agent.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
| 1 |
/**
|
| 2 |
-
* Agent-related types
|
|
|
|
|
|
|
|
|
|
| 3 |
*/
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
export interface SessionMeta {
|
| 6 |
id: string;
|
| 7 |
title: string;
|
|
@@ -9,61 +17,12 @@ export interface SessionMeta {
|
|
| 9 |
isActive: boolean;
|
| 10 |
}
|
| 11 |
|
| 12 |
-
export interface MessageSegment {
|
| 13 |
-
type: 'text' | 'tools';
|
| 14 |
-
content?: string;
|
| 15 |
-
tools?: TraceLog[];
|
| 16 |
-
}
|
| 17 |
-
|
| 18 |
-
export interface Message {
|
| 19 |
-
id: string;
|
| 20 |
-
role: 'user' | 'assistant' | 'tool';
|
| 21 |
-
content: string;
|
| 22 |
-
timestamp: string;
|
| 23 |
-
segments?: MessageSegment[];
|
| 24 |
-
approval?: {
|
| 25 |
-
status: 'pending' | 'approved' | 'rejected';
|
| 26 |
-
batch: ApprovalBatch;
|
| 27 |
-
decisions?: ToolApproval[];
|
| 28 |
-
};
|
| 29 |
-
toolOutput?: string;
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
export interface ToolCall {
|
| 33 |
-
id: string;
|
| 34 |
-
tool: string;
|
| 35 |
-
arguments: Record<string, unknown>;
|
| 36 |
-
status: 'pending' | 'running' | 'completed' | 'failed';
|
| 37 |
-
output?: string;
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
export interface ToolApproval {
|
| 41 |
tool_call_id: string;
|
| 42 |
approved: boolean;
|
| 43 |
feedback?: string | null;
|
| 44 |
}
|
| 45 |
|
| 46 |
-
export interface ApprovalBatch {
|
| 47 |
-
tools: Array<{
|
| 48 |
-
tool: string;
|
| 49 |
-
arguments: Record<string, unknown>;
|
| 50 |
-
tool_call_id: string;
|
| 51 |
-
}>;
|
| 52 |
-
count: number;
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
export interface TraceLog {
|
| 56 |
-
id: string;
|
| 57 |
-
type: 'call' | 'output';
|
| 58 |
-
text: string;
|
| 59 |
-
tool: string;
|
| 60 |
-
timestamp: string;
|
| 61 |
-
completed?: boolean;
|
| 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 {
|
| 68 |
authenticated: boolean;
|
| 69 |
username?: string;
|
|
|
|
| 1 |
/**
|
| 2 |
+
* Agent-related types.
|
| 3 |
+
*
|
| 4 |
+
* Message and tool-call types are now provided by the Vercel AI SDK
|
| 5 |
+
* (UIMessage, UIMessagePart, etc.). Only non-SDK types remain here.
|
| 6 |
*/
|
| 7 |
|
| 8 |
+
/** Custom metadata attached to every UIMessage via the `metadata` field. */
|
| 9 |
+
export interface MessageMeta {
|
| 10 |
+
createdAt?: string;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
export interface SessionMeta {
|
| 14 |
id: string;
|
| 15 |
title: string;
|
|
|
|
| 17 |
isActive: boolean;
|
| 18 |
}
|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
export interface ToolApproval {
|
| 21 |
tool_call_id: string;
|
| 22 |
approved: boolean;
|
| 23 |
feedback?: string | null;
|
| 24 |
}
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
export interface User {
|
| 27 |
authenticated: boolean;
|
| 28 |
username?: string;
|
frontend/src/types/events.ts
CHANGED
|
@@ -6,10 +6,13 @@ export type EventType =
|
|
| 6 |
| 'ready'
|
| 7 |
| 'processing'
|
| 8 |
| 'assistant_message'
|
|
|
|
|
|
|
| 9 |
| 'tool_call'
|
| 10 |
| 'tool_output'
|
| 11 |
| 'tool_log'
|
| 12 |
| 'approval_required'
|
|
|
|
| 13 |
| 'turn_complete'
|
| 14 |
| 'compacted'
|
| 15 |
| 'error'
|
|
|
|
| 6 |
| 'ready'
|
| 7 |
| 'processing'
|
| 8 |
| 'assistant_message'
|
| 9 |
+
| 'assistant_chunk'
|
| 10 |
+
| 'assistant_stream_end'
|
| 11 |
| 'tool_call'
|
| 12 |
| 'tool_output'
|
| 13 |
| 'tool_log'
|
| 14 |
| 'approval_required'
|
| 15 |
+
| 'tool_state_change'
|
| 16 |
| 'turn_complete'
|
| 17 |
| 'compacted'
|
| 18 |
| 'error'
|
frontend/src/utils/api.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Centralized API utilities.
|
| 3 |
+
*
|
| 4 |
+
* In production: HttpOnly cookie (hf_access_token) is sent automatically.
|
| 5 |
+
* In development: auth is bypassed on the backend.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
import { triggerLogin } from '@/hooks/useAuth';
|
| 9 |
+
|
| 10 |
+
/** Wrapper around fetch with credentials and common headers. */
|
| 11 |
+
export async function apiFetch(
|
| 12 |
+
path: string,
|
| 13 |
+
options: RequestInit = {}
|
| 14 |
+
): Promise<Response> {
|
| 15 |
+
const headers: Record<string, string> = {
|
| 16 |
+
'Content-Type': 'application/json',
|
| 17 |
+
...(options.headers as Record<string, string>),
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
const response = await fetch(path, {
|
| 21 |
+
...options,
|
| 22 |
+
headers,
|
| 23 |
+
credentials: 'include', // Send cookies with every request
|
| 24 |
+
});
|
| 25 |
+
|
| 26 |
+
// Handle 401 — redirect to login
|
| 27 |
+
if (response.status === 401) {
|
| 28 |
+
try {
|
| 29 |
+
const authStatus = await fetch('/auth/status', { credentials: 'include' });
|
| 30 |
+
const data = await authStatus.json();
|
| 31 |
+
if (data.auth_enabled) {
|
| 32 |
+
triggerLogin();
|
| 33 |
+
throw new Error('Authentication required — redirecting to login.');
|
| 34 |
+
}
|
| 35 |
+
} catch (e) {
|
| 36 |
+
if (e instanceof Error && e.message.includes('redirecting')) throw e;
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
return response;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/** Build the WebSocket URL for a session. */
|
| 44 |
+
export function getWebSocketUrl(sessionId: string): string {
|
| 45 |
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
| 46 |
+
return `${protocol}//${window.location.host}/api/ws/${sessionId}`;
|
| 47 |
+
}
|
frontend/src/utils/logger.ts
ADDED
|
@@ -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 |
+
};
|