Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
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 |
+
};
|