akseljoonas HF Staff commited on
Commit
9dbf093
·
1 Parent(s): 1f0e687
README.md CHANGED
@@ -9,9 +9,11 @@ hf_oauth: true
9
  hf_oauth_scopes:
10
  - read-repos
11
  - write-repos
 
12
  - manage-repos
13
  - inference-api
14
  - jobs
 
15
  ---
16
 
17
  # 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
@@ -47,9 +47,13 @@ def _get_hf_username() -> str:
47
  try:
48
  result = subprocess.run(
49
  [
50
- "curl", "-s", "-4", # force IPv4
51
- "-m", str(_HF_WHOAMI_TIMEOUT), # max time
52
- "-H", f"Authorization: Bearer {hf_token}",
 
 
 
 
53
  _HF_WHOAMI_URL,
54
  ],
55
  capture_output=True,
@@ -60,9 +64,13 @@ def _get_hf_username() -> str:
60
  if result.returncode == 0 and result.stdout:
61
  data = json.loads(result.stdout)
62
  _hf_username_cache = data.get("name", "unknown")
63
- logger.info(f"HF username resolved to '{_hf_username_cache}' in {t1 - t0:.2f}s")
 
 
64
  else:
65
- logger.warning(f"curl whoami failed (rc={result.returncode}) in {t1 - t0:.2f}s")
 
 
66
  _hf_username_cache = "unknown"
67
  except Exception as e:
68
  t1 = _t.monotonic()
@@ -165,10 +173,14 @@ class ContextManager:
165
  )
166
  )
167
 
 
168
  response = await acompletion(
169
  model=model_name,
170
  messages=messages_to_summarize,
171
  max_completion_tokens=self.compact_size,
 
 
 
172
  )
173
  summarized_message = Message(
174
  role="assistant", content=response.choices[0].message.content
 
47
  try:
48
  result = subprocess.run(
49
  [
50
+ "curl",
51
+ "-s",
52
+ "-4", # force IPv4
53
+ "-m",
54
+ str(_HF_WHOAMI_TIMEOUT), # max time
55
+ "-H",
56
+ f"Authorization: Bearer {hf_token}",
57
  _HF_WHOAMI_URL,
58
  ],
59
  capture_output=True,
 
64
  if result.returncode == 0 and result.stdout:
65
  data = json.loads(result.stdout)
66
  _hf_username_cache = data.get("name", "unknown")
67
+ logger.info(
68
+ f"HF username resolved to '{_hf_username_cache}' in {t1 - t0:.2f}s"
69
+ )
70
  else:
71
+ logger.warning(
72
+ f"curl whoami failed (rc={result.returncode}) in {t1 - t0:.2f}s"
73
+ )
74
  _hf_username_cache = "unknown"
75
  except Exception as e:
76
  t1 = _t.monotonic()
 
173
  )
174
  )
175
 
176
+ hf_key = os.environ.get("INFERENCE_TOKEN")
177
  response = await acompletion(
178
  model=model_name,
179
  messages=messages_to_summarize,
180
  max_completion_tokens=self.compact_size,
181
+ api_key=hf_key
182
+ if hf_key and model_name.startswith("huggingface/")
183
+ else None,
184
  )
185
  summarized_message = Message(
186
  role="assistant", content=response.choices[0].message.content
agent/core/agent_loop.py CHANGED
@@ -5,8 +5,9 @@ Main agent implementation with integrated tool system and MCP support
5
  import asyncio
6
  import json
7
  import logging
 
8
 
9
- from litellm import ChatCompletionMessageToolCall, Message, ModelResponse, acompletion
10
  from lmnr import observe
11
 
12
  from agent.config import Config
@@ -17,6 +18,9 @@ from agent.tools.jobs_tool import CPU_FLAVORS
17
  logger = logging.getLogger(__name__)
18
 
19
  ToolCall = ChatCompletionMessageToolCall
 
 
 
20
 
21
 
22
  def _validate_tool_args(tool_args: dict) -> tuple[bool, str | None]:
@@ -41,7 +45,9 @@ def _validate_tool_args(tool_args: dict) -> tuple[bool, str | None]:
41
  return True, None
42
 
43
 
44
- def _needs_approval(tool_name: str, tool_args: dict, config: Config | None = None) -> bool:
 
 
45
  """Check if a tool call requires user approval before execution."""
46
  # Yolo mode: skip all approvals
47
  if config and config.yolo_mode:
@@ -56,19 +62,24 @@ def _needs_approval(tool_name: str, tool_args: dict, config: Config | None = Non
56
  operation = tool_args.get("operation", "")
57
  if operation not in ["run", "uv", "scheduled run", "scheduled uv"]:
58
  return False
59
-
60
  # Check if this is a CPU-only job
61
  # hardware_flavor is at top level of tool_args, not nested in args
62
- hardware_flavor = tool_args.get("hardware_flavor") or tool_args.get("flavor") or tool_args.get("hardware") or "cpu-basic"
 
 
 
 
 
63
  is_cpu_job = hardware_flavor in CPU_FLAVORS
64
-
65
  if is_cpu_job:
66
  if config and not config.confirm_cpu_jobs:
67
  return False
68
  return True
69
-
70
  return True
71
-
72
  # Check for file upload operations (hf_private_repos or other tools)
73
  if tool_name == "hf_private_repos":
74
  operation = tool_args.get("operation", "")
@@ -89,7 +100,13 @@ def _needs_approval(tool_name: str, tool_args: dict, config: Config | None = Non
89
  # hf_repo_git: destructive operations require approval
90
  if tool_name == "hf_repo_git":
91
  operation = tool_args.get("operation", "")
92
- if operation in ["delete_branch", "delete_tag", "merge_pr", "create_repo", "update_repo"]:
 
 
 
 
 
 
93
  return True
94
 
95
  return False
@@ -130,7 +147,6 @@ class Handlers:
130
  while iteration < max_iterations:
131
  messages = session.context_manager.get_messages()
132
  tools = session.tool_router.get_tool_specs_for_llm()
133
-
134
  try:
135
  # ── Stream the LLM response ──────────────────────────
136
  response = await acompletion(
@@ -140,6 +156,10 @@ class Handlers:
140
  tool_choice="auto",
141
  stream=True,
142
  stream_options={"include_usage": True},
 
 
 
 
143
  )
144
 
145
  full_content = ""
@@ -180,13 +200,13 @@ class Handlers:
180
  tool_calls_acc[idx]["id"] = tc_delta.id
181
  if tc_delta.function:
182
  if tc_delta.function.name:
183
- tool_calls_acc[idx]["function"][
184
- "name"
185
- ] += tc_delta.function.name
186
  if tc_delta.function.arguments:
187
- tool_calls_acc[idx]["function"][
188
- "arguments"
189
- ] += tc_delta.function.arguments
190
 
191
  # Capture usage from the final chunk
192
  if hasattr(chunk, "usage") and chunk.usage:
@@ -219,9 +239,7 @@ class Handlers:
219
  if not tool_calls:
220
  if content:
221
  assistant_msg = Message(role="assistant", content=content)
222
- session.context_manager.add_message(
223
- assistant_msg, token_count
224
- )
225
  final_response = content
226
  break
227
 
 
5
  import asyncio
6
  import json
7
  import logging
8
+ import os
9
 
10
+ from litellm import ChatCompletionMessageToolCall, Message, acompletion
11
  from lmnr import observe
12
 
13
  from agent.config import Config
 
18
  logger = logging.getLogger(__name__)
19
 
20
  ToolCall = ChatCompletionMessageToolCall
21
+ # Explicit inference token — needed because litellm checks HF_TOKEN before
22
+ # HUGGINGFACE_API_KEY, and HF_TOKEN (used for Hub ops) may lack inference permissions.
23
+ _INFERENCE_API_KEY = os.environ.get("INFERENCE_TOKEN")
24
 
25
 
26
  def _validate_tool_args(tool_args: dict) -> tuple[bool, str | None]:
 
45
  return True, None
46
 
47
 
48
+ def _needs_approval(
49
+ tool_name: str, tool_args: dict, config: Config | None = None
50
+ ) -> bool:
51
  """Check if a tool call requires user approval before execution."""
52
  # Yolo mode: skip all approvals
53
  if config and config.yolo_mode:
 
62
  operation = tool_args.get("operation", "")
63
  if operation not in ["run", "uv", "scheduled run", "scheduled uv"]:
64
  return False
65
+
66
  # Check if this is a CPU-only job
67
  # hardware_flavor is at top level of tool_args, not nested in args
68
+ hardware_flavor = (
69
+ tool_args.get("hardware_flavor")
70
+ or tool_args.get("flavor")
71
+ or tool_args.get("hardware")
72
+ or "cpu-basic"
73
+ )
74
  is_cpu_job = hardware_flavor in CPU_FLAVORS
75
+
76
  if is_cpu_job:
77
  if config and not config.confirm_cpu_jobs:
78
  return False
79
  return True
80
+
81
  return True
82
+
83
  # Check for file upload operations (hf_private_repos or other tools)
84
  if tool_name == "hf_private_repos":
85
  operation = tool_args.get("operation", "")
 
100
  # hf_repo_git: destructive operations require approval
101
  if tool_name == "hf_repo_git":
102
  operation = tool_args.get("operation", "")
103
+ if operation in [
104
+ "delete_branch",
105
+ "delete_tag",
106
+ "merge_pr",
107
+ "create_repo",
108
+ "update_repo",
109
+ ]:
110
  return True
111
 
112
  return False
 
147
  while iteration < max_iterations:
148
  messages = session.context_manager.get_messages()
149
  tools = session.tool_router.get_tool_specs_for_llm()
 
150
  try:
151
  # ── Stream the LLM response ──────────────────────────
152
  response = await acompletion(
 
156
  tool_choice="auto",
157
  stream=True,
158
  stream_options={"include_usage": True},
159
+ api_key=_INFERENCE_API_KEY
160
+ if _INFERENCE_API_KEY
161
+ and session.config.model_name.startswith("huggingface/")
162
+ else None,
163
  )
164
 
165
  full_content = ""
 
200
  tool_calls_acc[idx]["id"] = tc_delta.id
201
  if tc_delta.function:
202
  if tc_delta.function.name:
203
+ tool_calls_acc[idx]["function"]["name"] += (
204
+ tc_delta.function.name
205
+ )
206
  if tc_delta.function.arguments:
207
+ tool_calls_acc[idx]["function"]["arguments"] += (
208
+ tc_delta.function.arguments
209
+ )
210
 
211
  # Capture usage from the final chunk
212
  if hasattr(chunk, "usage") and chunk.usage:
 
239
  if not tool_calls:
240
  if content:
241
  assistant_msg = Message(role="assistant", content=content)
242
+ session.context_manager.add_message(assistant_msg, token_count)
 
 
243
  final_response = content
244
  break
245
 
agent/core/session.py CHANGED
@@ -18,12 +18,16 @@ logger = logging.getLogger(__name__)
18
  # Local max-token lookup — avoids litellm.get_max_tokens() which can hang
19
  # on network calls for certain providers (known litellm issue).
20
  _MAX_TOKENS_MAP: dict[str, int] = {
 
21
  "anthropic/claude-opus-4-5-20251101": 200_000,
22
  "anthropic/claude-sonnet-4-5-20250929": 200_000,
23
  "anthropic/claude-sonnet-4-20250514": 200_000,
24
  "anthropic/claude-haiku-3-5-20241022": 200_000,
25
  "anthropic/claude-3-5-sonnet-20241022": 200_000,
26
  "anthropic/claude-3-opus-20240229": 200_000,
 
 
 
27
  }
28
  _DEFAULT_MAX_TOKENS = 200_000
29
 
@@ -36,10 +40,13 @@ def _get_max_tokens_safe(model_name: str) -> int:
36
  # Fallback: try litellm but with a short timeout via threading
37
  try:
38
  from litellm import get_max_tokens
 
39
  result = get_max_tokens(model_name)
40
  if result and isinstance(result, int):
41
  return result
42
- logger.warning(f"get_max_tokens returned {result} for {model_name}, using default")
 
 
43
  return _DEFAULT_MAX_TOKENS
44
  except Exception as e:
45
  logger.warning(f"get_max_tokens failed for {model_name}, using default: {e}")
 
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/MiniMaxAI/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
 
 
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}")
agent/tools/jobs_tool.py CHANGED
@@ -283,6 +283,7 @@ class HfJobsTool:
283
  namespace: Optional[str] = None,
284
  log_callback: Optional[Callable[[str], Awaitable[None]]] = None,
285
  ):
 
286
  self.api = HfApi(token=hf_token)
287
  self.namespace = namespace
288
  self.log_callback = log_callback
@@ -1028,7 +1029,7 @@ async def hf_jobs_handler(
1028
  or os.environ.get("HF_TOKEN")
1029
  or os.environ.get("HUGGINGFACE_HUB_TOKEN")
1030
  )
1031
- namespace = HfApi(token=hf_token).whoami().get("name") if hf_token else None
1032
 
1033
  tool = HfJobsTool(
1034
  namespace=namespace,
 
283
  namespace: Optional[str] = None,
284
  log_callback: Optional[Callable[[str], Awaitable[None]]] = None,
285
  ):
286
+ self.hf_token = hf_token
287
  self.api = HfApi(token=hf_token)
288
  self.namespace = namespace
289
  self.log_callback = log_callback
 
1029
  or os.environ.get("HF_TOKEN")
1030
  or os.environ.get("HUGGINGFACE_HUB_TOKEN")
1031
  )
