Spaces:
Running
ui updates (#1)
Browse files- Move model selector below chat input (9615e373f7cc0c8a2d7dbe1f241d42c18133c14d)
- inference token (72bac94ad9ae98213038d167ceca51573beb3493)
- fixing inference token (c53966b567f0b73f3e7b95a6aa2779dbd22765c9)
- debug (8601e9a5e420f759c29b85914ee055799f25b5e4)
- inference token (7e025045432d83571de5dd74aee3838072224955)
- removed avatars and made ui improvements (fe018ceb16f0a3b41d72fae38735a00d03b203c6)
- welcome message change (3cb2d2ee2bf5704da3a28509d88bf7620accfca9)
- font update (92ce85598106e114597d26d655f48fc39e95696e)
- removing avatars (0cc4d1b77e6c6238d3aa550e62fc309be2010728)
- theme edit and tool display fix (28267586971e511849386091fb1474ce1f065c59)
- namespace fix (a8af09b848935ae985177fcab67ad1a6911735c5)
- hf job output (de476e870b3aec6ca51882263dc7404c26fb3950)
- acess to ml-agent-explorers (c4c961e6a983ba2092b33bca68fc78ad1b12a8bf)
- d (ac2277ddae8a8eef61c0130518de82802b885fa5)
- auth scopes (154b509ce3887c3e5762ca4c37c04f4bc7040a13)
- fixing wrong token loading (01cc20c6bd5e81d949e7345f49d5b3eb6d62fe2c)
- README.md +2 -0
- agent/context_manager/manager.py +17 -5
- agent/core/agent_loop.py +36 -18
- agent/core/session.py +8 -1
- agent/tools/jobs_tool.py +2 -1
- backend/main.py +4 -0
- backend/routes/agent.py +65 -24
- backend/routes/auth.py +5 -3
- frontend/src/components/Chat/AssistantMessage.tsx +39 -54
- frontend/src/components/Chat/ChatInput.tsx +196 -2
- frontend/src/components/Chat/MarkdownContent.tsx +0 -21
- frontend/src/components/Chat/MessageList.tsx +43 -66
- frontend/src/components/Chat/ThinkingIndicator.tsx +36 -51
- frontend/src/components/Chat/ToolCallGroup.tsx +10 -48
- frontend/src/components/Chat/UserMessage.tsx +1 -15
- frontend/src/components/CodePanel/CodePanel.tsx +184 -6
- frontend/src/components/Layout/AppLayout.tsx +1 -69
- frontend/src/hooks/useAgentWebSocket.ts +12 -0
- frontend/src/store/agentStore.ts +63 -12
- frontend/src/theme.ts +4 -4
|
@@ -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
|
|
@@ -47,9 +47,13 @@ def _get_hf_username() -> str:
|
|
| 47 |
try:
|
| 48 |
result = subprocess.run(
|
| 49 |
[
|
| 50 |
-
"curl",
|
| 51 |
-
"-
|
| 52 |
-
"-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 64 |
else:
|
| 65 |
-
logger.warning(
|
|
|
|
|
|
|
| 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
|
|
@@ -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,
|
| 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(
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 185 |
-
|
| 186 |
if tc_delta.function.arguments:
|
| 187 |
-
tool_calls_acc[idx]["function"][
|
| 188 |
-
|
| 189 |
-
|
| 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 |
|
|
@@ -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(
|
|
|
|
|
|
|
| 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}")
|
|
@@ -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,
|
|
@@ -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"]
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
error_type = "auth"
|
| 75 |
-
elif
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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(
|
|
|
|
|
|
|
| 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 |
-
|
| 344 |
-
|
| 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 |
|
|
@@ -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 |
|
|
@@ -1,5 +1,4 @@
|
|
| 1 |
-
import { Box, Stack,
|
| 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 |
-
<
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
<
|
| 67 |
-
|
| 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 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
borderTopLeftRadius: 4,
|
| 98 |
-
px: { xs: 1.5, md: 2.5 },
|
| 99 |
-
py: 1.5,
|
| 100 |
-
border: '1px solid var(--border)',
|
| 101 |
}}
|
| 102 |
>
|
| 103 |
-
{
|
| 104 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
</Box>
|
| 106 |
-
</
|
| 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 |
}
|
|
@@ -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
|
| 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 |
);
|
|
@@ -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 |
}
|
|
@@ -1,9 +1,7 @@
|
|
| 1 |
import { useEffect, useRef, useMemo, useCallback } from 'react';
|
| 2 |
-
import { Box, Stack, Typography
|
| 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 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
/** Static welcome message rendered when the conversation is empty. */
|
| 33 |
-
function WelcomeMessage() {
|
| 34 |
return (
|
| 35 |
-
<
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
sx={{
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
mt: 0.5,
|
| 43 |
}}
|
| 44 |
>
|
| 45 |
-
|
| 46 |
-
</
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 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 |
-
{
|
| 174 |
-
|
| 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}
|
|
@@ -1,63 +1,48 @@
|
|
| 1 |
-
import { Box,
|
| 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 |
-
<
|
| 8 |
-
<
|
|
|
|
| 9 |
sx={{
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
| 15 |
}}
|
| 16 |
>
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
<Box sx={{ pt: 0.75 }}>
|
| 21 |
-
<Typography
|
| 22 |
-
variant="caption"
|
| 23 |
sx={{
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
}}
|
| 33 |
>
|
| 34 |
-
|
| 35 |
-
<
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 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 |
}
|
|
@@ -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 |
-
|
|
|
|
| 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}
|
|
@@ -1,5 +1,4 @@
|
|
| 1 |
-
import { Box, Stack, Typography,
|
| 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 |
}
|
|
@@ -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 |
-
<
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ─────────────────────────────────── */}
|
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useCallback, useRef, useEffect
|
| 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"
|
|
@@ -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
|
|
@@ -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 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
segments[
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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] || [];
|
|
@@ -176,8 +176,8 @@ function makeTextField() {
|
|
| 176 |
export const darkTheme = createTheme({
|
| 177 |
palette: {
|
| 178 |
mode: 'dark',
|
| 179 |
-
primary: { main: '#FF9D00' },
|
| 180 |
-
secondary: { main: '#
|
| 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: '#
|
| 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)',
|