1032
+ namespace = os.environ.get("HF_NAMESPACE") or (HfApi(token=hf_token).whoami().get("name") if hf_token else None)
1033
 
1034
  tool = HfJobsTool(
1035
  namespace=namespace,
backend/main.py CHANGED
@@ -5,6 +5,10 @@ import os
5
  from contextlib import asynccontextmanager
6
  from pathlib import Path
7
 
 
 
 
 
8
  # Ensure HF_TOKEN is set — fall back to HF_ADMIN_TOKEN if available (HF Spaces)
9
  if not os.environ.get("HF_TOKEN") and os.environ.get("HF_ADMIN_TOKEN"):
10
  os.environ["HF_TOKEN"] = os.environ["HF_ADMIN_TOKEN"]
 
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"]
backend/routes/agent.py CHANGED
@@ -5,13 +5,19 @@ dependency. In dev mode (no OAUTH_CLIENT_ID), auth is bypassed automatically.
5
  """
6
 
7
  import logging
 
8
  from typing import Any
9
 
10
- from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect
11
-
12
  from dependencies import get_current_user, get_ws_user
 
 
 
 
 
 
 
 
13
  from litellm import acompletion
14
-
15
  from models import (
16
  ApprovalRequest,
17
  HealthResponse,
@@ -27,6 +33,31 @@ logger = logging.getLogger(__name__)
27
 
28
  router = APIRouter(prefix="/api", tags=["agent"])
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
  def _check_session_access(session_id: str, user: dict[str, Any]) -> None:
32
  """Verify the user has access to the given session. Raises 403 or 404."""
@@ -58,21 +89,34 @@ async def llm_health_check() -> LLMHealthResponse:
58
  - timeout / network → provider unreachable
59
  """
60
  model = session_manager.config.model_name
 
61
  try:
62
  await acompletion(
63
  model=model,
64
  messages=[{"role": "user", "content": "hi"}],
65
  max_tokens=1,
66
  timeout=10,
 
67
  )
68
  return LLMHealthResponse(status="ok", model=model)
69
  except Exception as e:
70
  err_str = str(e).lower()
71
  error_type = "unknown"
72
 
73
- if "401" in err_str or "auth" in err_str or "invalid" in err_str or "api key" in err_str:
 
 
 
 
 
74
  error_type = "auth"
75
- elif "402" in err_str or "credit" in err_str or "quota" in err_str or "insufficient" in err_str or "billing" in err_str:
 
 
 
 
 
 
76
  error_type = "credits"
77
  elif "429" in err_str or "rate" in err_str:
78
  error_type = "rate_limit"
@@ -88,13 +132,6 @@ async def llm_health_check() -> LLMHealthResponse:
88
  )
89
 
90
 
91
- AVAILABLE_MODELS = [
92
- {"id": "anthropic/claude-opus-4-5-20251101", "label": "Claude Opus 4.5", "provider": "anthropic"},
93
- {"id": "huggingface/novita/deepseek-ai/DeepSeek-V3.1", "label": "DeepSeek V3.1", "provider": "huggingface"},
94
- {"id": "huggingface/novita/MiniMaxAI/MiniMax-M2.1", "label": "MiniMax M2.1", "provider": "huggingface"},
95
- ]
96
-
97
-
98
  @router.get("/config/model")
99
  async def get_model() -> dict:
100
  """Get current model and available models. No auth required."""
@@ -105,9 +142,7 @@ async def get_model() -> dict:
105
 
106
 
107
  @router.post("/config/model")
108
- async def set_model(
109
- body: dict, user: dict = Depends(get_current_user)
110
- ) -> dict:
111
  """Set the LLM model. Applies to new conversations."""
112
  model_id = body.get("model")
113
  if not model_id:
@@ -126,6 +161,7 @@ async def generate_title(
126
  ) -> dict:
127
  """Generate a short title for a chat session based on the first user message."""
128
  model = session_manager.config.model_name
 
129
  try:
130
  response = await acompletion(
131
  model=model,
@@ -143,6 +179,7 @@ async def generate_title(
143
  max_tokens=20,
144
  temperature=0.3,
145
  timeout=8,
 
146
  )
147
  title = response.choices[0].message.content.strip().strip('"').strip("'")
148
  # Safety: cap at 50 chars
@@ -169,11 +206,13 @@ async def create_session(
169
 
170
  Returns 503 if the server or user has reached the session limit.
171
  """
172
- # Extract the user's HF token from the Bearer header
173
  hf_token = None
174
  auth_header = request.headers.get("Authorization", "")
175
  if auth_header.startswith("Bearer "):
176
  hf_token = auth_header[7:]
 
 
177
 
178
  try:
179
  session_id = await session_manager.create_session(
@@ -258,9 +297,7 @@ async def interrupt_session(
258
 
259
 
260
  @router.post("/undo/{session_id}")
261
- async def undo_session(
262
- session_id: str, user: dict = Depends(get_current_user)
263
- ) -> dict:
264
  """Undo the last turn in a session."""
265
  _check_session_access(session_id, user)
266
  success = await session_manager.undo(session_id)
@@ -311,7 +348,9 @@ async def websocket_endpoint(websocket: WebSocket, session_id: str) -> None:
311
  # Authenticate the WebSocket connection
312
  user = await get_ws_user(websocket)
313
  if not user:
314
- logger.warning(f"WebSocket rejected: authentication failed for session {session_id}")
 
 
315
  await websocket.accept()
316
  await websocket.close(code=4001, reason="Authentication required")
317
  return
@@ -339,10 +378,12 @@ async def websocket_endpoint(websocket: WebSocket, session_id: str) -> None:
339
  # knows the session is alive. The original ready event from _run_session
340
  # fires before the WS is connected and is always lost.
341
  try:
342
- await websocket.send_json({
343
- "event_type": "ready",
344
- "data": {"message": "Agent initialized"},
345
- })
 
 
346
  except Exception as e:
347
  logger.error(f"Failed to send ready event for session {session_id}: {e}")
348
 
 
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
  from models import (
22
  ApprovalRequest,
23
  HealthResponse,
 
33
 
34
  router = APIRouter(prefix="/api", tags=["agent"])
35
 
36
+ AVAILABLE_MODELS = [
37
+ {
38
+ "id": "huggingface/novita/MiniMaxAI/MiniMax-M2.1",
39
+ "label": "MiniMax M2.1",
40
+ "provider": "huggingface",
41
+ "recommended": True,
42
+ },
43
+ {
44
+ "id": "anthropic/claude-opus-4-5-20251101",
45
+ "label": "Claude Opus 4.5",
46
+ "provider": "anthropic",
47
+ "recommended": True,
48
+ },
49
+ {
50
+ "id": "huggingface/novita/moonshotai/Kimi-K2.5",
51
+ "label": "Kimi K2.5",
52
+ "provider": "huggingface",
53
+ },
54
+ {
55
+ "id": "huggingface/novita/zai-org/GLM-5",
56
+ "label": "GLM 5",
57
+ "provider": "huggingface",
58
+ },
59
+ ]
60
+
61
 
62
  def _check_session_access(session_id: str, user: dict[str, Any]) -> None:
63
  """Verify the user has access to the given session. Raises 403 or 404."""
 
89
  - timeout / network → provider unreachable
90
  """
91
  model = session_manager.config.model_name
92
+ hf_key = os.environ.get("INFERENCE_TOKEN")
93
  try:
94
  await acompletion(
95
  model=model,
96
  messages=[{"role": "user", "content": "hi"}],
97
  max_tokens=1,
98
  timeout=10,
99
+ api_key=hf_key if hf_key and model.startswith("huggingface/") else None,
100
  )
101
  return LLMHealthResponse(status="ok", model=model)
102
  except Exception as e:
103
  err_str = str(e).lower()
104
  error_type = "unknown"
105
 
106
+ if (
107
+ "401" in err_str
108
+ or "auth" in err_str
109
+ or "invalid" in err_str
110
+ or "api key" in err_str
111
+ ):
112
  error_type = "auth"
113
+ elif (
114
+ "402" in err_str
115
+ or "credit" in err_str
116
+ or "quota" in err_str
117
+ or "insufficient" in err_str
118
+ or "billing" in err_str
119
+ ):
120
  error_type = "credits"
121
  elif "429" in err_str or "rate" in err_str:
122
  error_type = "rate_limit"
 
132
  )
133
 
134
 
 
 
 
 
 
 
 
135
  @router.get("/config/model")
136
  async def get_model() -> dict:
137
  """Get current model and available models. No auth required."""
 
142
 
143
 
144
  @router.post("/config/model")
145
+ async def set_model(body: dict, user: dict = Depends(get_current_user)) -> dict:
 
 
146
  """Set the LLM model. Applies to new conversations."""
147
  model_id = body.get("model")
148
  if not model_id:
 
161
  ) -> dict:
162
  """Generate a short title for a chat session based on the first user message."""
163
  model = session_manager.config.model_name
164
+ hf_key = os.environ.get("INFERENCE_TOKEN")
165
  try:
166
  response = await acompletion(
167
  model=model,
 
179
  max_tokens=20,
180
  temperature=0.3,
181
  timeout=8,
182
+ api_key=hf_key if hf_key and model.startswith("huggingface/") else None,
183
  )
184
  title = response.choices[0].message.content.strip().strip('"').strip("'")
185
  # Safety: cap at 50 chars
 
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(
 
297
 
298
 
299
  @router.post("/undo/{session_id}")
300
+ async def undo_session(session_id: str, user: dict = Depends(get_current_user)) -> dict:
 
 
301
  """Undo the last turn in a session."""
302
  _check_session_access(session_id, user)
303
  success = await session_manager.undo(session_id)
 
348
  # Authenticate the WebSocket connection
349
  user = await get_ws_user(websocket)
350
  if not user:
351
+ logger.warning(
352
+ f"WebSocket rejected: authentication failed for session {session_id}"
353
+ )
354
  await websocket.accept()
355
  await websocket.close(code=4001, reason="Authentication required")
356
  return
 
378
  # knows the session is alive. The original ready event from _run_session
379
  # fires before the WS is connected and is always lost.
380
  try:
381
+ await websocket.send_json(
382
+ {
383
+ "event_type": "ready",
384
+ "data": {"message": "Agent initialized"},
385
+ }
386
+ )
387
  except Exception as e:
388
  logger.error(f"Failed to send ready event for session {session_id}: {e}")
389
 
backend/routes/auth.py CHANGED
@@ -10,11 +10,10 @@ import time
10
  from urllib.parse import urlencode
11
 
12
  import httpx
 
13
  from fastapi import APIRouter, Depends, HTTPException, Request
14
  from fastapi.responses import RedirectResponse
15
 
16
- from dependencies import AUTH_ENABLED, get_current_user
17
-
18
  router = APIRouter(prefix="/auth", tags=["auth"])
19
 
20
  # OAuth configuration from environment
@@ -68,9 +67,12 @@ async def oauth_login(request: Request) -> RedirectResponse:
68
  params = {
69
  "client_id": OAUTH_CLIENT_ID,
70
  "redirect_uri": get_redirect_uri(request),
71
- "scope": "openid profile",
72
  "response_type": "code",
73
  "state": state,
 
 
 
74
  }
75
  auth_url = f"{OPENID_PROVIDER_URL}/oauth/authorize?{urlencode(params)}"
76
 
 
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"])
18
 
19
  # OAuth configuration from environment
 
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
 
frontend/src/components/Chat/AssistantMessage.tsx CHANGED
@@ -1,5 +1,4 @@
1
- import { Box, Stack, Avatar, Typography } from '@mui/material';
2
- import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
3
  import MarkdownContent from './MarkdownContent';
4
  import ToolCallGroup from './ToolCallGroup';
5
  import type { Message } from '@/types/agent';
@@ -48,61 +47,47 @@ export default function AssistantMessage({ message, isStreaming = false }: Assis
48
  };
49
 
50
  return (
51
- <Stack direction="row" spacing={1.5} alignItems="flex-start">
52
- <Avatar
53
- sx={{
54
- width: 28,
55
- height: 28,
56
- bgcolor: 'primary.main',
57
- flexShrink: 0,
58
- mt: 0.5,
59
- }}
60
- >
61
- <SmartToyOutlinedIcon sx={{ fontSize: 16, color: '#fff' }} />
62
- </Avatar>
63
-
64
- <Box sx={{ flex: 1, minWidth: 0 }}>
65
- {/* Role label + timestamp */}
66
- <Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 0.5 }}>
67
- <Typography
68
- variant="caption"
69
- sx={{
70
- fontWeight: 700,
71
- fontSize: '0.72rem',
72
- color: 'var(--muted-text)',
73
- textTransform: 'uppercase',
74
- letterSpacing: '0.04em',
75
- }}
76
- >
77
- Assistant
78
- </Typography>
79
- <Typography
80
- variant="caption"
81
- sx={{
82
- fontSize: '0.66rem',
83
- color: 'var(--muted-text)',
84
- opacity: 0.6,
85
- }}
86
- >
87
- {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
88
- </Typography>
89
- </Stack>
90
-
91
- {/* Message bubble */}
92
- <Box
93
  sx={{
94
- maxWidth: { xs: '95%', md: '85%' },
95
- bgcolor: 'var(--surface)',
96
- borderRadius: 1.5,
97
- borderTopLeftRadius: 4,
98
- px: { xs: 1.5, md: 2.5 },
99
- py: 1.5,
100
- border: '1px solid var(--border)',
101
  }}
102
  >
103
- {renderSegments()}
104
- </Box>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  </Box>
106
- </Stack>
107
  );
108
  }
 
1
+ import { Box, Stack, Typography } from '@mui/material';
 
2
  import MarkdownContent from './MarkdownContent';
3
  import ToolCallGroup from './ToolCallGroup';
4
  import type { Message } from '@/types/agent';
 
47
  };
48
 
49
  return (
50
+ <Box sx={{ minWidth: 0 }}>
51
+ {/* Role label + timestamp */}
52
+ <Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 0.5 }}>
53
+ <Typography
54
+ variant="caption"
55
+ sx={{
56
+ fontWeight: 700,
57
+ fontSize: '0.72rem',
58
+ color: 'var(--muted-text)',
59
+ textTransform: 'uppercase',
60
+ letterSpacing: '0.04em',
61
+ }}
62
+ >
63
+ Assistant
64
+ </Typography>
65
+ <Typography
66
+ variant="caption"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  sx={{
68
+ fontSize: '0.66rem',
69
+ color: 'var(--muted-text)',
70
+ opacity: 0.6,
 
 
 
 
71
  }}
72
  >
73
+ {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
74
+ </Typography>
75
+ </Stack>
76
+
77
+ {/* Message bubble */}
78
+ <Box
79
+ sx={{
80
+ maxWidth: { xs: '95%', md: '85%' },
81
+ bgcolor: 'var(--surface)',
82
+ borderRadius: 1.5,
83
+ borderTopLeftRadius: 4,
84
+ px: { xs: 1.5, md: 2.5 },
85
+ py: 1.5,
86
+ border: '1px solid var(--border)',
87
+ }}
88
+ >
89
+ {renderSegments()}
90
  </Box>
91
+ </Box>
92
  );
93
  }
frontend/src/components/Chat/ChatInput.tsx CHANGED
@@ -1,6 +1,60 @@
1
  import { useState, useCallback, useEffect, useRef, KeyboardEvent } from 'react';
2
- import { Box, TextField, IconButton, CircularProgress } from '@mui/material';
3
  import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  interface ChatInputProps {
6
  onSend: (text: string) => void;
@@ -10,8 +64,25 @@ interface ChatInputProps {
10
  export default function ChatInput({ onSend, disabled = false }: ChatInputProps) {
11
  const [input, setInput] = useState('');
12
  const inputRef = useRef<HTMLTextAreaElement>(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
 
14
- // Auto-focus the textarea when the session becomes ready (disabled false)
15
  useEffect(() => {
16
  if (!disabled && inputRef.current) {
17
  inputRef.current.focus();
@@ -35,6 +106,27 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
35
  [handleSend]
36
  );
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  return (
39
  <Box
40
  sx={{
@@ -118,6 +210,108 @@ export default function ChatInput({ onSend, disabled = false }: ChatInputProps)
118
  {disabled ? <CircularProgress size={20} color="inherit" /> : <ArrowUpwardIcon fontSize="small" />}
119
  </IconButton>
120
  </Box>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  </Box>
122
  </Box>
123
  );
 
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/MiniMaxAI/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;
 
64
  export default function ChatInput({ onSend, disabled = false }: ChatInputProps) {
65
  const [input, setInput] = useState('');
66
  const inputRef = useRef<HTMLTextAreaElement>(null);
67
+ const [selectedModelId, setSelectedModelId] = useState<string>(MODEL_OPTIONS[0].id);
68
+ const [modelAnchorEl, setModelAnchorEl] = useState<null | HTMLElement>(null);
69
+
70
+ // Sync with backend on mount
71
+ useEffect(() => {
72
+ fetch('/api/config/model')
73
+ .then((res) => (res.ok ? res.json() : null))
74
+ .then((data) => {
75
+ if (data?.current) {
76
+ const model = findModelByPath(data.current);
77
+ if (model) setSelectedModelId(model.id);
78
+ }
79
+ })
80
+ .catch(() => { /* ignore */ });
81
+ }, []);
82
+
83
+ const selectedModel = MODEL_OPTIONS.find(m => m.id === selectedModelId) || MODEL_OPTIONS[0];
84
 
85
+ // Auto-focus the textarea when the session becomes ready (disabled -> false)
86
  useEffect(() => {
87
  if (!disabled && inputRef.current) {
88
  inputRef.current.focus();
 
106
  [handleSend]
107
  );
108
 
109
+ const handleModelClick = (event: React.MouseEvent<HTMLElement>) => {
110
+ setModelAnchorEl(event.currentTarget);
111
+ };
112
+
113
+ const handleModelClose = () => {
114
+ setModelAnchorEl(null);
115
+ };
116
+
117
+ const handleSelectModel = async (model: ModelOption) => {
118
+ handleModelClose();
119
+ try {
120
+ const res = await apiFetch('/api/config/model', {
121
+ method: 'POST',
122
+ body: JSON.stringify({ model: model.modelPath }),
123
+ });
124
+ if (res.ok) {
125
+ setSelectedModelId(model.id);
126
+ }
127
+ } catch { /* ignore */ }
128
+ };
129
+
130
  return (
131
  <Box
132
  sx={{
 
210
  {disabled ? <CircularProgress size={20} color="inherit" /> : <ArrowUpwardIcon fontSize="small" />}
211
  </IconButton>
212
  </Box>
213
+
214
+ {/* Powered By Badge */}
215
+ <Box
216
+ onClick={handleModelClick}
217
+ sx={{
218
+ display: 'flex',
219
+ alignItems: 'center',
220
+ justifyContent: 'center',
221
+ mt: 1.5,
222
+ gap: 0.8,
223
+ opacity: 0.6,
224
+ cursor: 'pointer',
225
+ transition: 'opacity 0.2s',
226
+ '&:hover': {
227
+ opacity: 1
228
+ }
229
+ }}
230
+ >
231
+ <Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--muted-text)', textTransform: 'uppercase', letterSpacing: '0.05em', fontWeight: 500 }}>
232
+ powered by
233
+ </Typography>
234
+ <img
235
+ src={selectedModel.avatarUrl}
236
+ alt={selectedModel.name}
237
+ style={{ height: '14px', width: '14px', objectFit: 'contain', borderRadius: '2px' }}
238
+ />
239
+ <Typography variant="caption" sx={{ fontSize: '10px', color: 'var(--text)', fontWeight: 600, letterSpacing: '0.02em' }}>
240
+ {selectedModel.name}
241
+ </Typography>
242
+ <ArrowDropDownIcon sx={{ fontSize: '14px', color: 'var(--muted-text)' }} />
243
+ </Box>
244
+
245
+ {/* Model Selection Menu */}
246
+ <Menu
247
+ anchorEl={modelAnchorEl}
248
+ open={Boolean(modelAnchorEl)}
249
+ onClose={handleModelClose}
250
+ anchorOrigin={{
251
+ vertical: 'top',
252
+ horizontal: 'center',
253
+ }}
254
+ transformOrigin={{
255
+ vertical: 'bottom',
256
+ horizontal: 'center',
257
+ }}
258
+ slotProps={{
259
+ paper: {
260
+ sx: {
261
+ bgcolor: 'var(--panel)',
262
+ border: '1px solid var(--divider)',
263
+ mb: 1,
264
+ maxHeight: '400px',
265
+ }
266
+ }
267
+ }}
268
+ >
269
+ {MODEL_OPTIONS.map((model) => (
270
+ <MenuItem
271
+ key={model.id}
272
+ onClick={() => handleSelectModel(model)}
273
+ selected={selectedModelId === model.id}
274
+ sx={{
275
+ py: 1.5,
276
+ '&.Mui-selected': {
277
+ bgcolor: 'rgba(255,255,255,0.05)',
278
+ }
279
+ }}
280
+ >
281
+ <ListItemIcon>
282
+ <img
283
+ src={model.avatarUrl}
284
+ alt={model.name}
285
+ style={{ width: 24, height: 24, borderRadius: '4px', objectFit: 'cover' }}
286
+ />
287
+ </ListItemIcon>
288
+ <ListItemText
289
+ primary={
290
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
291
+ {model.name}
292
+ {model.recommended && (
293
+ <Chip
294
+ label="Recommended"
295
+ size="small"
296
+ sx={{
297
+ height: '18px',
298
+ fontSize: '10px',
299
+ bgcolor: 'var(--accent-yellow)',
300
+ color: '#000',
301
+ fontWeight: 600,
302
+ }}
303
+ />
304
+ )}
305
+ </Box>
306
+ }
307
+ secondary={model.description}
308
+ secondaryTypographyProps={{
309
+ sx: { fontSize: '12px', color: 'var(--muted-text)' }
310
+ }}
311
+ />
312
+ </MenuItem>
313
+ ))}
314
+ </Menu>
315
  </Box>
316
  </Box>
317
  );
frontend/src/components/Chat/MarkdownContent.tsx CHANGED
@@ -94,26 +94,6 @@ const markdownSx: SxProps<Theme> = {
94
  },
95
  };
96
 
97
- /** Blinking cursor shown at the end of streaming text. */
98
- const StreamingCursor = () => (
99
- <Box
100
- component="span"
101
- sx={{
102
- display: 'inline-block',
103
- width: '2px',
104
- height: '1.1em',
105
- bgcolor: 'var(--text)',
106
- ml: '2px',
107
- verticalAlign: 'text-bottom',
108
- animation: 'cursorBlink 1s step-end infinite',
109
- '@keyframes cursorBlink': {
110
- '0%, 100%': { opacity: 1 },
111
- '50%': { opacity: 0 },
112
- },
113
- }}
114
- />
115
- );
116
-
117
  /**
118
  * Throttled content for streaming: render the full markdown through
119
  * ReactMarkdown but only re-parse every ~80ms to avoid layout thrashing.
@@ -175,7 +155,6 @@ export default function MarkdownContent({ content, sx, isStreaming = false }: Ma
175
  return (
176
  <Box sx={[markdownSx, ...(Array.isArray(sx) ? sx : sx ? [sx] : [])]}>
177
  <ReactMarkdown remarkPlugins={remarkPlugins}>{displayContent}</ReactMarkdown>
178
- {isStreaming && <StreamingCursor />}
179
  </Box>
180
  );
181
  }
 
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.
 
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/MessageList.tsx CHANGED
@@ -1,9 +1,7 @@
1
  import { useEffect, useRef, useMemo, useCallback } from 'react';
2
- import { Box, Stack, Typography, Avatar } from '@mui/material';
3
- import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
4
  import MessageBubble from './MessageBubble';
5
  import ThinkingIndicator from './ThinkingIndicator';
6
- import MarkdownContent from './MarkdownContent';
7
  import { useAgentStore } from '@/store/agentStore';
8
  import { useSessionStore } from '@/store/sessionStore';
9
  import { apiFetch } from '@/utils/api';
@@ -15,66 +13,48 @@ interface MessageListProps {
15
  isProcessing: boolean;
16
  }
17
 
18
- const WELCOME_MD = `I'm ready to help you with machine learning tasks using the Hugging Face ecosystem.
19
-
20
- **Training & Fine-tuning** SFT, DPO, GRPO, PPO with TRL · LoRA/PEFT · Submit and monitor jobs on cloud GPUs
21
-
22
- **Data** — Find and explore datasets · Process, filter, transform · Push to the Hub
23
-
24
- **Models** — Search and discover models · Get details and configs · Deploy for inference
25
-
26
- **Research** — Find papers and documentation · Explore code examples · Check APIs and best practices
27
-
28
- **Infrastructure** — Run jobs on CPU/GPU instances · Manage repos, branches, PRs · Monitor Spaces and endpoints
29
 
30
- What would you like to do?`;
 
 
 
 
31
 
32
- /** Static welcome message rendered when the conversation is empty. */
33
- function WelcomeMessage() {
34
  return (
35
- <Stack direction="row" spacing={1.5} alignItems="flex-start">
36
- <Avatar
 
 
 
 
 
 
 
 
 
 
37
  sx={{
38
- width: 28,
39
- height: 28,
40
- bgcolor: 'primary.main',
41
- flexShrink: 0,
42
- mt: 0.5,
43
  }}
44
  >
45
- <SmartToyOutlinedIcon sx={{ fontSize: 16, color: '#fff' }} />
46
- </Avatar>
47
-
48
- <Box sx={{ flex: 1, minWidth: 0 }}>
49
- <Stack direction="row" alignItems="baseline" spacing={1} sx={{ mb: 0.5 }}>
50
- <Typography
51
- variant="caption"
52
- sx={{
53
- fontWeight: 700,
54
- fontSize: '0.72rem',
55
- color: 'var(--muted-text)',
56
- textTransform: 'uppercase',
57
- letterSpacing: '0.04em',
58
- }}
59
- >
60
- Assistant
61
- </Typography>
62
- </Stack>
63
- <Box
64
- sx={{
65
- maxWidth: { xs: '95%', md: '85%' },
66
- bgcolor: 'var(--surface)',
67
- borderRadius: 1.5,
68
- borderTopLeftRadius: 4,
69
- px: { xs: 1.5, md: 2.5 },
70
- py: 1.5,
71
- border: '1px solid var(--border)',
72
- }}
73
- >
74
- <MarkdownContent content={WELCOME_MD} />
75
- </Box>
76
- </Box>
77
- </Stack>
78
  );
79
  }
80
 
@@ -91,8 +71,6 @@ export default function MessageList({ messages, isProcessing }: MessageListProps
91
  }, []);
92
 
93
  // ── Track user scroll intent ────────────────────────────────────
94
- // When user scrolls up (>80px from bottom), disable auto-scroll.
95
- // When they scroll back to bottom, re-enable it.
96
  useEffect(() => {
97
  const el = scrollContainerRef.current;
98
  if (!el) return;
@@ -112,8 +90,6 @@ export default function MessageList({ messages, isProcessing }: MessageListProps
112
  }, [messages, isProcessing, scrollToBottom]);
113
 
114
  // ── Auto-scroll on DOM mutations (streaming content growth) ─────
115
- // This catches token-by-token updates that don't change the messages
116
- // array reference (appendToMessage mutates in place).
117
  useEffect(() => {
118
  const el = scrollContainerRef.current;
119
  if (!el) return;
@@ -145,7 +121,6 @@ export default function MessageList({ messages, isProcessing }: MessageListProps
145
  if (!activeSessionId) return;
146
  try {
147
  await apiFetch(`/api/undo/${activeSessionId}`, { method: 'POST' });
148
- // Optimistic removal — backend will also confirm via undo_complete WS event
149
  removeLastTurn(activeSessionId);
150
  } catch (e) {
151
  logger.error('Undo failed:', e);
@@ -160,6 +135,8 @@ export default function MessageList({ messages, isProcessing }: MessageListProps
160
  overflow: 'auto',
161
  px: { xs: 0.5, sm: 1, md: 2 },
162
  py: { xs: 2, md: 3 },
 
 
163
  }}
164
  >
165
  <Stack
@@ -168,12 +145,12 @@ export default function MessageList({ messages, isProcessing }: MessageListProps
168
  maxWidth: 880,
169
  mx: 'auto',
170
  width: '100%',
 
171
  }}
172
  >
173
- {/* Always show the welcome message at the top */}
174
- <WelcomeMessage />
175
-
176
- {messages.length > 0 && (
177
  messages.map((msg) => (
178
  <MessageBubble
179
  key={msg.id}
 
1
  import { useEffect, useRef, useMemo, useCallback } from 'react';
2
+ import { Box, Stack, Typography } from '@mui/material';
 
3
  import MessageBubble from './MessageBubble';
4
  import ThinkingIndicator from './ThinkingIndicator';
 
5
  import { useAgentStore } from '@/store/agentStore';
6
  import { useSessionStore } from '@/store/sessionStore';
7
  import { apiFetch } from '@/utils/api';
 
13
  isProcessing: boolean;
14
  }
15
 
16
+ function getGreeting(): string {
17
+ const h = new Date().getHours();
18
+ if (h < 12) return 'Morning';
19
+ if (h < 17) return 'Afternoon';
20
+ return 'Evening';
21
+ }
 
 
 
 
 
22
 
23
+ /** Minimal greeting shown when the conversation is empty. */
24
+ function WelcomeGreeting() {
25
+ const { user } = useAgentStore();
26
+ const firstName = user?.name?.split(' ')[0] || user?.username;
27
+ const greeting = firstName ? `${getGreeting()}, ${firstName}` : getGreeting();
28
 
 
 
29
  return (
30
+ <Box
31
+ sx={{
32
+ flex: 1,
33
+ display: 'flex',
34
+ flexDirection: 'column',
35
+ alignItems: 'center',
36
+ justifyContent: 'center',
37
+ py: 8,
38
+ gap: 1.5,
39
+ }}
40
+ >
41
+ <Typography
42
  sx={{
43
+ fontFamily: 'monospace',
44
+ fontSize: '1.6rem',
45
+ color: 'var(--text)',
46
+ fontWeight: 600,
 
47
  }}
48
  >
49
+ {greeting}
50
+ </Typography>
51
+ <Typography
52
+ color="text.secondary"
53
+ sx={{ fontFamily: 'monospace', fontSize: '0.9rem' }}
54
+ >
55
+ Let's build something impressive?
56
+ </Typography>
57
+ </Box>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  );
59
  }
60
 
 
71
  }, []);
72
 
73
  // ── Track user scroll intent ────────────────────────────────────
 
 
74
  useEffect(() => {
75
  const el = scrollContainerRef.current;
76
  if (!el) return;
 
90
  }, [messages, isProcessing, scrollToBottom]);
91
 
92
  // ── Auto-scroll on DOM mutations (streaming content growth) ─────
 
 
93
  useEffect(() => {
94
  const el = scrollContainerRef.current;
95
  if (!el) return;
 
121
  if (!activeSessionId) return;
122
  try {
123
  await apiFetch(`/api/undo/${activeSessionId}`, { method: 'POST' });
 
124
  removeLastTurn(activeSessionId);
125
  } catch (e) {
126
  logger.error('Undo failed:', e);
 
135
  overflow: 'auto',
136
  px: { xs: 0.5, sm: 1, md: 2 },
137
  py: { xs: 2, md: 3 },
138
+ display: 'flex',
139
+ flexDirection: 'column',
140
  }}
141
  >
142
  <Stack
 
145
  maxWidth: 880,
146
  mx: 'auto',
147
  width: '100%',
148
+ flex: messages.length === 0 && !isProcessing ? 1 : undefined,
149
  }}
150
  >
151
+ {messages.length === 0 && !isProcessing ? (
152
+ <WelcomeGreeting />
153
+ ) : (
 
154
  messages.map((msg) => (
155
  <MessageBubble
156
  key={msg.id}
frontend/src/components/Chat/ThinkingIndicator.tsx CHANGED
@@ -1,63 +1,48 @@
1
- import { Box, Stack, Avatar, Typography } from '@mui/material';
2
- import SmartToyOutlinedIcon from '@mui/icons-material/SmartToyOutlined';
3
 
4
  /** Pulsing dots shown while the agent is processing. */
5
  export default function ThinkingIndicator() {
6
  return (
7
- <Stack direction="row" spacing={1.5} alignItems="flex-start">
8
- <Avatar
 
9
  sx={{
10
- width: 28,
11
- height: 28,
12
- bgcolor: 'primary.main',
13
- flexShrink: 0,
14
- mt: 0.5,
 
 
 
15
  }}
16
  >
17
- <SmartToyOutlinedIcon sx={{ fontSize: 16, color: '#fff' }} />
18
- </Avatar>
19
-
20
- <Box sx={{ pt: 0.75 }}>
21
- <Typography
22
- variant="caption"
23
  sx={{
24
- fontWeight: 700,
25
- fontSize: '0.72rem',
26
- color: 'var(--muted-text)',
27
- textTransform: 'uppercase',
28
- letterSpacing: '0.04em',
29
- display: 'flex',
30
- alignItems: 'center',
31
- gap: 0.75,
 
 
 
 
 
 
 
32
  }}
33
  >
34
- Thinking
35
- <Box
36
- component="span"
37
- sx={{
38
- display: 'inline-flex',
39
- gap: '3px',
40
- '& span': {
41
- width: 4,
42
- height: 4,
43
- borderRadius: '50%',
44
- bgcolor: 'primary.main',
45
- animation: 'dotPulse 1.4s ease-in-out infinite',
46
- },
47
- '& span:nth-of-type(2)': { animationDelay: '0.2s' },
48
- '& span:nth-of-type(3)': { animationDelay: '0.4s' },
49
- '@keyframes dotPulse': {
50
- '0%, 80%, 100%': { opacity: 0.25, transform: 'scale(0.8)' },
51
- '40%': { opacity: 1, transform: 'scale(1)' },
52
- },
53
- }}
54
- >
55
- <span />
56
- <span />
57
- <span />
58
- </Box>
59
- </Typography>
60
- </Box>
61
- </Stack>
62
  );
63
  }
 
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 CHANGED
@@ -195,6 +195,14 @@ export default function ToolCallGroup({ tools }: ToolCallGroupProps) {
195
  content: String(log.args.script),
196
  language: 'python',
197
  });
 
 
 
 
 
 
 
 
198
  if (log.jobLogs) {
199
  setPanelTab({
200
  id: 'logs',
@@ -203,7 +211,8 @@ export default function ToolCallGroup({ tools }: ToolCallGroupProps) {
203
  language: 'text',
204
  });
205
  }
206
- setActivePanelTab('script');
 
207
  setRightPanelOpen(true);
208
  setLeftSidebarOpen(false);
209
  return;
@@ -307,53 +316,6 @@ export default function ToolCallGroup({ tools }: ToolCallGroupProps) {
307
  {log.tool}
308
  </Typography>
309
 
310
- {/* Quick action links for completed jobs */}
311
- {log.completed && log.tool === 'hf_jobs' && !!log.args?.script && (
312
- <Box sx={{ display: 'flex', gap: 0.5 }} onClick={(e) => e.stopPropagation()}>
313
- <Typography
314
- component="span"
315
- onClick={() => handleClick(log)}
316
- sx={{
317
- fontSize: '0.68rem',
318
- color: 'var(--muted-text)',
319
- cursor: 'pointer',
320
- px: 0.75,
321
- py: 0.25,
322
- borderRadius: 0.5,
323
- '&:hover': { color: 'var(--accent-yellow)', bgcolor: 'var(--hover-bg)' },
324
- }}
325
- >
326
- Script
327
- </Typography>
328
- {log.jobLogs && (
329
- <Typography
330
- component="span"
331
- onClick={() => {
332
- clearPanelTabs();
333
- if (log.args?.script) {
334
- setPanelTab({ id: 'script', title: 'Script', content: String(log.args.script), language: 'python' });
335
- }
336
- setPanelTab({ id: 'logs', title: 'Logs', content: log.jobLogs!, language: 'text' });
337
- setActivePanelTab('logs');
338
- setRightPanelOpen(true);
339
- setLeftSidebarOpen(false);
340
- }}
341
- sx={{
342
- fontSize: '0.68rem',
343
- color: 'var(--accent-yellow)',
344
- cursor: 'pointer',
345
- px: 0.75,
346
- py: 0.25,
347
- borderRadius: 0.5,
348
- '&:hover': { bgcolor: 'var(--hover-bg)' },
349
- }}
350
- >
351
- Logs
352
- </Typography>
353
- )}
354
- </Box>
355
- )}
356
-
357
  {label && (
358
  <Chip
359
  label={label}
 
195
  content: String(log.args.script),
196
  language: 'python',
197
  });
198
+ if (log.output) {
199
+ setPanelTab({
200
+ id: 'output',
201
+ title: 'Output',
202
+ content: log.output,
203
+ language: 'markdown',
204
+ });
205
+ }
206
  if (log.jobLogs) {
207
  setPanelTab({
208
  id: 'logs',
 
211
  language: 'text',
212
  });
213
  }
214
+ // Default to output if it exists (most useful), otherwise script
215
+ setActivePanelTab(log.output ? 'output' : 'script');
216
  setRightPanelOpen(true);
217
  setLeftSidebarOpen(false);
218
  return;
 
316
  {log.tool}
317
  </Typography>
318
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  {label && (
320
  <Chip
321
  label={label}
frontend/src/components/Chat/UserMessage.tsx CHANGED
@@ -1,5 +1,4 @@
1
- import { Box, Stack, Typography, Avatar, IconButton, Tooltip } from '@mui/material';
2
- import PersonOutlineIcon from '@mui/icons-material/PersonOutline';
3
  import CloseIcon from '@mui/icons-material/Close';
4
  import type { Message } from '@/types/agent';
5
 
@@ -99,19 +98,6 @@ export default function UserMessage({
99
  {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
100
  </Typography>
101
  </Box>
102
-
103
- <Avatar
104
- sx={{
105
- width: 28,
106
- height: 28,
107
- bgcolor: 'var(--hover-bg)',
108
- border: '1px solid var(--border)',
109
- flexShrink: 0,
110
- mt: 0.5,
111
- }}
112
- >
113
- <PersonOutlineIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />
114
- </Avatar>
115
  </Stack>
116
  );
117
  }
 
1
+ import { Box, Stack, Typography, IconButton, Tooltip } from '@mui/material';
 
2
  import CloseIcon from '@mui/icons-material/Close';
3
  import type { Message } from '@/types/agent';
4
 
 
98
  {new Date(message.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
99
  </Typography>
100
  </Box>
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  </Stack>
102
  );
103
  }
frontend/src/components/CodePanel/CodePanel.tsx CHANGED
@@ -1,5 +1,5 @@
1
- import { useRef, useEffect, useMemo } from 'react';
2
- import { Box, Stack, Typography, IconButton } from '@mui/material';
3
  import CloseIcon from '@mui/icons-material/Close';
4
  import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
5
  import CheckCircleIcon from '@mui/icons-material/CheckCircle';
@@ -7,6 +7,10 @@ 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, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
12
  import ReactMarkdown from 'react-markdown';
@@ -92,10 +96,15 @@ const markdownSx = {
92
  // ── Component ────────────────────────────────────────────────────
93
 
94
  export default function CodePanel() {
95
- const { panelContent, panelTabs, activePanelTab, setActivePanelTab, removePanelTab, plan } =
96
  useAgentStore();
97
  const { setRightPanelOpen, themeMode } = useLayoutStore();
98
  const scrollRef = useRef<HTMLDivElement>(null);
 
 
 
 
 
99
 
100
  const activeTab = panelTabs.find((t) => t.id === activePanelTab);
101
  const currentContent = activeTab || panelContent;
@@ -104,6 +113,66 @@ export default function CodePanel() {
104
  const isDark = themeMode === 'dark';
105
  const syntaxTheme = isDark ? vscDarkPlus : vs;
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  const displayContent = useMemo(() => {
108
  if (!currentContent?.content) return '';
109
  if (!currentContent.language || currentContent.language === 'text') {
@@ -147,6 +216,30 @@ export default function CodePanel() {
147
  );
148
  }
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  if (currentContent.language === 'python') return renderSyntaxBlock('python');
151
  if (currentContent.language === 'json') return renderSyntaxBlock('json');
152
 
@@ -247,9 +340,94 @@ export default function CodePanel() {
247
  </Typography>
248
  )}
249
 
250
- <IconButton size="small" onClick={() => setRightPanelOpen(false)} sx={{ color: 'var(--muted-text)' }}>
251
- <CloseIcon fontSize="small" />
252
- </IconButton>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  </Box>
254
 
255
  {/* ── Main content area ─────────────────────────────────── */}
 
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';
 
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 EditIcon from '@mui/icons-material/Edit';
11
+ import UndoIcon from '@mui/icons-material/Undo';
12
+ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
13
+ import CheckIcon from '@mui/icons-material/Check';
14
  import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
15
  import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism';
16
  import ReactMarkdown from 'react-markdown';
 
96
  // ── Component ────────────────────────────────────────────────────
97
 
98
  export default function CodePanel() {
99
+ const { panelContent, panelTabs, activePanelTab, setActivePanelTab, removePanelTab, plan, updatePanelTabContent, setEditedScript } =
100
  useAgentStore();
101
  const { setRightPanelOpen, themeMode } = useLayoutStore();
102
  const scrollRef = useRef<HTMLDivElement>(null);
103
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
104
+ const [isEditing, setIsEditing] = useState(false);
105
+ const [editedContent, setEditedContent] = useState('');
106
+ const [originalContent, setOriginalContent] = useState('');
107
+ const [copied, setCopied] = useState(false);
108
 
109
  const activeTab = panelTabs.find((t) => t.id === activePanelTab);
110
  const currentContent = activeTab || panelContent;
 
113
  const isDark = themeMode === 'dark';
114
  const syntaxTheme = isDark ? vscDarkPlus : vs;
115
 
116
+ // Check if this is an editable script tab
117
+ const isEditableScript = activeTab?.id === 'script' && activeTab?.language === 'python';
118
+ const hasUnsavedChanges = isEditing && editedContent !== originalContent;
119
+
120
+ // Sync edited content when switching tabs or content changes
121
+ useEffect(() => {
122
+ if (currentContent?.content && isEditableScript) {
123
+ setOriginalContent(currentContent.content);
124
+ if (!isEditing) {
125
+ setEditedContent(currentContent.content);
126
+ }
127
+ }
128
+ }, [currentContent?.content, isEditableScript, isEditing]);
129
+
130
+ // Exit editing when switching away from script tab
131
+ useEffect(() => {
132
+ if (!isEditableScript && isEditing) {
133
+ setIsEditing(false);
134
+ }
135
+ }, [isEditableScript, isEditing]);
136
+
137
+ const handleStartEdit = useCallback(() => {
138
+ if (currentContent?.content) {
139
+ setEditedContent(currentContent.content);
140
+ setOriginalContent(currentContent.content);
141
+ setIsEditing(true);
142
+ setTimeout(() => textareaRef.current?.focus(), 0);
143
+ }
144
+ }, [currentContent?.content]);
145
+
146
+ const handleCancelEdit = useCallback(() => {
147
+ setEditedContent(originalContent);
148
+ setIsEditing(false);
149
+ }, [originalContent]);
150
+
151
+ const handleSaveEdit = useCallback(() => {
152
+ if (activeTab && editedContent !== originalContent) {
153
+ updatePanelTabContent(activeTab.id, editedContent);
154
+ const toolCallId = activeTab.parameters?.tool_call_id as string | undefined;
155
+ if (toolCallId) {
156
+ setEditedScript(toolCallId, editedContent);
157
+ }
158
+ setOriginalContent(editedContent);
159
+ }
160
+ setIsEditing(false);
161
+ }, [activeTab, editedContent, originalContent, updatePanelTabContent, setEditedScript]);
162
+
163
+ const handleCopy = useCallback(async () => {
164
+ const contentToCopy = isEditing ? editedContent : (currentContent?.content || '');
165
+ if (contentToCopy) {
166
+ try {
167
+ await navigator.clipboard.writeText(contentToCopy);
168
+ setCopied(true);
169
+ setTimeout(() => setCopied(false), 2000);
170
+ } catch (err) {
171
+ console.error('Failed to copy:', err);
172
+ }
173
+ }
174
+ }, [isEditing, editedContent, currentContent?.content]);
175
+
176
  const displayContent = useMemo(() => {
177
  if (!currentContent?.content) return '';
178
  if (!currentContent.language || currentContent.language === 'text') {
 
216
  );
217
  }
218
 
219
+ // Editing mode: show textarea
220
+ if (isEditing && isEditableScript) {
221
+ return (
222
+ <textarea
223
+ ref={textareaRef}
224
+ value={editedContent}
225
+ onChange={(e) => setEditedContent(e.target.value)}
226
+ spellCheck={false}
227
+ style={{
228
+ width: '100%',
229
+ height: '100%',
230
+ background: 'transparent',
231
+ border: 'none',
232
+ outline: 'none',
233
+ resize: 'none',
234
+ color: 'var(--text)',
235
+ fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace',
236
+ fontSize: '13px',
237
+ lineHeight: 1.55,
238
+ }}
239
+ />
240
+ );
241
+ }
242
+
243
  if (currentContent.language === 'python') return renderSyntaxBlock('python');
244
  if (currentContent.language === 'json') return renderSyntaxBlock('json');
245
 
 
340
  </Typography>
341
  )}
342
 
343
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
344
+ {/* Copy button */}
345
+ {currentContent?.content && (
346
+ <Tooltip title={copied ? 'Copied!' : 'Copy'} placement="top">
347
+ <IconButton
348
+ size="small"
349
+ onClick={handleCopy}
350
+ sx={{
351
+ color: copied ? 'var(--accent-green)' : 'var(--muted-text)',
352
+ '&:hover': {
353
+ color: 'var(--accent-yellow)',
354
+ bgcolor: 'var(--hover-bg)',
355
+ },
356
+ }}
357
+ >
358
+ {copied ? <CheckIcon sx={{ fontSize: 18 }} /> : <ContentCopyIcon sx={{ fontSize: 18 }} />}
359
+ </IconButton>
360
+ </Tooltip>
361
+ )}
362
+ {/* Edit controls for script tab */}
363
+ {isEditableScript && !isEditing && (
364
+ <Button
365
+ size="small"
366
+ startIcon={<EditIcon sx={{ fontSize: 14 }} />}
367
+ onClick={handleStartEdit}
368
+ sx={{
369
+ textTransform: 'none',
370
+ color: 'var(--muted-text)',
371
+ fontSize: '0.75rem',
372
+ py: 0.5,
373
+ '&:hover': {
374
+ color: 'var(--accent-yellow)',
375
+ bgcolor: 'var(--hover-bg)',
376
+ },
377
+ }}
378
+ >
379
+ Edit
380
+ </Button>
381
+ )}
382
+ {isEditing && (
383
+ <>
384
+ <Button
385
+ size="small"
386
+ startIcon={<UndoIcon sx={{ fontSize: 14 }} />}
387
+ onClick={handleCancelEdit}
388
+ sx={{
389
+ textTransform: 'none',
390
+ color: 'var(--muted-text)',
391
+ fontSize: '0.75rem',
392
+ py: 0.5,
393
+ '&:hover': {
394
+ color: 'var(--accent-red)',
395
+ bgcolor: 'var(--hover-bg)',
396
+ },
397
+ }}
398
+ >
399
+ Cancel
400
+ </Button>
401
+ <Button
402
+ size="small"
403
+ variant="contained"
404
+ onClick={handleSaveEdit}
405
+ disabled={!hasUnsavedChanges}
406
+ sx={{
407
+ textTransform: 'none',
408
+ fontSize: '0.75rem',
409
+ py: 0.5,
410
+ bgcolor: hasUnsavedChanges ? 'var(--accent-green)' : 'var(--hover-bg)',
411
+ color: hasUnsavedChanges ? '#000' : 'var(--muted-text)',
412
+ '&:hover': {
413
+ bgcolor: hasUnsavedChanges ? 'var(--accent-green)' : 'var(--hover-bg)',
414
+ opacity: 0.9,
415
+ },
416
+ '&.Mui-disabled': {
417
+ bgcolor: 'var(--hover-bg)',
418
+ color: 'var(--muted-text)',
419
+ opacity: 0.5,
420
+ },
421
+ }}
422
+ >
423
+ Save
424
+ </Button>
425
+ </>
426
+ )}
427
+ <IconButton size="small" onClick={() => setRightPanelOpen(false)} sx={{ color: 'var(--muted-text)' }}>
428
+ <CloseIcon fontSize="small" />
429
+ </IconButton>
430
+ </Box>
431
  </Box>
432
 
433
  {/* ── Main content area ─────────────────────────────────── */}
frontend/src/components/Layout/AppLayout.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { useCallback, useRef, useEffect, useState } from 'react';
2
  import {
3
  Avatar,
4
  Box,
@@ -7,8 +7,6 @@ import {
7
  IconButton,
8
  Alert,
9
  AlertTitle,
10
- Select,
11
- MenuItem,
12
  useMediaQuery,
13
  useTheme,
14
  } from '@mui/material';
@@ -50,37 +48,6 @@ export default function AppLayout() {
50
  const theme = useTheme();
51
  const isMobile = useMediaQuery(theme.breakpoints.down('md'));
52
 
53
- // ── Model selector state ──────────────────────────────────────────
54
- const [currentModel, setCurrentModel] = useState('');
55
- const [availableModels, setAvailableModels] = useState<Array<{ id: string; label: string }>>([]);
56
-
57
- useEffect(() => {
58
- // Use plain fetch (not apiFetch) — this is a public endpoint,
59
- // no auth needed, and we don't want 401 handling to trigger redirects.
60
- fetch('/api/config/model')
61
- .then((res) => (res.ok ? res.json() : null))
62
- .then((data) => {
63
- if (data) {
64
- setCurrentModel(data.current);
65
- setAvailableModels(data.available);
66
- }
67
- })
68
- .catch(() => { /* ignore */ });
69
- }, []);
70
-
71
- const handleModelChange = useCallback(async (modelId: string) => {
72
- try {
73
- const res = await apiFetch('/api/config/model', {
74
- method: 'POST',
75
- body: JSON.stringify({ model: modelId }),
76
- });
77
- if (res.ok) {
78
- setCurrentModel(modelId);
79
- logger.log('Model changed to', modelId);
80
- }
81
- } catch { /* ignore */ }
82
- }, []);
83
-
84
  const isResizing = useRef(false);
85
 
86
  const handleMouseMove = useCallback((e: MouseEvent) => {
@@ -335,41 +302,6 @@ export default function AppLayout() {
335
  </Box>
336
 
337
  <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
338
- {/* Model selector */}
339
- {availableModels.length > 0 && currentModel && (
340
- <Select
341
- value={currentModel}
342
- onChange={(e) => handleModelChange(e.target.value)}
343
- size="small"
344
- variant="outlined"
345
- renderValue={(val) => {
346
- const m = availableModels.find((x) => x.id === val);
347
- return m?.label || val;
348
- }}
349
- sx={{
350
- fontSize: '0.72rem',
351
- height: 30,
352
- minWidth: 120,
353
- color: 'var(--muted-text)',
354
- '& .MuiOutlinedInput-notchedOutline': {
355
- borderColor: 'var(--border)',
356
- },
357
- '&:hover .MuiOutlinedInput-notchedOutline': {
358
- borderColor: 'var(--border-hover)',
359
- },
360
- '& .MuiSelect-select': {
361
- py: 0.5,
362
- px: 1,
363
- },
364
- }}
365
- >
366
- {availableModels.map((m) => (
367
- <MenuItem key={m.id} value={m.id} sx={{ fontSize: '0.75rem' }}>
368
- {m.label}
369
- </MenuItem>
370
- ))}
371
- </Select>
372
- )}
373
  <IconButton
374
  onClick={toggleTheme}
375
  size="small"
 
1
+ import { useCallback, useRef, useEffect } from 'react';
2
  import {
3
  Avatar,
4
  Box,
 
7
  IconButton,
8
  Alert,
9
  AlertTitle,
 
 
10
  useMediaQuery,
11
  useTheme,
12
  } from '@mui/material';
 
48
  const theme = useTheme();
49
  const isMobile = useMediaQuery(theme.breakpoints.down('md'));
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  const isResizing = useRef(false);
52
 
53
  const handleMouseMove = useCallback((e: MouseEvent) => {
 
302
  </Box>
303
 
304
  <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  <IconButton
306
  onClick={toggleTheme}
307
  size="small"
frontend/src/hooks/useAgentWebSocket.ts CHANGED
@@ -285,6 +285,18 @@ export function useAgentWebSocket({
285
 
286
  updateTraceLog(toolCallId, toolName, updates);
287
  updateCurrentTurnTrace(sessionId);
 
 
 
 
 
 
 
 
 
 
 
 
288
  }
289
 
290
  // Don't create message bubbles for tool outputs - they only show in trace logs
 
285
 
286
  updateTraceLog(toolCallId, toolName, updates);
287
  updateCurrentTurnTrace(sessionId);
288
+
289
+ // Add output tab so the user can see results (especially errors)
290
+ setPanelTab({
291
+ id: 'output',
292
+ title: 'Output',
293
+ content: output,
294
+ language: 'markdown',
295
+ });
296
+ // Auto-switch to output tab on failure so errors are immediately visible
297
+ if (!success) {
298
+ setActivePanelTab('output');
299
+ }
300
  }
301
 
302
  // Don't create message bubbles for tool outputs - they only show in trace logs
frontend/src/store/agentStore.ts CHANGED
@@ -36,6 +36,7 @@ interface AgentStore {
36
  activePanelTab: string | null;
37
  plan: PlanItem[];
38
  currentTurnMessageId: string | null; // Track the current turn's assistant message
 
39
 
40
  // Actions
41
  addMessage: (sessionId: string, message: Message) => void;
@@ -51,6 +52,7 @@ interface AgentStore {
51
  clearTraceLogs: () => void;
52
  setPanelContent: (content: { title: string; content: string; language?: string; parameters?: Record<string, unknown> } | null) => void;
53
  setPanelTab: (tab: PanelTab) => void;
 
54
  setActivePanelTab: (tabId: string) => void;
55
  clearPanelTabs: () => void;
56
  removePanelTab: (tabId: string) => void;
@@ -58,6 +60,9 @@ interface AgentStore {
58
  setCurrentTurnMessageId: (id: string | null) => void;
59
  updateCurrentTurnTrace: (sessionId: string) => void;
60
  showToolOutput: (log: TraceLog) => void;
 
 
 
61
  /** Append a streaming delta to an existing message. */
62
  appendToMessage: (sessionId: string, messageId: string, delta: string) => void;
63
  /** Remove all messages for a session (also clears from localStorage). */
@@ -82,6 +87,7 @@ export const useAgentStore = create<AgentStore>()(
82
  activePanelTab: null,
83
  plan: [],
84
  currentTurnMessageId: null,
 
85
 
86
  addMessage: (sessionId: string, message: Message) => {
87
  set((state) => {
@@ -199,6 +205,15 @@ export const useAgentStore = create<AgentStore>()(
199
  });
200
  },
201
 
 
 
 
 
 
 
 
 
 
202
  setActivePanelTab: (tabId: string) => {
203
  set({ activePanelTab: tabId });
204
  },
@@ -238,22 +253,44 @@ export const useAgentStore = create<AgentStore>()(
238
  const latestTools = state.traceLogs.length > 0 ? [...state.traceLogs] : undefined;
239
  if (!latestTools) return;
240
 
 
 
 
241
  const updatedMessages = currentMessages.map((msg) => {
242
  if (msg.id !== state.currentTurnMessageId) return msg;
243
 
244
  const segments = msg.segments ? [...msg.segments] : [];
245
- const lastToolsIdx = segments.map((s) => s.type).lastIndexOf('tools');
246
-
247
- if (lastToolsIdx >= 0 && lastToolsIdx === segments.length - 1) {
248
- // Last segment IS a tools segment update it in place
249
- segments[lastToolsIdx] = { type: 'tools', tools: latestTools };
250
- } else if (lastToolsIdx >= 0) {
251
- // A tools segment exists but is NOT last (text came after it).
252
- // Append a NEW tools segment at the end.
253
- segments.push({ type: 'tools', tools: latestTools });
254
- } else {
255
- // No tools segment at all — create one at the end.
256
- segments.push({ type: 'tools', tools: latestTools });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  }
258
 
259
  return { ...msg, segments };
@@ -301,6 +338,20 @@ export const useAgentStore = create<AgentStore>()(
301
  });
302
  },
303
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
  appendToMessage: (sessionId: string, messageId: string, delta: string) => {
305
  set((state) => {
306
  const messages = state.messagesBySession[sessionId] || [];
 
36
  activePanelTab: string | null;
37
  plan: PlanItem[];
38
  currentTurnMessageId: string | null; // Track the current turn's assistant message
39
+ editedScripts: Record<string, string>; // tool_call_id -> edited content
40
 
41
  // Actions
42
  addMessage: (sessionId: string, message: Message) => void;
 
52
  clearTraceLogs: () => void;
53
  setPanelContent: (content: { title: string; content: string; language?: string; parameters?: Record<string, unknown> } | null) => void;
54
  setPanelTab: (tab: PanelTab) => void;
55
+ updatePanelTabContent: (tabId: string, content: string) => void;
56
  setActivePanelTab: (tabId: string) => void;
57
  clearPanelTabs: () => void;
58
  removePanelTab: (tabId: string) => void;
 
60
  setCurrentTurnMessageId: (id: string | null) => void;
61
  updateCurrentTurnTrace: (sessionId: string) => void;
62
  showToolOutput: (log: TraceLog) => void;
63
+ setEditedScript: (toolCallId: string, content: string) => void;
64
+ getEditedScript: (toolCallId: string) => string | undefined;
65
+ clearEditedScripts: () => void;
66
  /** Append a streaming delta to an existing message. */
67
  appendToMessage: (sessionId: string, messageId: string, delta: string) => void;
68
  /** Remove all messages for a session (also clears from localStorage). */
 
87
  activePanelTab: null,
88
  plan: [],
89
  currentTurnMessageId: null,
90
+ editedScripts: {},
91
 
92
  addMessage: (sessionId: string, message: Message) => {
93
  set((state) => {
 
205
  });
206
  },
207
 
208
+ updatePanelTabContent: (tabId: string, content: string) => {
209
+ set((state) => {
210
+ const newTabs = state.panelTabs.map(tab =>
211
+ tab.id === tabId ? { ...tab, content } : tab
212
+ );
213
+ return { panelTabs: newTabs };
214
+ });
215
+ },
216
+
217
  setActivePanelTab: (tabId: string) => {
218
  set({ activePanelTab: tabId });
219
  },
 
253
  const latestTools = state.traceLogs.length > 0 ? [...state.traceLogs] : undefined;
254
  if (!latestTools) return;
255
 
256
+ // Build a lookup of the latest state for each tool by id
257
+ const toolById = new Map(latestTools.map(t => [t.id, t]));
258
+
259
  const updatedMessages = currentMessages.map((msg) => {
260
  if (msg.id !== state.currentTurnMessageId) return msg;
261
 
262
  const segments = msg.segments ? [...msg.segments] : [];
263
+
264
+ // First pass: update existing tools in their original segments
265
+ const placedToolIds = new Set<string>();
266
+ for (let i = 0; i < segments.length; i++) {
267
+ if (segments[i].type === 'tools' && segments[i].tools) {
268
+ segments[i] = {
269
+ ...segments[i],
270
+ tools: segments[i].tools!.map(t => {
271
+ placedToolIds.add(t.id);
272
+ return toolById.get(t.id) || t;
273
+ }),
274
+ };
275
+ }
276
+ }
277
+
278
+ // Collect only genuinely new tools (not yet in any segment)
279
+ const newTools = latestTools.filter(t => !placedToolIds.has(t.id));
280
+
281
+ if (newTools.length > 0) {
282
+ const lastToolsIdx = segments.map((s) => s.type).lastIndexOf('tools');
283
+
284
+ if (lastToolsIdx >= 0 && lastToolsIdx === segments.length - 1) {
285
+ // Last segment is tools — append new tools to it
286
+ segments[lastToolsIdx] = {
287
+ ...segments[lastToolsIdx],
288
+ tools: [...(segments[lastToolsIdx].tools || []), ...newTools],
289
+ };
290
+ } else {
291
+ // Text came after previous tools — create a new segment with only new tools
292
+ segments.push({ type: 'tools', tools: newTools });
293
+ }
294
  }
295
 
296
  return { ...msg, segments };
 
338
  });
339
  },
340
 
341
+ setEditedScript: (toolCallId: string, content: string) => {
342
+ set((state) => ({
343
+ editedScripts: { ...state.editedScripts, [toolCallId]: content },
344
+ }));
345
+ },
346
+
347
+ getEditedScript: (toolCallId: string) => {
348
+ return get().editedScripts[toolCallId];
349
+ },
350
+
351
+ clearEditedScripts: () => {
352
+ set({ editedScripts: {} });
353
+ },
354
+
355
  appendToMessage: (sessionId: string, messageId: string, delta: string) => {
356
  set((state) => {
357
  const messages = state.messagesBySession[sessionId] || [];
frontend/src/theme.ts CHANGED
@@ -176,8 +176,8 @@ function makeTextField() {
176
  export const darkTheme = createTheme({
177
  palette: {
178
  mode: 'dark',
179
- primary: { main: '#FF9D00' },
180
- secondary: { main: '#C7A500' },
181
  background: { default: '#0B0D10', paper: '#0F1316' },
182
  text: { primary: '#E6EEF8', secondary: '#98A0AA' },
183
  divider: 'rgba(255,255,255,0.03)',
@@ -199,8 +199,8 @@ export const darkTheme = createTheme({
199
  export const lightTheme = createTheme({
200
  palette: {
201
  mode: 'light',
202
- primary: { main: '#FF9D00' },
203
- secondary: { main: '#B8960A' },
204
  background: { default: '#FFFFFF', paper: '#F7F8FA' },
205
  text: { primary: '#1A1A2E', secondary: '#6B7280' },
206
  divider: 'rgba(0,0,0,0.08)',
 
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)',
 
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)',