Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Cherry-picked today's changes from HF Space development:
Browse files- agent/context_manager/manager.py +19 -0
- agent/prompts/system_prompt_v3.yaml +6 -0
- agent/tools/papers_tool.py +498 -23
- agent/tools/research_tool.py +35 -6
- agent/tools/sandbox_tool.py +8 -3
- backend/main.py +2 -1
- backend/models.py +6 -0
- backend/routes/agent.py +17 -1
- backend/routes/auth.py +18 -1
- backend/session_manager.py +8 -0
- frontend/package-lock.json +0 -15
- frontend/src/components/Chat/ActivityStatusBar.tsx +81 -1
- frontend/src/components/Chat/ChatInput.tsx +9 -7
- frontend/src/components/Chat/MessageBubble.tsx +3 -0
- frontend/src/components/Chat/MessageList.tsx +3 -1
- frontend/src/components/Chat/ToolCallGroup.tsx +335 -66
- frontend/src/components/Chat/UserMessage.tsx +150 -36
- frontend/src/components/SessionChat.tsx +9 -13
- frontend/src/components/SessionSidebar/SessionSidebar.tsx +133 -11
- frontend/src/components/WelcomeScreen/WelcomeScreen.tsx +329 -189
- frontend/src/hooks/useAgentChat.ts +117 -17
- frontend/src/hooks/useOrgMembership.ts +45 -0
- frontend/src/lib/chat-message-store.ts +2 -1
- frontend/src/lib/convert-llm-messages.ts +21 -4
- frontend/src/lib/research-store.ts +56 -0
- frontend/src/lib/sse-chat-transport.ts +2 -1
- frontend/src/store/agentStore.ts +101 -3
- frontend/src/types/agent.ts +1 -0
- uv.lock +0 -0
agent/context_manager/manager.py
CHANGED
|
@@ -243,6 +243,25 @@ class ContextManager:
|
|
| 243 |
|
| 244 |
return False
|
| 245 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
async def compact(
|
| 247 |
self, model_name: str, tool_specs: list[dict] | None = None
|
| 248 |
) -> None:
|
|
|
|
| 243 |
|
| 244 |
return False
|
| 245 |
|
| 246 |
+
def truncate_to_user_message(self, user_message_index: int) -> bool:
|
| 247 |
+
"""Truncate history to just before the Nth user message (0-indexed).
|
| 248 |
+
|
| 249 |
+
Removes that user message and everything after it.
|
| 250 |
+
System message (index 0) is never removed.
|
| 251 |
+
|
| 252 |
+
Returns True if the target user message was found and removed.
|
| 253 |
+
"""
|
| 254 |
+
count = 0
|
| 255 |
+
for i, msg in enumerate(self.items):
|
| 256 |
+
if i == 0:
|
| 257 |
+
continue # skip system message
|
| 258 |
+
if getattr(msg, "role", None) == "user":
|
| 259 |
+
if count == user_message_index:
|
| 260 |
+
self.items = self.items[:i]
|
| 261 |
+
return True
|
| 262 |
+
count += 1
|
| 263 |
+
return False
|
| 264 |
+
|
| 265 |
async def compact(
|
| 266 |
self, model_name: str, tool_specs: list[dict] | None = None
|
| 267 |
) -> None:
|
agent/prompts/system_prompt_v3.yaml
CHANGED
|
@@ -15,6 +15,12 @@ system_prompt: |
|
|
| 15 |
|
| 16 |
The sub-agent knows how to use github_find_examples, github_read_file, explore_hf_docs, fetch_hf_docs, hf_inspect_dataset, and hf_papers. Be specific in your task description.
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
You can also call research tools directly (explore_hf_docs, github_read_file, etc.) for quick lookups.
|
| 19 |
|
| 20 |
Skip research only for trivial non-code operations.
|
|
|
|
| 15 |
|
| 16 |
The sub-agent knows how to use github_find_examples, github_read_file, explore_hf_docs, fetch_hf_docs, hf_inspect_dataset, and hf_papers. Be specific in your task description.
|
| 17 |
|
| 18 |
+
When researching an ML task, include a SOTA check: tell the research sub-agent to search for recent papers on the task or technique to find what approaches, architectures, and hyperparameters are currently achieving the best results. This prevents you from using outdated methods when better ones exist.
|
| 19 |
+
|
| 20 |
+
```
|
| 21 |
+
research({"task": "Find SOTA approaches for [task]. Search recent papers for best-performing methods, key hyperparameters, and tricks. Also find working code examples using current TRL/Transformers APIs.", "context": "User wants to [goal]."})
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
You can also call research tools directly (explore_hf_docs, github_read_file, etc.) for quick lookups.
|
| 25 |
|
| 26 |
Skip research only for trivial non-code operations.
|
agent/tools/papers_tool.py
CHANGED
|
@@ -2,11 +2,14 @@
|
|
| 2 |
HF Papers Tool β Discover papers, read their contents, and find linked resources.
|
| 3 |
|
| 4 |
Operations: trending, search, paper_details, read_paper,
|
| 5 |
-
find_datasets, find_models, find_collections, find_all_resources
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
import asyncio
|
|
|
|
| 9 |
import re
|
|
|
|
| 10 |
from typing import Any
|
| 11 |
|
| 12 |
import httpx
|
|
@@ -30,6 +33,101 @@ SORT_MAP = {
|
|
| 30 |
"trending": "trendingScore",
|
| 31 |
}
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
# ---------------------------------------------------------------------------
|
| 35 |
# HTML paper parsing
|
|
@@ -193,7 +291,7 @@ def _format_paper_list(
|
|
| 193 |
return "\n".join(lines)
|
| 194 |
|
| 195 |
|
| 196 |
-
def _format_paper_detail(paper: dict) -> str:
|
| 197 |
arxiv_id = paper.get("id", "")
|
| 198 |
title = paper.get("title", "Unknown")
|
| 199 |
upvotes = paper.get("upvotes", 0)
|
|
@@ -205,7 +303,12 @@ def _format_paper_detail(paper: dict) -> str:
|
|
| 205 |
authors = paper.get("authors") or []
|
| 206 |
|
| 207 |
lines = [f"# {title}"]
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
lines.append(f"https://huggingface.co/papers/{arxiv_id}")
|
| 210 |
lines.append(f"https://arxiv.org/abs/{arxiv_id}")
|
| 211 |
|
|
@@ -218,16 +321,27 @@ def _format_paper_detail(paper: dict) -> str:
|
|
| 218 |
|
| 219 |
if keywords:
|
| 220 |
lines.append(f"**Keywords:** {', '.join(keywords)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
if github:
|
| 222 |
lines.append(f"**GitHub:** {github} ({stars} stars)")
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
if ai_summary:
|
| 225 |
lines.append(f"\n## AI Summary\n{ai_summary}")
|
| 226 |
if summary:
|
| 227 |
lines.append(f"\n## Abstract\n{_truncate(summary, 500)}")
|
| 228 |
|
| 229 |
lines.append(
|
| 230 |
-
"\n**Next:** Use read_paper to read specific sections,
|
|
|
|
| 231 |
)
|
| 232 |
return "\n".join(lines)
|
| 233 |
|
|
@@ -441,11 +555,101 @@ async def _op_trending(args: dict[str, Any], limit: int) -> ToolResult:
|
|
| 441 |
}
|
| 442 |
|
| 443 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
async def _op_search(args: dict[str, Any], limit: int) -> ToolResult:
|
| 445 |
query = args.get("query")
|
| 446 |
if not query:
|
| 447 |
return _error("'query' is required for search operation.")
|
| 448 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
async with httpx.AsyncClient(timeout=15) as client:
|
| 450 |
resp = await client.get(
|
| 451 |
f"{HF_API}/papers/search", params={"q": query, "limit": limit}
|
|
@@ -545,6 +749,108 @@ async def _op_read_paper(args: dict[str, Any], limit: int) -> ToolResult:
|
|
| 545 |
return {"formatted": formatted, "totalResults": 1, "resultsShared": 1}
|
| 546 |
|
| 547 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
async def _op_find_datasets(args: dict[str, Any], limit: int) -> ToolResult:
|
| 549 |
arxiv_id = _validate_arxiv_id(args)
|
| 550 |
if not arxiv_id:
|
|
@@ -703,6 +1009,136 @@ async def _op_find_all_resources(args: dict[str, Any], limit: int) -> ToolResult
|
|
| 703 |
return {"formatted": formatted, "totalResults": total, "resultsShared": total}
|
| 704 |
|
| 705 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 706 |
# ---------------------------------------------------------------------------
|
| 707 |
# Operation dispatch
|
| 708 |
# ---------------------------------------------------------------------------
|
|
@@ -712,6 +1148,9 @@ _OPERATIONS = {
|
|
| 712 |
"search": _op_search,
|
| 713 |
"paper_details": _op_paper_details,
|
| 714 |
"read_paper": _op_read_paper,
|
|
|
|
|
|
|
|
|
|
| 715 |
"find_datasets": _op_find_datasets,
|
| 716 |
"find_models": _op_find_models,
|
| 717 |
"find_collections": _op_find_collections,
|
|
@@ -726,22 +1165,25 @@ _OPERATIONS = {
|
|
| 726 |
HF_PAPERS_TOOL_SPEC = {
|
| 727 |
"name": "hf_papers",
|
| 728 |
"description": (
|
| 729 |
-
"Discover ML research papers,
|
| 730 |
-
"and
|
| 731 |
-
"
|
| 732 |
-
"
|
| 733 |
-
"
|
| 734 |
-
"
|
|
|
|
| 735 |
"Operations:\n"
|
| 736 |
"- trending: Get trending daily papers, optionally filter by topic keyword\n"
|
| 737 |
-
"- search:
|
| 738 |
-
"- paper_details:
|
| 739 |
-
"- read_paper: Read paper contents β without section:
|
| 740 |
-
"
|
|
|
|
|
|
|
| 741 |
"- find_datasets: Find datasets linked to a paper\n"
|
| 742 |
"- find_models: Find models linked to a paper\n"
|
| 743 |
"- find_collections: Find collections that include a paper\n"
|
| 744 |
-
"- find_all_resources: Parallel fetch of datasets + models + collections for a paper
|
| 745 |
),
|
| 746 |
"parameters": {
|
| 747 |
"type": "object",
|
|
@@ -754,36 +1196,69 @@ HF_PAPERS_TOOL_SPEC = {
|
|
| 754 |
"query": {
|
| 755 |
"type": "string",
|
| 756 |
"description": (
|
| 757 |
-
"Search query. Required for: search. "
|
| 758 |
-
"Optional for: trending (filters
|
|
|
|
| 759 |
),
|
| 760 |
},
|
| 761 |
"arxiv_id": {
|
| 762 |
"type": "string",
|
| 763 |
"description": (
|
| 764 |
"ArXiv paper ID (e.g. '2305.18290'). "
|
| 765 |
-
"Required for: paper_details, read_paper, find_datasets, find_models, find_collections, find_all_resources. "
|
| 766 |
-
"Get IDs from
|
| 767 |
),
|
| 768 |
},
|
| 769 |
"section": {
|
| 770 |
"type": "string",
|
| 771 |
"description": (
|
| 772 |
"Section name or number to read (e.g. '3', 'Experiments', '4.2'). "
|
| 773 |
-
"Optional for: read_paper. Without this,
|
| 774 |
-
"so you can choose which section to read."
|
| 775 |
),
|
| 776 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 777 |
"date": {
|
| 778 |
"type": "string",
|
| 779 |
"description": "Date in YYYY-MM-DD format. Optional for: trending (defaults to recent papers).",
|
| 780 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 781 |
"sort": {
|
| 782 |
"type": "string",
|
| 783 |
"enum": ["downloads", "likes", "trending"],
|
| 784 |
"description": (
|
| 785 |
-
"Sort order for find_datasets and find_models. Default: downloads.
|
| 786 |
-
"Use 'downloads' for most-used, 'likes' for community favorites, 'trending' for recently popular."
|
| 787 |
),
|
| 788 |
},
|
| 789 |
"limit": {
|
|
|
|
| 2 |
HF Papers Tool β Discover papers, read their contents, and find linked resources.
|
| 3 |
|
| 4 |
Operations: trending, search, paper_details, read_paper,
|
| 5 |
+
find_datasets, find_models, find_collections, find_all_resources,
|
| 6 |
+
citation_graph, snippet_search, recommend
|
| 7 |
"""
|
| 8 |
|
| 9 |
import asyncio
|
| 10 |
+
import os
|
| 11 |
import re
|
| 12 |
+
import time
|
| 13 |
from typing import Any
|
| 14 |
|
| 15 |
import httpx
|
|
|
|
| 33 |
"trending": "trendingScore",
|
| 34 |
}
|
| 35 |
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
# Semantic Scholar API
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
|
| 40 |
+
S2_API = "https://api.semanticscholar.org"
|
| 41 |
+
S2_API_KEY = os.environ.get("S2_API_KEY")
|
| 42 |
+
S2_HEADERS: dict[str, str] = {"x-api-key": S2_API_KEY} if S2_API_KEY else {}
|
| 43 |
+
S2_TIMEOUT = 12
|
| 44 |
+
_s2_last_request: float = 0.0
|
| 45 |
+
|
| 46 |
+
# Shared response cache (survives across sessions, keyed by (path, params_tuple))
|
| 47 |
+
_s2_cache: dict[str, Any] = {}
|
| 48 |
+
_S2_CACHE_MAX = 500
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _s2_paper_id(arxiv_id: str) -> str:
|
| 52 |
+
"""Convert bare arxiv ID to S2 format."""
|
| 53 |
+
return f"ARXIV:{arxiv_id}"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _s2_cache_key(path: str, params: dict | None) -> str:
|
| 57 |
+
"""Build a hashable cache key from path + sorted params."""
|
| 58 |
+
p = tuple(sorted((params or {}).items()))
|
| 59 |
+
return f"{path}:{p}"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
async def _s2_request(
|
| 63 |
+
client: httpx.AsyncClient,
|
| 64 |
+
method: str,
|
| 65 |
+
path: str,
|
| 66 |
+
**kwargs: Any,
|
| 67 |
+
) -> httpx.Response | None:
|
| 68 |
+
"""S2 request with 2 retries on 429/5xx. Rate-limited only when using API key."""
|
| 69 |
+
global _s2_last_request
|
| 70 |
+
url = f"{S2_API}{path}"
|
| 71 |
+
kwargs.setdefault("headers", {}).update(S2_HEADERS)
|
| 72 |
+
kwargs.setdefault("timeout", S2_TIMEOUT)
|
| 73 |
+
|
| 74 |
+
for attempt in range(3):
|
| 75 |
+
# Rate limit only when authenticated (1 req/s for search, 10 req/s for others)
|
| 76 |
+
if S2_API_KEY:
|
| 77 |
+
min_interval = 1.0 if "search" in path else 0.1
|
| 78 |
+
elapsed = time.monotonic() - _s2_last_request
|
| 79 |
+
if elapsed < min_interval:
|
| 80 |
+
await asyncio.sleep(min_interval - elapsed)
|
| 81 |
+
_s2_last_request = time.monotonic()
|
| 82 |
+
|
| 83 |
+
try:
|
| 84 |
+
resp = await client.request(method, url, **kwargs)
|
| 85 |
+
if resp.status_code == 429:
|
| 86 |
+
if attempt < 2:
|
| 87 |
+
await asyncio.sleep(60)
|
| 88 |
+
continue
|
| 89 |
+
return None
|
| 90 |
+
if resp.status_code >= 500:
|
| 91 |
+
if attempt < 2:
|
| 92 |
+
await asyncio.sleep(3)
|
| 93 |
+
continue
|
| 94 |
+
return None
|
| 95 |
+
return resp
|
| 96 |
+
except (httpx.RequestError, httpx.HTTPStatusError):
|
| 97 |
+
if attempt < 2:
|
| 98 |
+
await asyncio.sleep(3)
|
| 99 |
+
continue
|
| 100 |
+
return None
|
| 101 |
+
return None
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
async def _s2_get_json(
|
| 105 |
+
client: httpx.AsyncClient, path: str, params: dict | None = None,
|
| 106 |
+
) -> dict | None:
|
| 107 |
+
"""Cached S2 GET returning parsed JSON or None."""
|
| 108 |
+
key = _s2_cache_key(path, params)
|
| 109 |
+
if key in _s2_cache:
|
| 110 |
+
return _s2_cache[key]
|
| 111 |
+
|
| 112 |
+
resp = await _s2_request(client, "GET", path, params=params or {})
|
| 113 |
+
if resp and resp.status_code == 200:
|
| 114 |
+
data = resp.json()
|
| 115 |
+
if len(_s2_cache) < _S2_CACHE_MAX:
|
| 116 |
+
_s2_cache[key] = data
|
| 117 |
+
return data
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
async def _s2_get_paper(
|
| 122 |
+
client: httpx.AsyncClient, arxiv_id: str, fields: str,
|
| 123 |
+
) -> dict | None:
|
| 124 |
+
"""Fetch a single paper from S2 by arxiv ID. Returns None on failure."""
|
| 125 |
+
return await _s2_get_json(
|
| 126 |
+
client,
|
| 127 |
+
f"/graph/v1/paper/{_s2_paper_id(arxiv_id)}",
|
| 128 |
+
{"fields": fields},
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
|
| 132 |
# ---------------------------------------------------------------------------
|
| 133 |
# HTML paper parsing
|
|
|
|
| 291 |
return "\n".join(lines)
|
| 292 |
|
| 293 |
|
| 294 |
+
def _format_paper_detail(paper: dict, s2_data: dict | None = None) -> str:
|
| 295 |
arxiv_id = paper.get("id", "")
|
| 296 |
title = paper.get("title", "Unknown")
|
| 297 |
upvotes = paper.get("upvotes", 0)
|
|
|
|
| 303 |
authors = paper.get("authors") or []
|
| 304 |
|
| 305 |
lines = [f"# {title}"]
|
| 306 |
+
meta_parts = [f"**arxiv_id:** {arxiv_id}", f"**upvotes:** {upvotes}"]
|
| 307 |
+
if s2_data:
|
| 308 |
+
cites = s2_data.get("citationCount", 0)
|
| 309 |
+
influential = s2_data.get("influentialCitationCount", 0)
|
| 310 |
+
meta_parts.append(f"**citations:** {cites} ({influential} influential)")
|
| 311 |
+
lines.append(" | ".join(meta_parts))
|
| 312 |
lines.append(f"https://huggingface.co/papers/{arxiv_id}")
|
| 313 |
lines.append(f"https://arxiv.org/abs/{arxiv_id}")
|
| 314 |
|
|
|
|
| 321 |
|
| 322 |
if keywords:
|
| 323 |
lines.append(f"**Keywords:** {', '.join(keywords)}")
|
| 324 |
+
if s2_data and s2_data.get("s2FieldsOfStudy"):
|
| 325 |
+
fields = [f["category"] for f in s2_data["s2FieldsOfStudy"] if f.get("category")]
|
| 326 |
+
if fields:
|
| 327 |
+
lines.append(f"**Fields:** {', '.join(fields)}")
|
| 328 |
+
if s2_data and s2_data.get("venue"):
|
| 329 |
+
lines.append(f"**Venue:** {s2_data['venue']}")
|
| 330 |
if github:
|
| 331 |
lines.append(f"**GitHub:** {github} ({stars} stars)")
|
| 332 |
|
| 333 |
+
if s2_data and s2_data.get("tldr"):
|
| 334 |
+
tldr_text = s2_data["tldr"].get("text", "")
|
| 335 |
+
if tldr_text:
|
| 336 |
+
lines.append(f"\n## TL;DR\n{tldr_text}")
|
| 337 |
if ai_summary:
|
| 338 |
lines.append(f"\n## AI Summary\n{ai_summary}")
|
| 339 |
if summary:
|
| 340 |
lines.append(f"\n## Abstract\n{_truncate(summary, 500)}")
|
| 341 |
|
| 342 |
lines.append(
|
| 343 |
+
"\n**Next:** Use read_paper to read specific sections, find_all_resources for linked datasets/models, "
|
| 344 |
+
"or citation_graph to trace references and citations."
|
| 345 |
)
|
| 346 |
return "\n".join(lines)
|
| 347 |
|
|
|
|
| 555 |
}
|
| 556 |
|
| 557 |
|
| 558 |
+
def _format_s2_paper_list(papers: list[dict], title: str) -> str:
|
| 559 |
+
"""Format a list of S2 paper results."""
|
| 560 |
+
lines = [f"# {title}"]
|
| 561 |
+
lines.append(f"Showing {len(papers)} result(s)\n")
|
| 562 |
+
|
| 563 |
+
for i, paper in enumerate(papers, 1):
|
| 564 |
+
ptitle = paper.get("title") or "(untitled)"
|
| 565 |
+
year = paper.get("year") or "?"
|
| 566 |
+
cites = paper.get("citationCount", 0)
|
| 567 |
+
venue = paper.get("venue") or ""
|
| 568 |
+
ext_ids = paper.get("externalIds") or {}
|
| 569 |
+
aid = ext_ids.get("ArXiv", "")
|
| 570 |
+
tldr = (paper.get("tldr") or {}).get("text", "")
|
| 571 |
+
|
| 572 |
+
lines.append(f"### {i}. {ptitle}")
|
| 573 |
+
meta = [f"Year: {year}", f"Citations: {cites}"]
|
| 574 |
+
if venue:
|
| 575 |
+
meta.append(f"Venue: {venue}")
|
| 576 |
+
if aid:
|
| 577 |
+
meta.append(f"arxiv_id: {aid}")
|
| 578 |
+
lines.append(" | ".join(meta))
|
| 579 |
+
if aid:
|
| 580 |
+
lines.append(f"https://arxiv.org/abs/{aid}")
|
| 581 |
+
if tldr:
|
| 582 |
+
lines.append(f"**TL;DR:** {tldr}")
|
| 583 |
+
lines.append("")
|
| 584 |
+
|
| 585 |
+
lines.append("Use paper_details with arxiv_id for full info, or read_paper to read sections.")
|
| 586 |
+
return "\n".join(lines)
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
async def _s2_bulk_search(query: str, args: dict[str, Any], limit: int) -> ToolResult | None:
|
| 590 |
+
"""Search via S2 bulk endpoint with filters. Returns None on failure."""
|
| 591 |
+
params: dict[str, Any] = {
|
| 592 |
+
"query": query,
|
| 593 |
+
"limit": limit,
|
| 594 |
+
"fields": "title,externalIds,year,citationCount,tldr,venue,publicationDate",
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
# Date filter
|
| 598 |
+
date_from = args.get("date_from", "")
|
| 599 |
+
date_to = args.get("date_to", "")
|
| 600 |
+
if date_from or date_to:
|
| 601 |
+
params["publicationDateOrYear"] = f"{date_from}:{date_to}"
|
| 602 |
+
|
| 603 |
+
# Fields of study
|
| 604 |
+
categories = args.get("categories")
|
| 605 |
+
if categories:
|
| 606 |
+
params["fieldsOfStudy"] = categories
|
| 607 |
+
|
| 608 |
+
# Min citations
|
| 609 |
+
min_cites = args.get("min_citations")
|
| 610 |
+
if min_cites:
|
| 611 |
+
params["minCitationCount"] = str(min_cites)
|
| 612 |
+
|
| 613 |
+
# Sort
|
| 614 |
+
sort_by = args.get("sort_by")
|
| 615 |
+
if sort_by and sort_by != "relevance":
|
| 616 |
+
params["sort"] = f"{sort_by}:desc"
|
| 617 |
+
|
| 618 |
+
async with httpx.AsyncClient(timeout=15) as client:
|
| 619 |
+
resp = await _s2_request(client, "GET", "/graph/v1/paper/search/bulk", params=params)
|
| 620 |
+
if not resp or resp.status_code != 200:
|
| 621 |
+
return None
|
| 622 |
+
data = resp.json()
|
| 623 |
+
|
| 624 |
+
papers = data.get("data") or []
|
| 625 |
+
if not papers:
|
| 626 |
+
return {
|
| 627 |
+
"formatted": f"No papers found for '{query}' with the given filters.",
|
| 628 |
+
"totalResults": 0,
|
| 629 |
+
"resultsShared": 0,
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
formatted = _format_s2_paper_list(papers[:limit], f"Papers matching '{query}' (Semantic Scholar)")
|
| 633 |
+
return {
|
| 634 |
+
"formatted": formatted,
|
| 635 |
+
"totalResults": data.get("total", len(papers)),
|
| 636 |
+
"resultsShared": min(limit, len(papers)),
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
|
| 640 |
async def _op_search(args: dict[str, Any], limit: int) -> ToolResult:
|
| 641 |
query = args.get("query")
|
| 642 |
if not query:
|
| 643 |
return _error("'query' is required for search operation.")
|
| 644 |
|
| 645 |
+
# Route to S2 when filters are present
|
| 646 |
+
use_s2 = any(args.get(k) for k in ("date_from", "date_to", "categories", "min_citations", "sort_by"))
|
| 647 |
+
if use_s2:
|
| 648 |
+
result = await _s2_bulk_search(query, args, limit)
|
| 649 |
+
if result is not None:
|
| 650 |
+
return result
|
| 651 |
+
# Fall back to HF search (without filters) if S2 fails
|
| 652 |
+
|
| 653 |
async with httpx.AsyncClient(timeout=15) as client:
|
| 654 |
resp = await client.get(
|
| 655 |
f"{HF_API}/papers/search", params={"q": query, "limit": limit}
|
|
|
|
| 749 |
return {"formatted": formatted, "totalResults": 1, "resultsShared": 1}
|
| 750 |
|
| 751 |
|
| 752 |
+
# ---------------------------------------------------------------------------
|
| 753 |
+
# Citation graph (Semantic Scholar)
|
| 754 |
+
# ---------------------------------------------------------------------------
|
| 755 |
+
|
| 756 |
+
|
| 757 |
+
def _format_citation_entry(entry: dict, show_context: bool = False) -> str:
|
| 758 |
+
"""Format a single citation/reference entry."""
|
| 759 |
+
paper = entry.get("citingPaper") or entry.get("citedPaper") or {}
|
| 760 |
+
title = paper.get("title") or "(untitled)"
|
| 761 |
+
year = paper.get("year") or "?"
|
| 762 |
+
cites = paper.get("citationCount", 0)
|
| 763 |
+
ext_ids = paper.get("externalIds") or {}
|
| 764 |
+
aid = ext_ids.get("ArXiv", "")
|
| 765 |
+
influential = " **[influential]**" if entry.get("isInfluential") else ""
|
| 766 |
+
|
| 767 |
+
parts = [f"- **{title}** ({year}, {cites} cites){influential}"]
|
| 768 |
+
if aid:
|
| 769 |
+
parts[0] += f" arxiv:{aid}"
|
| 770 |
+
|
| 771 |
+
if show_context:
|
| 772 |
+
intents = entry.get("intents") or []
|
| 773 |
+
if intents:
|
| 774 |
+
parts.append(f" Intent: {', '.join(intents)}")
|
| 775 |
+
contexts = entry.get("contexts") or []
|
| 776 |
+
for ctx in contexts[:2]:
|
| 777 |
+
if ctx:
|
| 778 |
+
parts.append(f" > {_truncate(ctx, 200)}")
|
| 779 |
+
|
| 780 |
+
return "\n".join(parts)
|
| 781 |
+
|
| 782 |
+
|
| 783 |
+
def _format_citation_graph(
|
| 784 |
+
arxiv_id: str,
|
| 785 |
+
references: list[dict] | None,
|
| 786 |
+
citations: list[dict] | None,
|
| 787 |
+
) -> str:
|
| 788 |
+
lines = [f"# Citation Graph for {arxiv_id}"]
|
| 789 |
+
lines.append(f"https://arxiv.org/abs/{arxiv_id}\n")
|
| 790 |
+
|
| 791 |
+
if references is not None:
|
| 792 |
+
lines.append(f"## References ({len(references)})")
|
| 793 |
+
if references:
|
| 794 |
+
for entry in references:
|
| 795 |
+
lines.append(_format_citation_entry(entry))
|
| 796 |
+
else:
|
| 797 |
+
lines.append("No references found.")
|
| 798 |
+
lines.append("")
|
| 799 |
+
|
| 800 |
+
if citations is not None:
|
| 801 |
+
lines.append(f"## Citations ({len(citations)})")
|
| 802 |
+
if citations:
|
| 803 |
+
for entry in citations:
|
| 804 |
+
lines.append(_format_citation_entry(entry, show_context=True))
|
| 805 |
+
else:
|
| 806 |
+
lines.append("No citations found.")
|
| 807 |
+
lines.append("")
|
| 808 |
+
|
| 809 |
+
lines.append("**Tip:** Use paper_details with an arxiv_id from above to explore further.")
|
| 810 |
+
return "\n".join(lines)
|
| 811 |
+
|
| 812 |
+
|
| 813 |
+
async def _op_citation_graph(args: dict[str, Any], limit: int) -> ToolResult:
|
| 814 |
+
arxiv_id = _validate_arxiv_id(args)
|
| 815 |
+
if not arxiv_id:
|
| 816 |
+
return _error("'arxiv_id' is required for citation_graph.")
|
| 817 |
+
|
| 818 |
+
direction = args.get("direction", "both")
|
| 819 |
+
s2_id = _s2_paper_id(arxiv_id)
|
| 820 |
+
fields = "title,externalIds,year,citationCount,influentialCitationCount,contexts,intents,isInfluential"
|
| 821 |
+
params = {"fields": fields, "limit": limit}
|
| 822 |
+
|
| 823 |
+
async with httpx.AsyncClient(timeout=15) as client:
|
| 824 |
+
refs, cites = None, None
|
| 825 |
+
coros = []
|
| 826 |
+
if direction in ("references", "both"):
|
| 827 |
+
coros.append(_s2_get_json(client, f"/graph/v1/paper/{s2_id}/references", params))
|
| 828 |
+
if direction in ("citations", "both"):
|
| 829 |
+
coros.append(_s2_get_json(client, f"/graph/v1/paper/{s2_id}/citations", params))
|
| 830 |
+
|
| 831 |
+
results = await asyncio.gather(*coros, return_exceptions=True)
|
| 832 |
+
idx = 0
|
| 833 |
+
if direction in ("references", "both"):
|
| 834 |
+
r = results[idx]
|
| 835 |
+
if isinstance(r, dict):
|
| 836 |
+
refs = r.get("data", [])
|
| 837 |
+
idx += 1
|
| 838 |
+
if direction in ("citations", "both"):
|
| 839 |
+
r = results[idx]
|
| 840 |
+
if isinstance(r, dict):
|
| 841 |
+
cites = r.get("data", [])
|
| 842 |
+
|
| 843 |
+
if refs is None and cites is None:
|
| 844 |
+
return _error(f"Could not fetch citation data for {arxiv_id}. Paper may not be indexed by Semantic Scholar.")
|
| 845 |
+
|
| 846 |
+
total = (len(refs) if refs else 0) + (len(cites) if cites else 0)
|
| 847 |
+
return {
|
| 848 |
+
"formatted": _format_citation_graph(arxiv_id, refs, cites),
|
| 849 |
+
"totalResults": total,
|
| 850 |
+
"resultsShared": total,
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
|
| 854 |
async def _op_find_datasets(args: dict[str, Any], limit: int) -> ToolResult:
|
| 855 |
arxiv_id = _validate_arxiv_id(args)
|
| 856 |
if not arxiv_id:
|
|
|
|
| 1009 |
return {"formatted": formatted, "totalResults": total, "resultsShared": total}
|
| 1010 |
|
| 1011 |
|
| 1012 |
+
# ---------------------------------------------------------------------------
|
| 1013 |
+
# Snippet search (Semantic Scholar)
|
| 1014 |
+
# ---------------------------------------------------------------------------
|
| 1015 |
+
|
| 1016 |
+
|
| 1017 |
+
def _format_snippets(snippets: list[dict], query: str) -> str:
|
| 1018 |
+
lines = [f"# Snippet Search: '{query}'"]
|
| 1019 |
+
lines.append(f"Found {len(snippets)} matching passage(s)\n")
|
| 1020 |
+
|
| 1021 |
+
for i, item in enumerate(snippets, 1):
|
| 1022 |
+
paper = item.get("paper") or {}
|
| 1023 |
+
ptitle = paper.get("title") or "(untitled)"
|
| 1024 |
+
year = paper.get("year") or "?"
|
| 1025 |
+
cites = paper.get("citationCount", 0)
|
| 1026 |
+
ext_ids = paper.get("externalIds") or {}
|
| 1027 |
+
aid = ext_ids.get("ArXiv", "")
|
| 1028 |
+
|
| 1029 |
+
snippet = item.get("snippet") or {}
|
| 1030 |
+
text = snippet.get("text", "")
|
| 1031 |
+
section = snippet.get("section") or ""
|
| 1032 |
+
|
| 1033 |
+
lines.append(f"### {i}. {ptitle} ({year}, {cites} cites)")
|
| 1034 |
+
if aid:
|
| 1035 |
+
lines.append(f"arxiv:{aid}")
|
| 1036 |
+
if section:
|
| 1037 |
+
lines.append(f"Section: {section}")
|
| 1038 |
+
if text:
|
| 1039 |
+
lines.append(f"> {_truncate(text, 400)}")
|
| 1040 |
+
lines.append("")
|
| 1041 |
+
|
| 1042 |
+
lines.append("Use paper_details or read_paper with arxiv_id to explore a paper further.")
|
| 1043 |
+
return "\n".join(lines)
|
| 1044 |
+
|
| 1045 |
+
|
| 1046 |
+
async def _op_snippet_search(args: dict[str, Any], limit: int) -> ToolResult:
|
| 1047 |
+
query = args.get("query")
|
| 1048 |
+
if not query:
|
| 1049 |
+
return _error("'query' is required for snippet_search.")
|
| 1050 |
+
|
| 1051 |
+
params: dict[str, Any] = {
|
| 1052 |
+
"query": query,
|
| 1053 |
+
"limit": limit,
|
| 1054 |
+
"fields": "title,externalIds,year,citationCount",
|
| 1055 |
+
}
|
| 1056 |
+
|
| 1057 |
+
# Optional filters (same as search)
|
| 1058 |
+
date_from = args.get("date_from", "")
|
| 1059 |
+
date_to = args.get("date_to", "")
|
| 1060 |
+
if date_from or date_to:
|
| 1061 |
+
params["publicationDateOrYear"] = f"{date_from}:{date_to}"
|
| 1062 |
+
if args.get("categories"):
|
| 1063 |
+
params["fieldsOfStudy"] = args["categories"]
|
| 1064 |
+
if args.get("min_citations"):
|
| 1065 |
+
params["minCitationCount"] = str(args["min_citations"])
|
| 1066 |
+
|
| 1067 |
+
async with httpx.AsyncClient(timeout=15) as client:
|
| 1068 |
+
resp = await _s2_request(client, "GET", "/graph/v1/snippet/search", params=params)
|
| 1069 |
+
if not resp or resp.status_code != 200:
|
| 1070 |
+
return _error("Snippet search failed. Semantic Scholar may be unavailable.")
|
| 1071 |
+
data = resp.json()
|
| 1072 |
+
|
| 1073 |
+
snippets = data.get("data") or []
|
| 1074 |
+
if not snippets:
|
| 1075 |
+
return {
|
| 1076 |
+
"formatted": f"No snippets found for '{query}'.",
|
| 1077 |
+
"totalResults": 0,
|
| 1078 |
+
"resultsShared": 0,
|
| 1079 |
+
}
|
| 1080 |
+
|
| 1081 |
+
return {
|
| 1082 |
+
"formatted": _format_snippets(snippets, query),
|
| 1083 |
+
"totalResults": len(snippets),
|
| 1084 |
+
"resultsShared": len(snippets),
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
+
|
| 1088 |
+
# ---------------------------------------------------------------------------
|
| 1089 |
+
# Recommendations (Semantic Scholar)
|
| 1090 |
+
# ---------------------------------------------------------------------------
|
| 1091 |
+
|
| 1092 |
+
|
| 1093 |
+
async def _op_recommend(args: dict[str, Any], limit: int) -> ToolResult:
|
| 1094 |
+
positive_ids = args.get("positive_ids")
|
| 1095 |
+
arxiv_id = _validate_arxiv_id(args)
|
| 1096 |
+
|
| 1097 |
+
if not arxiv_id and not positive_ids:
|
| 1098 |
+
return _error("'arxiv_id' or 'positive_ids' is required for recommend.")
|
| 1099 |
+
|
| 1100 |
+
fields = "title,externalIds,year,citationCount,tldr,venue"
|
| 1101 |
+
|
| 1102 |
+
async with httpx.AsyncClient(timeout=15) as client:
|
| 1103 |
+
if positive_ids and not arxiv_id:
|
| 1104 |
+
# Multi-paper recommendations (POST, not cached)
|
| 1105 |
+
pos = [_s2_paper_id(pid.strip()) for pid in positive_ids.split(",") if pid.strip()]
|
| 1106 |
+
neg_raw = args.get("negative_ids", "")
|
| 1107 |
+
neg = [_s2_paper_id(pid.strip()) for pid in neg_raw.split(",") if pid.strip()] if neg_raw else []
|
| 1108 |
+
resp = await _s2_request(
|
| 1109 |
+
client, "POST", "/recommendations/v1/papers/",
|
| 1110 |
+
json={"positivePaperIds": pos, "negativePaperIds": neg},
|
| 1111 |
+
params={"fields": fields, "limit": limit},
|
| 1112 |
+
)
|
| 1113 |
+
if not resp or resp.status_code != 200:
|
| 1114 |
+
return _error("Recommendation request failed. Semantic Scholar may be unavailable.")
|
| 1115 |
+
data = resp.json()
|
| 1116 |
+
else:
|
| 1117 |
+
# Single-paper recommendations (cached)
|
| 1118 |
+
data = await _s2_get_json(
|
| 1119 |
+
client,
|
| 1120 |
+
f"/recommendations/v1/papers/forpaper/{_s2_paper_id(arxiv_id)}",
|
| 1121 |
+
{"fields": fields, "limit": limit, "from": "recent"},
|
| 1122 |
+
)
|
| 1123 |
+
if not data:
|
| 1124 |
+
return _error("Recommendation request failed. Semantic Scholar may be unavailable.")
|
| 1125 |
+
|
| 1126 |
+
papers = data.get("recommendedPapers") or []
|
| 1127 |
+
if not papers:
|
| 1128 |
+
return {
|
| 1129 |
+
"formatted": "No recommendations found.",
|
| 1130 |
+
"totalResults": 0,
|
| 1131 |
+
"resultsShared": 0,
|
| 1132 |
+
}
|
| 1133 |
+
|
| 1134 |
+
title = f"Recommended papers based on {arxiv_id or positive_ids}"
|
| 1135 |
+
return {
|
| 1136 |
+
"formatted": _format_s2_paper_list(papers[:limit], title),
|
| 1137 |
+
"totalResults": len(papers),
|
| 1138 |
+
"resultsShared": min(limit, len(papers)),
|
| 1139 |
+
}
|
| 1140 |
+
|
| 1141 |
+
|
| 1142 |
# ---------------------------------------------------------------------------
|
| 1143 |
# Operation dispatch
|
| 1144 |
# ---------------------------------------------------------------------------
|
|
|
|
| 1148 |
"search": _op_search,
|
| 1149 |
"paper_details": _op_paper_details,
|
| 1150 |
"read_paper": _op_read_paper,
|
| 1151 |
+
"citation_graph": _op_citation_graph,
|
| 1152 |
+
"snippet_search": _op_snippet_search,
|
| 1153 |
+
"recommend": _op_recommend,
|
| 1154 |
"find_datasets": _op_find_datasets,
|
| 1155 |
"find_models": _op_find_models,
|
| 1156 |
"find_collections": _op_find_collections,
|
|
|
|
| 1165 |
HF_PAPERS_TOOL_SPEC = {
|
| 1166 |
"name": "hf_papers",
|
| 1167 |
"description": (
|
| 1168 |
+
"Discover ML research papers, analyze citations, search paper contents, and find linked resources.\n\n"
|
| 1169 |
+
"Combines HuggingFace Hub, arXiv, and Semantic Scholar. Use for exploring research areas, "
|
| 1170 |
+
"finding datasets for a task, tracing citation chains, or implementing a paper's approach.\n\n"
|
| 1171 |
+
"Typical flows:\n"
|
| 1172 |
+
" search β read_paper β find_all_resources β hf_inspect_dataset\n"
|
| 1173 |
+
" search β paper_details β citation_graph β read_paper (trace influence)\n"
|
| 1174 |
+
" snippet_search β paper_details β read_paper (find specific claims)\n\n"
|
| 1175 |
"Operations:\n"
|
| 1176 |
"- trending: Get trending daily papers, optionally filter by topic keyword\n"
|
| 1177 |
+
"- search: Search papers. Uses HF by default (ML-tuned). Add date_from/min_citations/categories to use Semantic Scholar with filters\n"
|
| 1178 |
+
"- paper_details: Metadata, abstract, AI summary, github link\n"
|
| 1179 |
+
"- read_paper: Read paper contents β without section: abstract + TOC; with section: full text\n"
|
| 1180 |
+
"- citation_graph: Get references and citations for a paper with influence flags and citation intents\n"
|
| 1181 |
+
"- snippet_search: Semantic search over full-text passages from 12M+ papers\n"
|
| 1182 |
+
"- recommend: Find similar papers (single paper or positive/negative examples)\n"
|
| 1183 |
"- find_datasets: Find datasets linked to a paper\n"
|
| 1184 |
"- find_models: Find models linked to a paper\n"
|
| 1185 |
"- find_collections: Find collections that include a paper\n"
|
| 1186 |
+
"- find_all_resources: Parallel fetch of datasets + models + collections for a paper"
|
| 1187 |
),
|
| 1188 |
"parameters": {
|
| 1189 |
"type": "object",
|
|
|
|
| 1196 |
"query": {
|
| 1197 |
"type": "string",
|
| 1198 |
"description": (
|
| 1199 |
+
"Search query. Required for: search, snippet_search. "
|
| 1200 |
+
"Optional for: trending (filters by keyword). "
|
| 1201 |
+
"Supports boolean syntax for Semantic Scholar: '\"exact phrase\" term1 | term2'."
|
| 1202 |
),
|
| 1203 |
},
|
| 1204 |
"arxiv_id": {
|
| 1205 |
"type": "string",
|
| 1206 |
"description": (
|
| 1207 |
"ArXiv paper ID (e.g. '2305.18290'). "
|
| 1208 |
+
"Required for: paper_details, read_paper, citation_graph, find_datasets, find_models, find_collections, find_all_resources. "
|
| 1209 |
+
"Optional for: recommend (single-paper recs). Get IDs from search results first."
|
| 1210 |
),
|
| 1211 |
},
|
| 1212 |
"section": {
|
| 1213 |
"type": "string",
|
| 1214 |
"description": (
|
| 1215 |
"Section name or number to read (e.g. '3', 'Experiments', '4.2'). "
|
| 1216 |
+
"Optional for: read_paper. Without this, returns abstract + TOC."
|
|
|
|
| 1217 |
),
|
| 1218 |
},
|
| 1219 |
+
"direction": {
|
| 1220 |
+
"type": "string",
|
| 1221 |
+
"enum": ["citations", "references", "both"],
|
| 1222 |
+
"description": "Direction for citation_graph. Default: both.",
|
| 1223 |
+
},
|
| 1224 |
"date": {
|
| 1225 |
"type": "string",
|
| 1226 |
"description": "Date in YYYY-MM-DD format. Optional for: trending (defaults to recent papers).",
|
| 1227 |
},
|
| 1228 |
+
"date_from": {
|
| 1229 |
+
"type": "string",
|
| 1230 |
+
"description": "Start date (YYYY-MM-DD). Triggers Semantic Scholar search. For: search, snippet_search.",
|
| 1231 |
+
},
|
| 1232 |
+
"date_to": {
|
| 1233 |
+
"type": "string",
|
| 1234 |
+
"description": "End date (YYYY-MM-DD). Triggers Semantic Scholar search. For: search, snippet_search.",
|
| 1235 |
+
},
|
| 1236 |
+
"categories": {
|
| 1237 |
+
"type": "string",
|
| 1238 |
+
"description": "Field of study filter (e.g. 'Computer Science'). Triggers Semantic Scholar search.",
|
| 1239 |
+
},
|
| 1240 |
+
"min_citations": {
|
| 1241 |
+
"type": "integer",
|
| 1242 |
+
"description": "Minimum citation count filter. Triggers Semantic Scholar search.",
|
| 1243 |
+
},
|
| 1244 |
+
"sort_by": {
|
| 1245 |
+
"type": "string",
|
| 1246 |
+
"enum": ["relevance", "citationCount", "publicationDate"],
|
| 1247 |
+
"description": "Sort order for Semantic Scholar search. Default: relevance.",
|
| 1248 |
+
},
|
| 1249 |
+
"positive_ids": {
|
| 1250 |
+
"type": "string",
|
| 1251 |
+
"description": "Comma-separated arxiv IDs for multi-paper recommendations. For: recommend.",
|
| 1252 |
+
},
|
| 1253 |
+
"negative_ids": {
|
| 1254 |
+
"type": "string",
|
| 1255 |
+
"description": "Comma-separated arxiv IDs as negative examples. For: recommend.",
|
| 1256 |
+
},
|
| 1257 |
"sort": {
|
| 1258 |
"type": "string",
|
| 1259 |
"enum": ["downloads", "likes", "trending"],
|
| 1260 |
"description": (
|
| 1261 |
+
"Sort order for find_datasets and find_models. Default: downloads."
|
|
|
|
| 1262 |
),
|
| 1263 |
},
|
| 1264 |
"limit": {
|
agent/tools/research_tool.py
CHANGED
|
@@ -46,12 +46,22 @@ Your job: explore documentation, code examples, APIs, and repos,
|
|
| 46 |
then return a concise, actionable summary. The main agent will use
|
| 47 |
your findings to implement the actual solution.
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
# Research methodology
|
| 50 |
|
| 51 |
-
1. **Discovery**: Find relevant entry points β example scripts, doc pages, API endpoints
|
| 52 |
2. **Tracing**: Follow the chain from entry point to implementation detail
|
| 53 |
-
3. **Analysis**: Identify patterns, current API usage, key dependencies
|
| 54 |
-
4. **Synthesis**: Summarize findings in a structured format
|
| 55 |
|
| 56 |
# How to use your tools
|
| 57 |
|
|
@@ -75,12 +85,30 @@ your findings to implement the actual solution.
|
|
| 75 |
- DPO: needs "prompt", "chosen", "rejected"
|
| 76 |
- GRPO: needs "prompt" only
|
| 77 |
|
| 78 |
-
## Papers
|
| 79 |
-
- `hf_papers`: Search papers
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
## Hub repo inspection
|
| 82 |
- `hf_repo_files`: List/read files in any HF repo (model, dataset, space)
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
# Correct research pattern for ML tasks
|
| 85 |
|
| 86 |
```
|
|
@@ -101,11 +129,12 @@ hf_inspect_dataset({"dataset": "org/name", "split": "train", "sample_rows": 3})
|
|
| 101 |
# Output format
|
| 102 |
|
| 103 |
Your output MUST include:
|
|
|
|
| 104 |
- **Key findings**: The most important things you discovered (current API usage, working patterns)
|
| 105 |
- **Essential references**: Specific file paths, URLs, function names, doc sections, code snippets
|
| 106 |
that the main agent should use directly
|
| 107 |
- **Code patterns**: Key imports, configurations, and usage patterns from working examples
|
| 108 |
-
- **Recommendations**: What to do next based on your findings
|
| 109 |
|
| 110 |
Be concise. Your output goes into another agent's context β every token counts.
|
| 111 |
Aim for 500-1500 words max. Include actual code snippets from examples you read,
|
|
|
|
| 46 |
then return a concise, actionable summary. The main agent will use
|
| 47 |
your findings to implement the actual solution.
|
| 48 |
|
| 49 |
+
# Being up to date is critical
|
| 50 |
+
|
| 51 |
+
Always prioritize finding the most current, state-of-the-art approaches.
|
| 52 |
+
ML moves fast β a method from 6 months ago may already be obsolete.
|
| 53 |
+
|
| 54 |
+
- Search for **recent papers** (use `hf_papers`) to find SOTA methods, models, and datasets for the task
|
| 55 |
+
- Compare what you find in docs/examples against what recent papers recommend β prefer the newer approach
|
| 56 |
+
- When multiple approaches exist, identify which is SOTA and why (benchmark results, adoption, recency)
|
| 57 |
+
- Include in your findings: what is the current best model, dataset, and method for the task
|
| 58 |
+
|
| 59 |
# Research methodology
|
| 60 |
|
| 61 |
+
1. **Discovery**: Find relevant entry points β example scripts, doc pages, API endpoints, **and recent papers for SOTA approaches**
|
| 62 |
2. **Tracing**: Follow the chain from entry point to implementation detail
|
| 63 |
+
3. **Analysis**: Identify patterns, current API usage, key dependencies. **Compare against SOTA from recent papers**
|
| 64 |
+
4. **Synthesis**: Summarize findings in a structured format, highlighting what is current best practice vs. outdated
|
| 65 |
|
| 66 |
# How to use your tools
|
| 67 |
|
|
|
|
| 85 |
- DPO: needs "prompt", "chosen", "rejected"
|
| 86 |
- GRPO: needs "prompt" only
|
| 87 |
|
| 88 |
+
## Papers & citations
|
| 89 |
+
- `hf_papers(operation="search", query=...)`: Search papers (HF-tuned for ML)
|
| 90 |
+
- `hf_papers(operation="search", query=..., min_citations=50, sort_by="citationCount")`: Find highly-cited papers via Semantic Scholar
|
| 91 |
+
- `hf_papers(operation="search", query=..., date_from="2024-01-01")`: Search with date filter
|
| 92 |
+
- `hf_papers(operation="paper_details", arxiv_id=...)`: Metadata, citations, TL;DR
|
| 93 |
+
- `hf_papers(operation="citation_graph", arxiv_id=...)`: References + citations with influence flags and intents
|
| 94 |
+
- `hf_papers(operation="snippet_search", query=...)`: Semantic search across 12M+ full-text paper passages
|
| 95 |
+
- `hf_papers(operation="recommend", arxiv_id=...)`: Find related papers
|
| 96 |
|
| 97 |
## Hub repo inspection
|
| 98 |
- `hf_repo_files`: List/read files in any HF repo (model, dataset, space)
|
| 99 |
|
| 100 |
+
# Paper analysis checklist
|
| 101 |
+
|
| 102 |
+
When reading a paper, always extract:
|
| 103 |
+
- **Key claims**: What does the paper propose or demonstrate?
|
| 104 |
+
- **Methodology**: Architecture, training setup, key techniques
|
| 105 |
+
- **Results**: Benchmark numbers, comparisons to baselines
|
| 106 |
+
- **Limitations**: What the authors acknowledge or what seems missing
|
| 107 |
+
|
| 108 |
+
Use `citation_graph` to trace influence: check what a breakthrough paper cites (foundations)
|
| 109 |
+
and who cites it (impact and extensions). Use `snippet_search` to verify claims across
|
| 110 |
+
papers (e.g., "does method X consistently outperform Y?").
|
| 111 |
+
|
| 112 |
# Correct research pattern for ML tasks
|
| 113 |
|
| 114 |
```
|
|
|
|
| 129 |
# Output format
|
| 130 |
|
| 131 |
Your output MUST include:
|
| 132 |
+
- **SOTA landscape**: Current best models, datasets, and methods for the task (from recent papers). Flag anything outdated.
|
| 133 |
- **Key findings**: The most important things you discovered (current API usage, working patterns)
|
| 134 |
- **Essential references**: Specific file paths, URLs, function names, doc sections, code snippets
|
| 135 |
that the main agent should use directly
|
| 136 |
- **Code patterns**: Key imports, configurations, and usage patterns from working examples
|
| 137 |
+
- **Recommendations**: What to do next based on your findings, preferring SOTA approaches
|
| 138 |
|
| 139 |
Be concise. Your output goes into another agent's context β every token counts.
|
| 140 |
Aim for 500-1500 words max. Include actual code snippets from examples you read,
|
agent/tools/sandbox_tool.py
CHANGED
|
@@ -12,7 +12,6 @@ a cpu-basic sandbox is auto-created (no approval needed).
|
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
import asyncio
|
| 15 |
-
import shlex
|
| 16 |
import threading
|
| 17 |
from typing import Any
|
| 18 |
|
|
@@ -49,9 +48,15 @@ async def resolve_sandbox_script(
|
|
| 49 |
if not sandbox or not _looks_like_path(script):
|
| 50 |
return None, None
|
| 51 |
try:
|
| 52 |
-
|
|
|
|
| 53 |
if result.success and result.output:
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
return None, f"Failed to read {script} from sandbox: {result.error}"
|
| 56 |
except Exception as e:
|
| 57 |
return None, f"Failed to read {script} from sandbox: {e}"
|
|
|
|
| 12 |
from __future__ import annotations
|
| 13 |
|
| 14 |
import asyncio
|
|
|
|
| 15 |
import threading
|
| 16 |
from typing import Any
|
| 17 |
|
|
|
|
| 48 |
if not sandbox or not _looks_like_path(script):
|
| 49 |
return None, None
|
| 50 |
try:
|
| 51 |
+
# Use the read endpoint instead of bash("cat ...") which truncates at 25KB.
|
| 52 |
+
result = await asyncio.to_thread(sandbox.read, script, limit=100_000)
|
| 53 |
if result.success and result.output:
|
| 54 |
+
# Strip line number prefixes (read returns "N\tcontent" format)
|
| 55 |
+
lines = []
|
| 56 |
+
for line in result.output.split("\n"):
|
| 57 |
+
parts = line.split("\t", 1)
|
| 58 |
+
lines.append(parts[1] if len(parts) == 2 else line)
|
| 59 |
+
return "\n".join(lines), None
|
| 60 |
return None, f"Failed to read {script} from sandbox: {result.error}"
|
| 61 |
except Exception as e:
|
| 62 |
return None, f"Failed to read {script} from sandbox: {e}"
|
backend/main.py
CHANGED
|
@@ -12,7 +12,8 @@ from fastapi.staticfiles import StaticFiles
|
|
| 12 |
from routes.agent import router as agent_router
|
| 13 |
from routes.auth import router as auth_router
|
| 14 |
|
| 15 |
-
|
|
|
|
| 16 |
|
| 17 |
# Configure logging
|
| 18 |
logging.basicConfig(
|
|
|
|
| 12 |
from routes.agent import router as agent_router
|
| 13 |
from routes.auth import router as auth_router
|
| 14 |
|
| 15 |
+
# Load .env from project root (parent directory)
|
| 16 |
+
load_dotenv(Path(__file__).parent.parent / ".env")
|
| 17 |
|
| 18 |
# Configure logging
|
| 19 |
logging.basicConfig(
|
backend/models.py
CHANGED
|
@@ -54,6 +54,12 @@ class SubmitRequest(BaseModel):
|
|
| 54 |
text: str
|
| 55 |
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
class SessionResponse(BaseModel):
|
| 58 |
"""Response when creating a new session."""
|
| 59 |
|
|
|
|
| 54 |
text: str
|
| 55 |
|
| 56 |
|
| 57 |
+
class TruncateRequest(BaseModel):
|
| 58 |
+
"""Request to truncate conversation history to before a specific user message."""
|
| 59 |
+
|
| 60 |
+
user_message_index: int
|
| 61 |
+
|
| 62 |
+
|
| 63 |
class SessionResponse(BaseModel):
|
| 64 |
"""Response when creating a new session."""
|
| 65 |
|
backend/routes/agent.py
CHANGED
|
@@ -7,6 +7,7 @@ dependency. In dev mode (no OAUTH_CLIENT_ID), auth is bypassed automatically.
|
|
| 7 |
import asyncio
|
| 8 |
import json
|
| 9 |
import logging
|
|
|
|
| 10 |
from typing import Any
|
| 11 |
|
| 12 |
from dependencies import get_current_user
|
|
@@ -25,6 +26,7 @@ from models import (
|
|
| 25 |
SessionInfo,
|
| 26 |
SessionResponse,
|
| 27 |
SubmitRequest,
|
|
|
|
| 28 |
)
|
| 29 |
from session_manager import MAX_SESSIONS, SessionCapacityError, session_manager
|
| 30 |
|
|
@@ -205,13 +207,15 @@ async def create_session(
|
|
| 205 |
|
| 206 |
Returns 503 if the server or user has reached the session limit.
|
| 207 |
"""
|
| 208 |
-
# Extract the user's HF token (Bearer header
|
| 209 |
hf_token = None
|
| 210 |
auth_header = request.headers.get("Authorization", "")
|
| 211 |
if auth_header.startswith("Bearer "):
|
| 212 |
hf_token = auth_header[7:]
|
| 213 |
if not hf_token:
|
| 214 |
hf_token = request.cookies.get("hf_access_token")
|
|
|
|
|
|
|
| 215 |
|
| 216 |
try:
|
| 217 |
session_id = await session_manager.create_session(
|
|
@@ -435,6 +439,18 @@ async def undo_session(session_id: str, user: dict = Depends(get_current_user))
|
|
| 435 |
return {"status": "undo_requested", "session_id": session_id}
|
| 436 |
|
| 437 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
@router.post("/compact/{session_id}")
|
| 439 |
async def compact_session(
|
| 440 |
session_id: str, user: dict = Depends(get_current_user)
|
|
|
|
| 7 |
import asyncio
|
| 8 |
import json
|
| 9 |
import logging
|
| 10 |
+
import os
|
| 11 |
from typing import Any
|
| 12 |
|
| 13 |
from dependencies import get_current_user
|
|
|
|
| 26 |
SessionInfo,
|
| 27 |
SessionResponse,
|
| 28 |
SubmitRequest,
|
| 29 |
+
TruncateRequest,
|
| 30 |
)
|
| 31 |
from session_manager import MAX_SESSIONS, SessionCapacityError, session_manager
|
| 32 |
|
|
|
|
| 207 |
|
| 208 |
Returns 503 if the server or user has reached the session limit.
|
| 209 |
"""
|
| 210 |
+
# Extract the user's HF token (Bearer header, HttpOnly cookie, or env var)
|
| 211 |
hf_token = None
|
| 212 |
auth_header = request.headers.get("Authorization", "")
|
| 213 |
if auth_header.startswith("Bearer "):
|
| 214 |
hf_token = auth_header[7:]
|
| 215 |
if not hf_token:
|
| 216 |
hf_token = request.cookies.get("hf_access_token")
|
| 217 |
+
if not hf_token:
|
| 218 |
+
hf_token = os.environ.get("HF_TOKEN")
|
| 219 |
|
| 220 |
try:
|
| 221 |
session_id = await session_manager.create_session(
|
|
|
|
| 439 |
return {"status": "undo_requested", "session_id": session_id}
|
| 440 |
|
| 441 |
|
| 442 |
+
@router.post("/truncate/{session_id}")
|
| 443 |
+
async def truncate_session(
|
| 444 |
+
session_id: str, body: TruncateRequest, user: dict = Depends(get_current_user)
|
| 445 |
+
) -> dict:
|
| 446 |
+
"""Truncate conversation to before a specific user message."""
|
| 447 |
+
_check_session_access(session_id, user)
|
| 448 |
+
success = await session_manager.truncate(session_id, body.user_message_index)
|
| 449 |
+
if not success:
|
| 450 |
+
raise HTTPException(status_code=404, detail="Session not found, inactive, or message index out of range")
|
| 451 |
+
return {"status": "truncated", "session_id": session_id}
|
| 452 |
+
|
| 453 |
+
|
| 454 |
@router.post("/compact/{session_id}")
|
| 455 |
async def compact_session(
|
| 456 |
session_id: str, user: dict = Depends(get_current_user)
|
backend/routes/auth.py
CHANGED
|
@@ -10,7 +10,7 @@ import time
|
|
| 10 |
from urllib.parse import urlencode
|
| 11 |
|
| 12 |
import httpx
|
| 13 |
-
from dependencies import AUTH_ENABLED, get_current_user
|
| 14 |
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 15 |
from fastapi.responses import RedirectResponse
|
| 16 |
|
|
@@ -169,3 +169,20 @@ async def get_me(user: dict = Depends(get_current_user)) -> dict:
|
|
| 169 |
Uses the shared auth dependency which handles cookie + Bearer token.
|
| 170 |
"""
|
| 171 |
return user
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from urllib.parse import urlencode
|
| 11 |
|
| 12 |
import httpx
|
| 13 |
+
from dependencies import AUTH_ENABLED, check_org_membership, get_current_user
|
| 14 |
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 15 |
from fastapi.responses import RedirectResponse
|
| 16 |
|
|
|
|
| 169 |
Uses the shared auth dependency which handles cookie + Bearer token.
|
| 170 |
"""
|
| 171 |
return user
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
ORG_NAME = "ml-agent-explorers"
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@router.get("/org-membership")
|
| 178 |
+
async def org_membership(
|
| 179 |
+
request: Request, user: dict = Depends(get_current_user)
|
| 180 |
+
) -> dict:
|
| 181 |
+
"""Check if the authenticated user belongs to the ml-agent-explorers org."""
|
| 182 |
+
if not AUTH_ENABLED:
|
| 183 |
+
return {"is_member": True}
|
| 184 |
+
token = request.cookies.get("hf_access_token") or ""
|
| 185 |
+
if not token:
|
| 186 |
+
return {"is_member": False}
|
| 187 |
+
is_member = await check_org_membership(token, ORG_NAME)
|
| 188 |
+
return {"is_member": is_member}
|
backend/session_manager.py
CHANGED
|
@@ -319,6 +319,14 @@ class SessionManager:
|
|
| 319 |
operation = Operation(op_type=OpType.UNDO)
|
| 320 |
return await self.submit(session_id, operation)
|
| 321 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 322 |
async def compact(self, session_id: str) -> bool:
|
| 323 |
"""Compact context in a session."""
|
| 324 |
operation = Operation(op_type=OpType.COMPACT)
|
|
|
|
| 319 |
operation = Operation(op_type=OpType.UNDO)
|
| 320 |
return await self.submit(session_id, operation)
|
| 321 |
|
| 322 |
+
async def truncate(self, session_id: str, user_message_index: int) -> bool:
|
| 323 |
+
"""Truncate conversation to before a specific user message (direct, no queue)."""
|
| 324 |
+
async with self._lock:
|
| 325 |
+
agent_session = self.sessions.get(session_id)
|
| 326 |
+
if not agent_session or not agent_session.is_active:
|
| 327 |
+
return False
|
| 328 |
+
return agent_session.session.context_manager.truncate_to_user_message(user_message_index)
|
| 329 |
+
|
| 330 |
async def compact(self, session_id: str) -> bool:
|
| 331 |
"""Compact context in a session."""
|
| 332 |
operation = Operation(op_type=OpType.COMPACT)
|
frontend/package-lock.json
CHANGED
|
@@ -130,7 +130,6 @@
|
|
| 130 |
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
| 131 |
"dev": true,
|
| 132 |
"license": "MIT",
|
| 133 |
-
"peer": true,
|
| 134 |
"dependencies": {
|
| 135 |
"@babel/code-frame": "^7.28.6",
|
| 136 |
"@babel/generator": "^7.28.6",
|
|
@@ -447,7 +446,6 @@
|
|
| 447 |
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
| 448 |
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
| 449 |
"license": "MIT",
|
| 450 |
-
"peer": true,
|
| 451 |
"dependencies": {
|
| 452 |
"@babel/runtime": "^7.18.3",
|
| 453 |
"@emotion/babel-plugin": "^11.13.5",
|
|
@@ -491,7 +489,6 @@
|
|
| 491 |
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
|
| 492 |
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
|
| 493 |
"license": "MIT",
|
| 494 |
-
"peer": true,
|
| 495 |
"dependencies": {
|
| 496 |
"@babel/runtime": "^7.18.3",
|
| 497 |
"@emotion/babel-plugin": "^11.13.5",
|
|
@@ -1224,7 +1221,6 @@
|
|
| 1224 |
"resolved": "https://registry.npmjs.org/@mui/material/-/material-6.5.0.tgz",
|
| 1225 |
"integrity": "sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow==",
|
| 1226 |
"license": "MIT",
|
| 1227 |
-
"peer": true,
|
| 1228 |
"dependencies": {
|
| 1229 |
"@babel/runtime": "^7.26.0",
|
| 1230 |
"@mui/core-downloads-tracker": "^6.5.0",
|
|
@@ -1919,7 +1915,6 @@
|
|
| 1919 |
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
| 1920 |
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
| 1921 |
"license": "MIT",
|
| 1922 |
-
"peer": true,
|
| 1923 |
"dependencies": {
|
| 1924 |
"@types/prop-types": "*",
|
| 1925 |
"csstype": "^3.2.2"
|
|
@@ -2005,7 +2000,6 @@
|
|
| 2005 |
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
| 2006 |
"dev": true,
|
| 2007 |
"license": "MIT",
|
| 2008 |
-
"peer": true,
|
| 2009 |
"dependencies": {
|
| 2010 |
"@typescript-eslint/scope-manager": "8.53.0",
|
| 2011 |
"@typescript-eslint/types": "8.53.0",
|
|
@@ -2272,7 +2266,6 @@
|
|
| 2272 |
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
| 2273 |
"dev": true,
|
| 2274 |
"license": "MIT",
|
| 2275 |
-
"peer": true,
|
| 2276 |
"bin": {
|
| 2277 |
"acorn": "bin/acorn"
|
| 2278 |
},
|
|
@@ -2421,7 +2414,6 @@
|
|
| 2421 |
}
|
| 2422 |
],
|
| 2423 |
"license": "MIT",
|
| 2424 |
-
"peer": true,
|
| 2425 |
"dependencies": {
|
| 2426 |
"baseline-browser-mapping": "^2.9.0",
|
| 2427 |
"caniuse-lite": "^1.0.30001759",
|
|
@@ -2774,7 +2766,6 @@
|
|
| 2774 |
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
| 2775 |
"dev": true,
|
| 2776 |
"license": "MIT",
|
| 2777 |
-
"peer": true,
|
| 2778 |
"dependencies": {
|
| 2779 |
"@eslint-community/eslint-utils": "^4.8.0",
|
| 2780 |
"@eslint-community/regexpp": "^4.12.1",
|
|
@@ -4673,7 +4664,6 @@
|
|
| 4673 |
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
| 4674 |
"dev": true,
|
| 4675 |
"license": "MIT",
|
| 4676 |
-
"peer": true,
|
| 4677 |
"engines": {
|
| 4678 |
"node": ">=12"
|
| 4679 |
},
|
|
@@ -4771,7 +4761,6 @@
|
|
| 4771 |
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
| 4772 |
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
| 4773 |
"license": "MIT",
|
| 4774 |
-
"peer": true,
|
| 4775 |
"dependencies": {
|
| 4776 |
"loose-envify": "^1.1.0"
|
| 4777 |
},
|
|
@@ -4784,7 +4773,6 @@
|
|
| 4784 |
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
| 4785 |
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
| 4786 |
"license": "MIT",
|
| 4787 |
-
"peer": true,
|
| 4788 |
"dependencies": {
|
| 4789 |
"loose-envify": "^1.1.0",
|
| 4790 |
"scheduler": "^0.23.2"
|
|
@@ -5269,7 +5257,6 @@
|
|
| 5269 |
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
| 5270 |
"dev": true,
|
| 5271 |
"license": "Apache-2.0",
|
| 5272 |
-
"peer": true,
|
| 5273 |
"bin": {
|
| 5274 |
"tsc": "bin/tsc",
|
| 5275 |
"tsserver": "bin/tsserver"
|
|
@@ -5435,7 +5422,6 @@
|
|
| 5435 |
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
| 5436 |
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
| 5437 |
"license": "MIT",
|
| 5438 |
-
"peer": true,
|
| 5439 |
"peerDependencies": {
|
| 5440 |
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 5441 |
}
|
|
@@ -5474,7 +5460,6 @@
|
|
| 5474 |
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
| 5475 |
"dev": true,
|
| 5476 |
"license": "MIT",
|
| 5477 |
-
"peer": true,
|
| 5478 |
"dependencies": {
|
| 5479 |
"esbuild": "^0.21.3",
|
| 5480 |
"postcss": "^8.4.43",
|
|
|
|
| 130 |
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
| 131 |
"dev": true,
|
| 132 |
"license": "MIT",
|
|
|
|
| 133 |
"dependencies": {
|
| 134 |
"@babel/code-frame": "^7.28.6",
|
| 135 |
"@babel/generator": "^7.28.6",
|
|
|
|
| 446 |
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
| 447 |
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
| 448 |
"license": "MIT",
|
|
|
|
| 449 |
"dependencies": {
|
| 450 |
"@babel/runtime": "^7.18.3",
|
| 451 |
"@emotion/babel-plugin": "^11.13.5",
|
|
|
|
| 489 |
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
|
| 490 |
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
|
| 491 |
"license": "MIT",
|
|
|
|
| 492 |
"dependencies": {
|
| 493 |
"@babel/runtime": "^7.18.3",
|
| 494 |
"@emotion/babel-plugin": "^11.13.5",
|
|
|
|
| 1221 |
"resolved": "https://registry.npmjs.org/@mui/material/-/material-6.5.0.tgz",
|
| 1222 |
"integrity": "sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow==",
|
| 1223 |
"license": "MIT",
|
|
|
|
| 1224 |
"dependencies": {
|
| 1225 |
"@babel/runtime": "^7.26.0",
|
| 1226 |
"@mui/core-downloads-tracker": "^6.5.0",
|
|
|
|
| 1915 |
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz",
|
| 1916 |
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
|
| 1917 |
"license": "MIT",
|
|
|
|
| 1918 |
"dependencies": {
|
| 1919 |
"@types/prop-types": "*",
|
| 1920 |
"csstype": "^3.2.2"
|
|
|
|
| 2000 |
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
| 2001 |
"dev": true,
|
| 2002 |
"license": "MIT",
|
|
|
|
| 2003 |
"dependencies": {
|
| 2004 |
"@typescript-eslint/scope-manager": "8.53.0",
|
| 2005 |
"@typescript-eslint/types": "8.53.0",
|
|
|
|
| 2266 |
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
| 2267 |
"dev": true,
|
| 2268 |
"license": "MIT",
|
|
|
|
| 2269 |
"bin": {
|
| 2270 |
"acorn": "bin/acorn"
|
| 2271 |
},
|
|
|
|
| 2414 |
}
|
| 2415 |
],
|
| 2416 |
"license": "MIT",
|
|
|
|
| 2417 |
"dependencies": {
|
| 2418 |
"baseline-browser-mapping": "^2.9.0",
|
| 2419 |
"caniuse-lite": "^1.0.30001759",
|
|
|
|
| 2766 |
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
| 2767 |
"dev": true,
|
| 2768 |
"license": "MIT",
|
|
|
|
| 2769 |
"dependencies": {
|
| 2770 |
"@eslint-community/eslint-utils": "^4.8.0",
|
| 2771 |
"@eslint-community/regexpp": "^4.12.1",
|
|
|
|
| 4664 |
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
| 4665 |
"dev": true,
|
| 4666 |
"license": "MIT",
|
|
|
|
| 4667 |
"engines": {
|
| 4668 |
"node": ">=12"
|
| 4669 |
},
|
|
|
|
| 4761 |
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
| 4762 |
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
| 4763 |
"license": "MIT",
|
|
|
|
| 4764 |
"dependencies": {
|
| 4765 |
"loose-envify": "^1.1.0"
|
| 4766 |
},
|
|
|
|
| 4773 |
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
| 4774 |
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
| 4775 |
"license": "MIT",
|
|
|
|
| 4776 |
"dependencies": {
|
| 4777 |
"loose-envify": "^1.1.0",
|
| 4778 |
"scheduler": "^0.23.2"
|
|
|
|
| 5257 |
"integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==",
|
| 5258 |
"dev": true,
|
| 5259 |
"license": "Apache-2.0",
|
|
|
|
| 5260 |
"bin": {
|
| 5261 |
"tsc": "bin/tsc",
|
| 5262 |
"tsserver": "bin/tsserver"
|
|
|
|
| 5422 |
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
| 5423 |
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
| 5424 |
"license": "MIT",
|
|
|
|
| 5425 |
"peerDependencies": {
|
| 5426 |
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 5427 |
}
|
|
|
|
| 5460 |
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
|
| 5461 |
"dev": true,
|
| 5462 |
"license": "MIT",
|
|
|
|
| 5463 |
"dependencies": {
|
| 5464 |
"esbuild": "^0.21.3",
|
| 5465 |
"postcss": "^8.4.43",
|
frontend/src/components/Chat/ActivityStatusBar.tsx
CHANGED
|
@@ -20,11 +20,90 @@ const TOOL_LABELS: Record<string, string> = {
|
|
| 20 |
research: 'Researching',
|
| 21 |
};
|
| 22 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
function statusLabel(status: ActivityStatus): string {
|
| 24 |
switch (status.type) {
|
| 25 |
case 'thinking': return 'Thinking';
|
| 26 |
case 'streaming': return 'Writing';
|
| 27 |
case 'tool': {
|
|
|
|
|
|
|
|
|
|
| 28 |
const base = status.description || TOOL_LABELS[status.toolName] || `Running ${status.toolName}`;
|
| 29 |
if (status.toolName === 'bash' && status.description && /install/i.test(status.description)) {
|
| 30 |
return `${base} β this can take a few minutes, sit tight`;
|
|
@@ -32,6 +111,7 @@ function statusLabel(status: ActivityStatus): string {
|
|
| 32 |
return base;
|
| 33 |
}
|
| 34 |
case 'waiting-approval': return 'Waiting for approval';
|
|
|
|
| 35 |
default: return '';
|
| 36 |
}
|
| 37 |
}
|
|
@@ -59,7 +139,7 @@ export default function ActivityStatusBar() {
|
|
| 59 |
animation: `${shimmer} 4s ease-in-out infinite`,
|
| 60 |
}}
|
| 61 |
>
|
| 62 |
-
{label}β¦
|
| 63 |
</Typography>
|
| 64 |
</Box>
|
| 65 |
);
|
|
|
|
| 20 |
research: 'Researching',
|
| 21 |
};
|
| 22 |
|
| 23 |
+
/** Format raw research log into a clean status label. */
|
| 24 |
+
function formatResearchStatus(raw: string): string {
|
| 25 |
+
const s = raw.replace(/^βΈ\s*/, '');
|
| 26 |
+
const jsonStart = s.indexOf('{');
|
| 27 |
+
const toolName = jsonStart > 0 ? s.slice(0, jsonStart).trim() : s.trim();
|
| 28 |
+
let args: Record<string, string> = {};
|
| 29 |
+
if (jsonStart > 0) {
|
| 30 |
+
const jsonStr = s.slice(jsonStart);
|
| 31 |
+
try {
|
| 32 |
+
const parsed = JSON.parse(jsonStr);
|
| 33 |
+
for (const [k, v] of Object.entries(parsed)) {
|
| 34 |
+
if (typeof v === 'string') args[k] = v;
|
| 35 |
+
}
|
| 36 |
+
} catch {
|
| 37 |
+
// JSON is likely truncated β extract complete "key": "value" pairs
|
| 38 |
+
for (const m of jsonStr.matchAll(/"(\w+)":\s*"([^"]*)"/g)) {
|
| 39 |
+
args[m[1]] = m[2];
|
| 40 |
+
}
|
| 41 |
+
// Also try to extract a truncated value for known keys if not found yet
|
| 42 |
+
if (!args.query && !args.arxiv_id) {
|
| 43 |
+
const partial = jsonStr.match(/"(query|arxiv_id)":\s*"([^"]*)/);
|
| 44 |
+
if (partial) args[partial[1]] = partial[2];
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
if (toolName === 'github_find_examples') {
|
| 50 |
+
const d = (args.keyword) || (args.repo);
|
| 51 |
+
return d ? `Finding examples: ${d}` : 'Finding examples';
|
| 52 |
+
}
|
| 53 |
+
if (toolName === 'github_read_file') {
|
| 54 |
+
const f = ((args.path) || '').split('/').pop();
|
| 55 |
+
return f ? `Reading ${f}` : 'Reading file';
|
| 56 |
+
}
|
| 57 |
+
if (toolName === 'explore_hf_docs') {
|
| 58 |
+
const d = (args.endpoint) || (args.query);
|
| 59 |
+
return d ? `Exploring docs: ${d}` : 'Exploring docs';
|
| 60 |
+
}
|
| 61 |
+
if (toolName === 'fetch_hf_docs') {
|
| 62 |
+
const p = ((args.url) || '').split('/').pop()?.replace(/\.md$/, '');
|
| 63 |
+
return p ? `Reading docs: ${p}` : 'Fetching docs';
|
| 64 |
+
}
|
| 65 |
+
if (toolName === 'hf_inspect_dataset') {
|
| 66 |
+
const d = args.dataset as string;
|
| 67 |
+
return d ? `Inspecting dataset: ${d}` : 'Inspecting dataset';
|
| 68 |
+
}
|
| 69 |
+
if (toolName === 'hf_papers') {
|
| 70 |
+
const op = args.operation as string;
|
| 71 |
+
const detail = (args.query) || (args.arxiv_id) || (args.positive_ids);
|
| 72 |
+
const opLabels: Record<string, string> = {
|
| 73 |
+
trending: 'Browsing trending papers',
|
| 74 |
+
search: 'Searching papers',
|
| 75 |
+
paper_details: 'Reading paper details',
|
| 76 |
+
read_paper: 'Reading paper',
|
| 77 |
+
citation_graph: 'Tracing citations',
|
| 78 |
+
snippet_search: 'Searching paper passages',
|
| 79 |
+
recommend: 'Finding similar papers',
|
| 80 |
+
find_datasets: 'Finding paper datasets',
|
| 81 |
+
find_models: 'Finding paper models',
|
| 82 |
+
find_collections: 'Finding paper collections',
|
| 83 |
+
find_all_resources: 'Finding paper resources',
|
| 84 |
+
};
|
| 85 |
+
const base = (op && opLabels[op]) || 'Searching papers';
|
| 86 |
+
return detail ? `${base}: ${detail}` : base;
|
| 87 |
+
}
|
| 88 |
+
if (toolName === 'find_hf_api') {
|
| 89 |
+
const d = (args.query) || (args.tag);
|
| 90 |
+
return d ? `Finding API: ${d}` : 'Finding API endpoints';
|
| 91 |
+
}
|
| 92 |
+
if (toolName === 'hf_repo_files') {
|
| 93 |
+
const d = (args.repo_id) || (args.repo);
|
| 94 |
+
return d ? `Reading ${d} files` : 'Reading repo files';
|
| 95 |
+
}
|
| 96 |
+
return 'Researching';
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
function statusLabel(status: ActivityStatus): string {
|
| 100 |
switch (status.type) {
|
| 101 |
case 'thinking': return 'Thinking';
|
| 102 |
case 'streaming': return 'Writing';
|
| 103 |
case 'tool': {
|
| 104 |
+
if (status.toolName === 'research' && status.description) {
|
| 105 |
+
return formatResearchStatus(status.description);
|
| 106 |
+
}
|
| 107 |
const base = status.description || TOOL_LABELS[status.toolName] || `Running ${status.toolName}`;
|
| 108 |
if (status.toolName === 'bash' && status.description && /install/i.test(status.description)) {
|
| 109 |
return `${base} β this can take a few minutes, sit tight`;
|
|
|
|
| 111 |
return base;
|
| 112 |
}
|
| 113 |
case 'waiting-approval': return 'Waiting for approval';
|
| 114 |
+
case 'cancelled': return 'What should the agent do instead?';
|
| 115 |
default: return '';
|
| 116 |
}
|
| 117 |
}
|
|
|
|
| 139 |
animation: `${shimmer} 4s ease-in-out infinite`,
|
| 140 |
}}
|
| 141 |
>
|
| 142 |
+
{label}{activityStatus.type !== 'cancelled' && 'β¦'}
|
| 143 |
</Typography>
|
| 144 |
</Box>
|
| 145 |
);
|
frontend/src/components/Chat/ChatInput.tsx
CHANGED
|
@@ -2,7 +2,7 @@ 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
|
| 6 |
import { apiFetch } from '@/utils/api';
|
| 7 |
|
| 8 |
// Model configuration
|
|
@@ -67,7 +67,6 @@ interface ChatInputProps {
|
|
| 67 |
|
| 68 |
export default function ChatInput({ onSend, onStop, isProcessing = false, disabled = false, placeholder = 'Ask anything...' }: ChatInputProps) {
|
| 69 |
const [input, setInput] = useState('');
|
| 70 |
-
const [stopHovered, setStopHovered] = useState(false);
|
| 71 |
const inputRef = useRef<HTMLTextAreaElement>(null);
|
| 72 |
const [selectedModelId, setSelectedModelId] = useState<string>(() => {
|
| 73 |
try {
|
|
@@ -207,20 +206,23 @@ export default function ChatInput({ onSend, onStop, isProcessing = false, disabl
|
|
| 207 |
{isProcessing ? (
|
| 208 |
<IconButton
|
| 209 |
onClick={onStop}
|
| 210 |
-
onMouseEnter={() => setStopHovered(true)}
|
| 211 |
-
onMouseLeave={() => setStopHovered(false)}
|
| 212 |
sx={{
|
| 213 |
mt: 1,
|
| 214 |
-
p: 1,
|
| 215 |
borderRadius: '10px',
|
| 216 |
-
color:
|
| 217 |
transition: 'all 0.2s',
|
|
|
|
| 218 |
'&:hover': {
|
| 219 |
bgcolor: 'var(--hover-bg)',
|
|
|
|
| 220 |
},
|
| 221 |
}}
|
| 222 |
>
|
| 223 |
-
{
|
|
|
|
|
|
|
|
|
|
| 224 |
</IconButton>
|
| 225 |
) : (
|
| 226 |
<IconButton
|
|
|
|
| 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 StopIcon from '@mui/icons-material/Stop';
|
| 6 |
import { apiFetch } from '@/utils/api';
|
| 7 |
|
| 8 |
// Model configuration
|
|
|
|
| 67 |
|
| 68 |
export default function ChatInput({ onSend, onStop, isProcessing = false, disabled = false, placeholder = 'Ask anything...' }: ChatInputProps) {
|
| 69 |
const [input, setInput] = useState('');
|
|
|
|
| 70 |
const inputRef = useRef<HTMLTextAreaElement>(null);
|
| 71 |
const [selectedModelId, setSelectedModelId] = useState<string>(() => {
|
| 72 |
try {
|
|
|
|
| 206 |
{isProcessing ? (
|
| 207 |
<IconButton
|
| 208 |
onClick={onStop}
|
|
|
|
|
|
|
| 209 |
sx={{
|
| 210 |
mt: 1,
|
| 211 |
+
p: 1.5,
|
| 212 |
borderRadius: '10px',
|
| 213 |
+
color: 'var(--muted-text)',
|
| 214 |
transition: 'all 0.2s',
|
| 215 |
+
position: 'relative',
|
| 216 |
'&:hover': {
|
| 217 |
bgcolor: 'var(--hover-bg)',
|
| 218 |
+
color: 'var(--accent-red)',
|
| 219 |
},
|
| 220 |
}}
|
| 221 |
>
|
| 222 |
+
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
| 223 |
+
<CircularProgress size={28} thickness={3} sx={{ color: 'inherit', position: 'absolute' }} />
|
| 224 |
+
<StopIcon sx={{ fontSize: 16 }} />
|
| 225 |
+
</Box>
|
| 226 |
</IconButton>
|
| 227 |
) : (
|
| 228 |
<IconButton
|
frontend/src/components/Chat/MessageBubble.tsx
CHANGED
|
@@ -6,6 +6,7 @@ interface MessageBubbleProps {
|
|
| 6 |
message: UIMessage;
|
| 7 |
isLastTurn?: boolean;
|
| 8 |
onUndoTurn?: () => void;
|
|
|
|
| 9 |
isProcessing?: boolean;
|
| 10 |
isStreaming?: boolean;
|
| 11 |
approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>) => Promise<boolean>;
|
|
@@ -15,6 +16,7 @@ export default function MessageBubble({
|
|
| 15 |
message,
|
| 16 |
isLastTurn = false,
|
| 17 |
onUndoTurn,
|
|
|
|
| 18 |
isProcessing = false,
|
| 19 |
isStreaming = false,
|
| 20 |
approveTools,
|
|
@@ -25,6 +27,7 @@ export default function MessageBubble({
|
|
| 25 |
message={message}
|
| 26 |
isLastTurn={isLastTurn}
|
| 27 |
onUndoTurn={onUndoTurn}
|
|
|
|
| 28 |
isProcessing={isProcessing}
|
| 29 |
/>
|
| 30 |
);
|
|
|
|
| 6 |
message: UIMessage;
|
| 7 |
isLastTurn?: boolean;
|
| 8 |
onUndoTurn?: () => void;
|
| 9 |
+
onEditAndRegenerate?: (messageId: string, newText: string) => void | Promise<void>;
|
| 10 |
isProcessing?: boolean;
|
| 11 |
isStreaming?: boolean;
|
| 12 |
approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>) => Promise<boolean>;
|
|
|
|
| 16 |
message,
|
| 17 |
isLastTurn = false,
|
| 18 |
onUndoTurn,
|
| 19 |
+
onEditAndRegenerate,
|
| 20 |
isProcessing = false,
|
| 21 |
isStreaming = false,
|
| 22 |
approveTools,
|
|
|
|
| 27 |
message={message}
|
| 28 |
isLastTurn={isLastTurn}
|
| 29 |
onUndoTurn={onUndoTurn}
|
| 30 |
+
onEditAndRegenerate={onEditAndRegenerate}
|
| 31 |
isProcessing={isProcessing}
|
| 32 |
/>
|
| 33 |
);
|
frontend/src/components/Chat/MessageList.tsx
CHANGED
|
@@ -10,6 +10,7 @@ interface MessageListProps {
|
|
| 10 |
isProcessing: boolean;
|
| 11 |
approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>) => Promise<boolean>;
|
| 12 |
onUndoLastTurn: () => void | Promise<void>;
|
|
|
|
| 13 |
}
|
| 14 |
|
| 15 |
function getGreeting(): string {
|
|
@@ -56,7 +57,7 @@ function WelcomeGreeting() {
|
|
| 56 |
);
|
| 57 |
}
|
| 58 |
|
| 59 |
-
export default function MessageList({ messages, isProcessing, approveTools, onUndoLastTurn }: MessageListProps) {
|
| 60 |
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
| 61 |
const stickToBottom = useRef(true);
|
| 62 |
|
|
@@ -135,6 +136,7 @@ export default function MessageList({ messages, isProcessing, approveTools, onUn
|
|
| 135 |
message={msg}
|
| 136 |
isLastTurn={msg.id === lastUserMsgId}
|
| 137 |
onUndoTurn={onUndoLastTurn}
|
|
|
|
| 138 |
isProcessing={isProcessing}
|
| 139 |
isStreaming={isProcessing && msg.id === lastAssistantId}
|
| 140 |
approveTools={approveTools}
|
|
|
|
| 10 |
isProcessing: boolean;
|
| 11 |
approveTools: (approvals: Array<{ tool_call_id: string; approved: boolean; feedback?: string | null }>) => Promise<boolean>;
|
| 12 |
onUndoLastTurn: () => void | Promise<void>;
|
| 13 |
+
onEditAndRegenerate?: (messageId: string, newText: string) => void | Promise<void>;
|
| 14 |
}
|
| 15 |
|
| 16 |
function getGreeting(): string {
|
|
|
|
| 57 |
);
|
| 58 |
}
|
| 59 |
|
| 60 |
+
export default function MessageList({ messages, isProcessing, approveTools, onUndoLastTurn, onEditAndRegenerate }: MessageListProps) {
|
| 61 |
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
| 62 |
const stickToBottom = useRef(true);
|
| 63 |
|
|
|
|
| 136 |
message={msg}
|
| 137 |
isLastTurn={msg.id === lastUserMsgId}
|
| 138 |
onUndoTurn={onUndoLastTurn}
|
| 139 |
+
onEditAndRegenerate={onEditAndRegenerate}
|
| 140 |
isProcessing={isProcessing}
|
| 141 |
isStreaming={isProcessing && msg.id === lastAssistantId}
|
| 142 |
approveTools={approveTools}
|
frontend/src/components/Chat/ToolCallGroup.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import BlockIcon from '@mui/icons-material/Block';
|
|
| 10 |
import { useAgentStore } from '@/store/agentStore';
|
| 11 |
import { useLayoutStore } from '@/store/layoutStore';
|
| 12 |
import { logger } from '@/utils/logger';
|
|
|
|
| 13 |
import type { UIMessage } from 'ai';
|
| 14 |
|
| 15 |
// ---------------------------------------------------------------------------
|
|
@@ -35,37 +36,153 @@ interface ToolCallGroupProps {
|
|
| 35 |
// Research sub-steps (inline under the research tool row)
|
| 36 |
// ---------------------------------------------------------------------------
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
/** Pretty labels for research sub-agent tool calls */
|
| 39 |
-
function formatResearchStep(
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
if (step.startsWith('github_read_file')) {
|
| 44 |
-
const path =
|
| 45 |
const filename = path.split('/').pop() || path;
|
| 46 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
-
if (step.startsWith('
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
|
|
|
|
| 57 |
function ResearchSteps({ steps, isRunning }: { steps: string[]; isRunning: boolean }) {
|
| 58 |
-
|
| 59 |
-
const
|
| 60 |
-
|
| 61 |
-
);
|
| 62 |
-
if (toolSteps.length === 0) return null;
|
| 63 |
|
| 64 |
return (
|
| 65 |
<Box sx={{ pl: 4.5, pr: 1.5, pb: 1, pt: 0.25 }}>
|
| 66 |
-
{
|
| 67 |
-
const {
|
| 68 |
-
const isLast = i ===
|
| 69 |
return (
|
| 70 |
<Stack
|
| 71 |
key={i}
|
|
@@ -74,17 +191,16 @@ function ResearchSteps({ steps, isRunning }: { steps: string[]; isRunning: boole
|
|
| 74 |
spacing={0.75}
|
| 75 |
sx={{ py: 0.2 }}
|
| 76 |
>
|
| 77 |
-
|
| 78 |
-
{isLast && isRunning ? '' : icon}
|
| 79 |
-
</Typography>
|
| 80 |
-
{isLast && isRunning && (
|
| 81 |
<CircularProgress size={10} thickness={5} sx={{ color: 'var(--accent-yellow)', flexShrink: 0 }} />
|
|
|
|
|
|
|
| 82 |
)}
|
| 83 |
<Typography
|
| 84 |
sx={{
|
| 85 |
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, monospace',
|
| 86 |
fontSize: '0.68rem',
|
| 87 |
-
color: isLast
|
| 88 |
overflow: 'hidden',
|
| 89 |
textOverflow: 'ellipsis',
|
| 90 |
whiteSpace: 'nowrap',
|
|
@@ -132,8 +248,8 @@ function costLabel(hardware: string): string | null {
|
|
| 132 |
// Visual helpers
|
| 133 |
// ---------------------------------------------------------------------------
|
| 134 |
|
| 135 |
-
function StatusIcon({ state, cancelled }: { state: ToolPartState; cancelled?: boolean }) {
|
| 136 |
-
if (cancelled) {
|
| 137 |
return <BlockIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />;
|
| 138 |
}
|
| 139 |
switch (state) {
|
|
@@ -397,11 +513,17 @@ function InlineApproval({
|
|
| 397 |
// ---------------------------------------------------------------------------
|
| 398 |
|
| 399 |
export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProps) {
|
| 400 |
-
const { setPanel, lockPanel, getJobUrl, getEditedScript } = useAgentStore();
|
| 401 |
const researchSteps = useAgentStore(s => {
|
| 402 |
const activeId = s.activeSessionId;
|
| 403 |
return activeId ? (s.sessionStates[activeId]?.researchSteps) : undefined;
|
| 404 |
}) ?? EMPTY_STEPS;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
|
| 406 |
|
| 407 |
// ββ Batch approval state ββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -417,6 +539,9 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
|
|
| 417 |
// Track which toolCallIds we've already submitted so we can detect new approval rounds
|
| 418 |
const submittedIdsRef = useRef<Set<string>>(new Set());
|
| 419 |
|
|
|
|
|
|
|
|
|
|
| 420 |
// Reset submission state when new (unseen) pending tools arrive β e.g. second approval round
|
| 421 |
useEffect(() => {
|
| 422 |
if (!isSubmitting || pendingTools.length === 0) return;
|
|
@@ -428,6 +553,35 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
|
|
| 428 |
}
|
| 429 |
}, [pendingTools, isSubmitting]);
|
| 430 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
const { scriptLabelMap, toolDisplayMap } = useMemo(() => {
|
| 432 |
const hfJobs = tools.filter(t => t.toolName === 'hf_jobs' && (t.input as Record<string, unknown>)?.script);
|
| 433 |
const scriptMap: Record<string, string> = {};
|
|
@@ -463,6 +617,10 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
|
|
| 463 |
if (editedScript) {
|
| 464 |
logger.log(`Sending edited script for ${toolCallId} (${editedScript.length} chars)`);
|
| 465 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
return {
|
| 467 |
tool_call_id: toolCallId,
|
| 468 |
approved: d.approved,
|
|
@@ -482,7 +640,7 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
|
|
| 482 |
setIsSubmitting(false);
|
| 483 |
}
|
| 484 |
},
|
| 485 |
-
[approveTools, lockPanel, getEditedScript],
|
| 486 |
);
|
| 487 |
|
| 488 |
const handleApproveAll = useCallback(() => {
|
|
@@ -518,8 +676,8 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
|
|
| 518 |
});
|
| 519 |
}, []);
|
| 520 |
|
| 521 |
-
// ββ
|
| 522 |
-
const
|
| 523 |
(tool: DynamicToolPart) => {
|
| 524 |
const args = tool.input as Record<string, unknown> | undefined;
|
| 525 |
const displayName = toolDisplayMap[tool.toolCallId] || tool.toolName;
|
|
@@ -545,7 +703,13 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
|
|
| 545 |
const inputSection = args ? { content: JSON.stringify(args, null, 2), language: 'json' } : undefined;
|
| 546 |
|
| 547 |
const outputText = tool.output ?? (tool.state === 'output-error' ? (tool as Record<string, unknown>).errorText : undefined);
|
| 548 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 549 |
let language = 'text';
|
| 550 |
const content = String(outputText);
|
| 551 |
if (content.trim().startsWith('{') || content.trim().startsWith('[')) language = 'json';
|
|
@@ -557,14 +721,79 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
|
|
| 557 |
const content = `Tool \`${tool.toolName}\` returned an error with no output message.`;
|
| 558 |
setPanel({ title: displayName, output: { content, language: 'markdown' }, input: inputSection }, 'output');
|
| 559 |
setRightPanelOpen(true);
|
| 560 |
-
} else if (args) {
|
|
|
|
| 561 |
setPanel({ title: displayName, output: { content: JSON.stringify(args, null, 2), language: 'json' }, input: inputSection }, 'output');
|
| 562 |
setRightPanelOpen(true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
}
|
| 564 |
},
|
| 565 |
[toolDisplayMap, setPanel, getEditedScript, setRightPanelOpen, setLeftSidebarOpen],
|
| 566 |
);
|
| 567 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
// ββ Parse hf_jobs metadata from output ββββββββββββββββββββββββββββ
|
| 569 |
function parseJobMeta(output: unknown): { jobUrl?: string; jobStatus?: string } {
|
| 570 |
if (typeof output !== 'string') return {};
|
|
@@ -651,25 +880,46 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
|
|
| 651 |
const clickable =
|
| 652 |
state === 'output-available' ||
|
| 653 |
state === 'output-error' ||
|
| 654 |
-
!!tool.input
|
|
|
|
| 655 |
const localDecision = decisions[tool.toolCallId];
|
| 656 |
|
| 657 |
const cancelled = isCancelledTool(tool);
|
| 658 |
-
const
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 662 |
|
| 663 |
// Parse job metadata from hf_jobs output and store
|
| 664 |
const jobUrlFromStore = tool.toolName === 'hf_jobs' ? getJobUrl(tool.toolCallId) : undefined;
|
| 665 |
-
const
|
| 666 |
-
|
|
|
|
|
|
|
| 667 |
: {};
|
| 668 |
-
|
| 669 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
const jobMeta = {
|
| 671 |
jobUrl: jobUrlFromStore || jobMetaFromOutput.jobUrl,
|
| 672 |
-
jobStatus: jobMetaFromOutput.jobStatus,
|
| 673 |
};
|
| 674 |
|
| 675 |
return (
|
|
@@ -685,15 +935,20 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
|
|
| 685 |
py: 1,
|
| 686 |
cursor: isPending ? 'default' : clickable ? 'pointer' : 'default',
|
| 687 |
transition: 'background-color 0.15s',
|
|
|
|
|
|
|
| 688 |
'&:hover': clickable && !isPending ? { bgcolor: 'var(--hover-bg)' } : {},
|
| 689 |
}}
|
| 690 |
>
|
| 691 |
<StatusIcon
|
| 692 |
cancelled={cancelled}
|
|
|
|
| 693 |
state={
|
| 694 |
-
|
| 695 |
? 'output-error'
|
| 696 |
-
:
|
|
|
|
|
|
|
| 697 |
}
|
| 698 |
/>
|
| 699 |
|
|
@@ -715,23 +970,37 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
|
|
| 715 |
</Typography>
|
| 716 |
|
| 717 |
{/* Status chip (non hf_jobs, or hf_jobs without final status) */}
|
| 718 |
-
{
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 735 |
|
| 736 |
{/* HF Jobs: final status chip from job metadata */}
|
| 737 |
{tool.toolName === 'hf_jobs' && jobMeta.jobStatus && (
|
|
@@ -785,11 +1054,11 @@ export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProp
|
|
| 785 |
)}
|
| 786 |
</Stack>
|
| 787 |
|
| 788 |
-
{/* Research sub-agent steps */}
|
| 789 |
-
{tool.toolName === 'research' &&
|
| 790 |
<ResearchSteps
|
| 791 |
steps={researchSteps}
|
| 792 |
-
isRunning={
|
| 793 |
/>
|
| 794 |
)}
|
| 795 |
|
|
|
|
| 10 |
import { useAgentStore } from '@/store/agentStore';
|
| 11 |
import { useLayoutStore } from '@/store/layoutStore';
|
| 12 |
import { logger } from '@/utils/logger';
|
| 13 |
+
import { RESEARCH_MAX_STEPS } from '@/lib/research-store';
|
| 14 |
import type { UIMessage } from 'ai';
|
| 15 |
|
| 16 |
// ---------------------------------------------------------------------------
|
|
|
|
| 36 |
// Research sub-steps (inline under the research tool row)
|
| 37 |
// ---------------------------------------------------------------------------
|
| 38 |
|
| 39 |
+
/** Hook that ticks every second while startedAt is set, returning elapsed seconds. */
|
| 40 |
+
function useElapsed(startedAt: number | null): number | null {
|
| 41 |
+
const [elapsed, setElapsed] = useState<number | null>(null);
|
| 42 |
+
useEffect(() => {
|
| 43 |
+
if (startedAt === null) { setElapsed(null); return; }
|
| 44 |
+
setElapsed(Math.round((Date.now() - startedAt) / 1000));
|
| 45 |
+
const id = setInterval(() => setElapsed(Math.round((Date.now() - startedAt) / 1000)), 1000);
|
| 46 |
+
return () => clearInterval(id);
|
| 47 |
+
}, [startedAt]);
|
| 48 |
+
return elapsed;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/** Format token count like the CLI: "12.4k" or "800". */
|
| 52 |
+
function formatTokens(tokens: number): string {
|
| 53 |
+
return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens);
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/** Format elapsed seconds like the CLI: "18s" or "2m 5s". */
|
| 57 |
+
function formatElapsed(seconds: number): string {
|
| 58 |
+
if (seconds < 60) return `${seconds}s`;
|
| 59 |
+
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/** Build the research stats chip label. */
|
| 63 |
+
function researchChipLabel(
|
| 64 |
+
stats: { toolCount: number; tokenCount: number; startedAt: number | null; finalElapsed: number | null },
|
| 65 |
+
liveElapsed: number | null,
|
| 66 |
+
): string | null {
|
| 67 |
+
const elapsed = stats.finalElapsed ?? liveElapsed;
|
| 68 |
+
if (elapsed === null && stats.toolCount === 0) return null;
|
| 69 |
+
const parts: string[] = [];
|
| 70 |
+
if (stats.startedAt !== null) parts.push('running');
|
| 71 |
+
if (stats.toolCount > 0) parts.push(`${stats.toolCount} tools`);
|
| 72 |
+
if (stats.tokenCount > 0) parts.push(`${formatTokens(stats.tokenCount)} tokens`);
|
| 73 |
+
if (elapsed !== null) parts.push(formatElapsed(elapsed));
|
| 74 |
+
return parts.join(' \u00B7 ');
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/** Parse JSON args from a step string like "tool_name {json}" (may be truncated at 80 chars). */
|
| 78 |
+
function parseStepArgs(step: string): Record<string, string> {
|
| 79 |
+
const jsonStart = step.indexOf('{');
|
| 80 |
+
if (jsonStart < 0) return {};
|
| 81 |
+
const jsonStr = step.slice(jsonStart);
|
| 82 |
+
try {
|
| 83 |
+
const parsed = JSON.parse(jsonStr);
|
| 84 |
+
const result: Record<string, string> = {};
|
| 85 |
+
for (const [k, v] of Object.entries(parsed)) {
|
| 86 |
+
if (typeof v === 'string') result[k] = v;
|
| 87 |
+
}
|
| 88 |
+
return result;
|
| 89 |
+
} catch {
|
| 90 |
+
// JSON likely truncated β extract key-value pairs via regex
|
| 91 |
+
const result: Record<string, string> = {};
|
| 92 |
+
// Match complete "key": "value" pairs
|
| 93 |
+
for (const m of jsonStr.matchAll(/"(\w+)":\s*"([^"]*)"/g)) {
|
| 94 |
+
result[m[1]] = m[2];
|
| 95 |
+
}
|
| 96 |
+
// Match truncated trailing value: "key": "value... (no closing quote)
|
| 97 |
+
if (Object.keys(result).length === 0 || !result.query) {
|
| 98 |
+
const trunc = jsonStr.match(/"(\w+)":\s*"([^"]+)$/);
|
| 99 |
+
if (trunc && !result[trunc[1]]) {
|
| 100 |
+
result[trunc[1]] = trunc[2];
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
return result;
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
/** Pretty labels for research sub-agent tool calls */
|
| 108 |
+
function formatResearchStep(raw: string): { label: string } {
|
| 109 |
+
// Backend sends logs like "βΈ tool_name {args}" β strip the prefix
|
| 110 |
+
const step = raw.replace(/^βΈ\s*/, '');
|
| 111 |
+
const args = parseStepArgs(step);
|
| 112 |
+
|
| 113 |
+
if (step.startsWith('github_find_examples')) {
|
| 114 |
+
const detail = (args.keyword) || (args.repo);
|
| 115 |
+
return { label: detail ? `Finding examples: ${detail}` : 'Finding examples' };
|
| 116 |
+
}
|
| 117 |
if (step.startsWith('github_read_file')) {
|
| 118 |
+
const path = (args.path) || '';
|
| 119 |
const filename = path.split('/').pop() || path;
|
| 120 |
+
return { label: filename ? `Reading ${filename}` : 'Reading file' };
|
| 121 |
+
}
|
| 122 |
+
if (step.startsWith('explore_hf_docs')) {
|
| 123 |
+
const endpoint = (args.endpoint) || (args.query);
|
| 124 |
+
return { label: endpoint ? `Exploring docs: ${endpoint}` : 'Exploring docs' };
|
| 125 |
+
}
|
| 126 |
+
if (step.startsWith('fetch_hf_docs')) {
|
| 127 |
+
const url = (args.url) || '';
|
| 128 |
+
const page = url.split('/').pop()?.replace(/\.md$/, '');
|
| 129 |
+
return { label: page ? `Reading docs: ${page}` : 'Fetching docs' };
|
| 130 |
+
}
|
| 131 |
+
if (step.startsWith('hf_inspect_dataset')) {
|
| 132 |
+
const dataset = (args.dataset);
|
| 133 |
+
return { label: dataset ? `Inspecting dataset: ${dataset}` : 'Inspecting dataset' };
|
| 134 |
}
|
| 135 |
+
if (step.startsWith('hf_papers')) {
|
| 136 |
+
const op = args.operation as string;
|
| 137 |
+
const detail = (args.query) || (args.arxiv_id);
|
| 138 |
+
const opLabels: Record<string, string> = {
|
| 139 |
+
trending: 'Browsing trending papers',
|
| 140 |
+
search: 'Searching papers',
|
| 141 |
+
paper_details: 'Reading paper details',
|
| 142 |
+
read_paper: 'Reading paper',
|
| 143 |
+
citation_graph: 'Tracing citations',
|
| 144 |
+
snippet_search: 'Searching paper snippets',
|
| 145 |
+
recommend: 'Finding related papers',
|
| 146 |
+
find_datasets: 'Finding paper datasets',
|
| 147 |
+
find_models: 'Finding paper models',
|
| 148 |
+
find_collections: 'Finding paper collections',
|
| 149 |
+
find_all_resources: 'Finding paper resources',
|
| 150 |
+
};
|
| 151 |
+
const base = (op && opLabels[op]) || 'Searching papers';
|
| 152 |
+
return { label: detail ? `${base}: ${detail}` : base };
|
| 153 |
+
}
|
| 154 |
+
if (step.startsWith('find_hf_api')) {
|
| 155 |
+
const detail = (args.query) || (args.tag);
|
| 156 |
+
return { label: detail ? `Finding API: ${detail}` : 'Finding API endpoints' };
|
| 157 |
+
}
|
| 158 |
+
if (step.startsWith('hf_repo_files')) {
|
| 159 |
+
const repo = (args.repo_id) || (args.repo);
|
| 160 |
+
return { label: repo ? `Reading ${repo} files` : 'Reading repo files' };
|
| 161 |
+
}
|
| 162 |
+
if (step.startsWith('read')) {
|
| 163 |
+
const path = (args.path) || '';
|
| 164 |
+
const filename = path.split('/').pop();
|
| 165 |
+
return { label: filename ? `Reading ${filename}` : 'Reading file' };
|
| 166 |
+
}
|
| 167 |
+
if (step.startsWith('bash')) {
|
| 168 |
+
const cmd = args.command as string;
|
| 169 |
+
const short = cmd && cmd.length > 40 ? cmd.slice(0, 40) + '...' : cmd;
|
| 170 |
+
return { label: short ? `Running: ${short}` : 'Running command' };
|
| 171 |
+
}
|
| 172 |
+
return { label: step.replace(/^βΈ\s*/, '') };
|
| 173 |
}
|
| 174 |
|
| 175 |
+
/** Rolling 2-line display of research sub-tool calls β hidden when complete. */
|
| 176 |
function ResearchSteps({ steps, isRunning }: { steps: string[]; isRunning: boolean }) {
|
| 177 |
+
if (!isRunning) return null;
|
| 178 |
+
const visible = steps.slice(-RESEARCH_MAX_STEPS);
|
| 179 |
+
if (visible.length === 0) return null;
|
|
|
|
|
|
|
| 180 |
|
| 181 |
return (
|
| 182 |
<Box sx={{ pl: 4.5, pr: 1.5, pb: 1, pt: 0.25 }}>
|
| 183 |
+
{visible.map((step, i) => {
|
| 184 |
+
const { label } = formatResearchStep(step);
|
| 185 |
+
const isLast = i === visible.length - 1;
|
| 186 |
return (
|
| 187 |
<Stack
|
| 188 |
key={i}
|
|
|
|
| 191 |
spacing={0.75}
|
| 192 |
sx={{ py: 0.2 }}
|
| 193 |
>
|
| 194 |
+
{isLast ? (
|
|
|
|
|
|
|
|
|
|
| 195 |
<CircularProgress size={10} thickness={5} sx={{ color: 'var(--accent-yellow)', flexShrink: 0 }} />
|
| 196 |
+
) : (
|
| 197 |
+
<CheckCircleOutlineIcon sx={{ fontSize: 12, color: 'var(--muted-text)', flexShrink: 0 }} />
|
| 198 |
)}
|
| 199 |
<Typography
|
| 200 |
sx={{
|
| 201 |
fontFamily: '"JetBrains Mono", ui-monospace, SFMono-Regular, monospace',
|
| 202 |
fontSize: '0.68rem',
|
| 203 |
+
color: isLast ? 'var(--text)' : 'var(--muted-text)',
|
| 204 |
overflow: 'hidden',
|
| 205 |
textOverflow: 'ellipsis',
|
| 206 |
whiteSpace: 'nowrap',
|
|
|
|
| 248 |
// Visual helpers
|
| 249 |
// ---------------------------------------------------------------------------
|
| 250 |
|
| 251 |
+
function StatusIcon({ state, cancelled, isRejected }: { state: ToolPartState; cancelled?: boolean; isRejected?: boolean }) {
|
| 252 |
+
if (cancelled || isRejected) {
|
| 253 |
return <BlockIcon sx={{ fontSize: 16, color: 'var(--muted-text)' }} />;
|
| 254 |
}
|
| 255 |
switch (state) {
|
|
|
|
| 513 |
// ---------------------------------------------------------------------------
|
| 514 |
|
| 515 |
export default function ToolCallGroup({ tools, approveTools }: ToolCallGroupProps) {
|
| 516 |
+
const { setPanel, lockPanel, getJobUrl, getEditedScript, setJobStatus, getJobStatus, setToolError, getToolError, setToolRejected, getToolRejected } = useAgentStore();
|
| 517 |
const researchSteps = useAgentStore(s => {
|
| 518 |
const activeId = s.activeSessionId;
|
| 519 |
return activeId ? (s.sessionStates[activeId]?.researchSteps) : undefined;
|
| 520 |
}) ?? EMPTY_STEPS;
|
| 521 |
+
const researchStats = useAgentStore(s => {
|
| 522 |
+
const activeId = s.activeSessionId;
|
| 523 |
+
return activeId ? s.sessionStates[activeId]?.researchStats : undefined;
|
| 524 |
+
}) ?? { toolCount: 0, tokenCount: 0, startedAt: null, finalElapsed: null };
|
| 525 |
+
const liveElapsed = useElapsed(researchStats.startedAt);
|
| 526 |
+
const isProcessing = useAgentStore(s => s.isProcessing);
|
| 527 |
const { setRightPanelOpen, setLeftSidebarOpen } = useLayoutStore();
|
| 528 |
|
| 529 |
// ββ Batch approval state ββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 539 |
// Track which toolCallIds we've already submitted so we can detect new approval rounds
|
| 540 |
const submittedIdsRef = useRef<Set<string>>(new Set());
|
| 541 |
|
| 542 |
+
// ββ Panel lock state (for auto-follow vs user-selected) βββββββββββ
|
| 543 |
+
const [lockedToolId, setLockedToolId] = useState<string | null>(null);
|
| 544 |
+
|
| 545 |
// Reset submission state when new (unseen) pending tools arrive β e.g. second approval round
|
| 546 |
useEffect(() => {
|
| 547 |
if (!isSubmitting || pendingTools.length === 0) return;
|
|
|
|
| 553 |
}
|
| 554 |
}, [pendingTools, isSubmitting]);
|
| 555 |
|
| 556 |
+
// Clean up stale decisions for tools that are no longer pending
|
| 557 |
+
useEffect(() => {
|
| 558 |
+
const pendingIds = new Set(pendingTools.map(t => t.toolCallId));
|
| 559 |
+
const decisionIds = Object.keys(decisions);
|
| 560 |
+
const hasStale = decisionIds.some(id => !pendingIds.has(id));
|
| 561 |
+
if (hasStale) {
|
| 562 |
+
setDecisions(prev => {
|
| 563 |
+
const cleaned = { ...prev };
|
| 564 |
+
for (const id of decisionIds) {
|
| 565 |
+
if (!pendingIds.has(id)) delete cleaned[id];
|
| 566 |
+
}
|
| 567 |
+
return cleaned;
|
| 568 |
+
});
|
| 569 |
+
}
|
| 570 |
+
}, [pendingTools, decisions]);
|
| 571 |
+
|
| 572 |
+
// Persist error states when tools error
|
| 573 |
+
useEffect(() => {
|
| 574 |
+
for (const tool of tools) {
|
| 575 |
+
const currentlyHasError = tool.state === 'output-error';
|
| 576 |
+
const persistedError = getToolError(tool.toolCallId);
|
| 577 |
+
|
| 578 |
+
// Persist error state if we detect it and haven't already
|
| 579 |
+
if (currentlyHasError && !persistedError) {
|
| 580 |
+
setToolError(tool.toolCallId, true);
|
| 581 |
+
}
|
| 582 |
+
}
|
| 583 |
+
}, [tools, setToolError, getToolError]);
|
| 584 |
+
|
| 585 |
const { scriptLabelMap, toolDisplayMap } = useMemo(() => {
|
| 586 |
const hfJobs = tools.filter(t => t.toolName === 'hf_jobs' && (t.input as Record<string, unknown>)?.script);
|
| 587 |
const scriptMap: Record<string, string> = {};
|
|
|
|
| 617 |
if (editedScript) {
|
| 618 |
logger.log(`Sending edited script for ${toolCallId} (${editedScript.length} chars)`);
|
| 619 |
}
|
| 620 |
+
// Mark tool as rejected if not approved
|
| 621 |
+
if (!d.approved) {
|
| 622 |
+
setToolRejected(toolCallId, true);
|
| 623 |
+
}
|
| 624 |
return {
|
| 625 |
tool_call_id: toolCallId,
|
| 626 |
approved: d.approved,
|
|
|
|
| 640 |
setIsSubmitting(false);
|
| 641 |
}
|
| 642 |
},
|
| 643 |
+
[approveTools, lockPanel, getEditedScript, setToolRejected],
|
| 644 |
);
|
| 645 |
|
| 646 |
const handleApproveAll = useCallback(() => {
|
|
|
|
| 676 |
});
|
| 677 |
}, []);
|
| 678 |
|
| 679 |
+
// ββ Show tool panel (shared logic) ββββββββββββββββββββββββββββββββ
|
| 680 |
+
const showToolPanel = useCallback(
|
| 681 |
(tool: DynamicToolPart) => {
|
| 682 |
const args = tool.input as Record<string, unknown> | undefined;
|
| 683 |
const displayName = toolDisplayMap[tool.toolCallId] || tool.toolName;
|
|
|
|
| 703 |
const inputSection = args ? { content: JSON.stringify(args, null, 2), language: 'json' } : undefined;
|
| 704 |
|
| 705 |
const outputText = tool.output ?? (tool.state === 'output-error' ? (tool as Record<string, unknown>).errorText : undefined);
|
| 706 |
+
|
| 707 |
+
// Determine if tool is still running or has completed
|
| 708 |
+
const isRunning = tool.state === 'input-available' || tool.state === 'input-streaming' || tool.state === 'approval-responded';
|
| 709 |
+
const hasCompleted = tool.state === 'output-available' || tool.state === 'output-error' || tool.state === 'output-denied';
|
| 710 |
+
|
| 711 |
+
if (outputText) {
|
| 712 |
+
// Tool has output - show it (regardless of state)
|
| 713 |
let language = 'text';
|
| 714 |
const content = String(outputText);
|
| 715 |
if (content.trim().startsWith('{') || content.trim().startsWith('[')) language = 'json';
|
|
|
|
| 721 |
const content = `Tool \`${tool.toolName}\` returned an error with no output message.`;
|
| 722 |
setPanel({ title: displayName, output: { content, language: 'markdown' }, input: inputSection }, 'output');
|
| 723 |
setRightPanelOpen(true);
|
| 724 |
+
} else if (hasCompleted && args) {
|
| 725 |
+
// Tool completed but has no output - show input as fallback
|
| 726 |
setPanel({ title: displayName, output: { content: JSON.stringify(args, null, 2), language: 'json' }, input: inputSection }, 'output');
|
| 727 |
setRightPanelOpen(true);
|
| 728 |
+
} else if (isRunning && args) {
|
| 729 |
+
// Tool is still running - show running message
|
| 730 |
+
const content = `Tool \`${tool.toolName}\` is still running...\n\nClick the input tab to view the tool arguments.`;
|
| 731 |
+
setPanel({ title: displayName, output: { content, language: 'markdown' }, input: inputSection }, 'output');
|
| 732 |
+
setRightPanelOpen(true);
|
| 733 |
+
} else if (args) {
|
| 734 |
+
const runningMessages = [
|
| 735 |
+
'Crunching numbers and herding tensors...',
|
| 736 |
+
'Teaching the model some new tricks...',
|
| 737 |
+
'Consulting the GPU oracle...',
|
| 738 |
+
'Wrangling data into submission...',
|
| 739 |
+
'Brewing a fresh batch of predictions...',
|
| 740 |
+
'Negotiating with the transformer heads...',
|
| 741 |
+
'Polishing the attention weights...',
|
| 742 |
+
'Aligning the embedding stars...',
|
| 743 |
+
];
|
| 744 |
+
const funMsg = runningMessages[Math.floor(Math.random() * runningMessages.length)];
|
| 745 |
+
setPanel({ title: displayName, output: { content: funMsg, language: 'text' }, input: inputSection }, 'output');
|
| 746 |
+
setRightPanelOpen(true);
|
| 747 |
}
|
| 748 |
},
|
| 749 |
[toolDisplayMap, setPanel, getEditedScript, setRightPanelOpen, setLeftSidebarOpen],
|
| 750 |
);
|
| 751 |
|
| 752 |
+
// ββ Panel click handler βββββββββββββββββββββββββββββββββββββββββββ
|
| 753 |
+
const handleClick = useCallback(
|
| 754 |
+
(tool: DynamicToolPart) => {
|
| 755 |
+
// Toggle lock: if clicking the same tool that's already locked, unlock it
|
| 756 |
+
if (lockedToolId === tool.toolCallId) {
|
| 757 |
+
setLockedToolId(null);
|
| 758 |
+
return;
|
| 759 |
+
}
|
| 760 |
+
|
| 761 |
+
// Lock this tool
|
| 762 |
+
setLockedToolId(tool.toolCallId);
|
| 763 |
+
|
| 764 |
+
// Show the panel
|
| 765 |
+
showToolPanel(tool);
|
| 766 |
+
},
|
| 767 |
+
[lockedToolId, showToolPanel],
|
| 768 |
+
);
|
| 769 |
+
|
| 770 |
+
// ββ Auto-follow currently active tool when not locked βββββββββββββ
|
| 771 |
+
const activeToolIdRef = useRef<string | null>(null);
|
| 772 |
+
|
| 773 |
+
useEffect(() => {
|
| 774 |
+
if (lockedToolId !== null) return; // User has locked a tool, don't auto-follow
|
| 775 |
+
|
| 776 |
+
// Find the currently running tool (latest tool that's in progress)
|
| 777 |
+
const runningTool = tools.slice().reverse().find(t =>
|
| 778 |
+
t.state === 'input-available' ||
|
| 779 |
+
t.state === 'input-streaming' ||
|
| 780 |
+
t.state === 'approval-responded'
|
| 781 |
+
);
|
| 782 |
+
|
| 783 |
+
if (runningTool) {
|
| 784 |
+
// Track this as the active tool and show its panel
|
| 785 |
+
activeToolIdRef.current = runningTool.toolCallId;
|
| 786 |
+
showToolPanel(runningTool);
|
| 787 |
+
} else if (activeToolIdRef.current) {
|
| 788 |
+
// No running tool, but we were following one - check if it completed
|
| 789 |
+
const completedTool = tools.find(t => t.toolCallId === activeToolIdRef.current);
|
| 790 |
+
if (completedTool && (completedTool.state === 'output-available' || completedTool.state === 'output-error')) {
|
| 791 |
+
// The tool we were following has completed - update its panel
|
| 792 |
+
showToolPanel(completedTool);
|
| 793 |
+
}
|
| 794 |
+
}
|
| 795 |
+
}, [tools, lockedToolId, showToolPanel]);
|
| 796 |
+
|
| 797 |
// ββ Parse hf_jobs metadata from output ββββββββββββββββββββββββββββ
|
| 798 |
function parseJobMeta(output: unknown): { jobUrl?: string; jobStatus?: string } {
|
| 799 |
if (typeof output !== 'string') return {};
|
|
|
|
| 880 |
const clickable =
|
| 881 |
state === 'output-available' ||
|
| 882 |
state === 'output-error' ||
|
| 883 |
+
!!tool.input ||
|
| 884 |
+
(!isProcessing && (state === 'input-available' || state === 'input-streaming'));
|
| 885 |
const localDecision = decisions[tool.toolCallId];
|
| 886 |
|
| 887 |
const cancelled = isCancelledTool(tool);
|
| 888 |
+
const currentlyHasError = state === 'output-error';
|
| 889 |
+
const persistedError = getToolError(tool.toolCallId);
|
| 890 |
+
const persistedRejection = getToolRejected(tool.toolCallId);
|
| 891 |
+
|
| 892 |
+
// Stale in-progress tools after page reload: treat as completed
|
| 893 |
+
const stale = !isProcessing && (state === 'input-available' || state === 'input-streaming');
|
| 894 |
+
const displayState = stale ? 'output-available'
|
| 895 |
+
: isPending && localDecision
|
| 896 |
+
? (localDecision.approved ? 'input-available' : 'output-denied')
|
| 897 |
+
: state;
|
| 898 |
+
const isRejected = displayState === 'output-denied' || persistedRejection;
|
| 899 |
+
const hasError = (persistedError || currentlyHasError) && !isRejected;
|
| 900 |
+
const label = cancelled ? 'cancelled'
|
| 901 |
+
: isRejected ? 'rejected'
|
| 902 |
+
: hasError ? 'error'
|
| 903 |
+
: statusLabel(displayState as ToolPartState);
|
| 904 |
|
| 905 |
// Parse job metadata from hf_jobs output and store
|
| 906 |
const jobUrlFromStore = tool.toolName === 'hf_jobs' ? getJobUrl(tool.toolCallId) : undefined;
|
| 907 |
+
const jobStatusFromStore = tool.toolName === 'hf_jobs' ? getJobStatus(tool.toolCallId) : undefined;
|
| 908 |
+
|
| 909 |
+
const jobMetaFromOutput = tool.toolName === 'hf_jobs' && (tool.output || (tool as Record<string, unknown>).errorText)
|
| 910 |
+
? parseJobMeta(tool.output ?? (tool as Record<string, unknown>).errorText)
|
| 911 |
: {};
|
| 912 |
+
|
| 913 |
+
// Store job status if we just parsed it and don't have it stored yet
|
| 914 |
+
if (tool.toolName === 'hf_jobs' && jobMetaFromOutput.jobStatus && !jobStatusFromStore) {
|
| 915 |
+
setJobStatus(tool.toolCallId, jobMetaFromOutput.jobStatus);
|
| 916 |
+
}
|
| 917 |
+
|
| 918 |
+
// Combine job URL and status from store (persisted) with output metadata (freshly parsed)
|
| 919 |
+
// Prefer stored values to ensure they persist across renders
|
| 920 |
const jobMeta = {
|
| 921 |
jobUrl: jobUrlFromStore || jobMetaFromOutput.jobUrl,
|
| 922 |
+
jobStatus: jobStatusFromStore || jobMetaFromOutput.jobStatus,
|
| 923 |
};
|
| 924 |
|
| 925 |
return (
|
|
|
|
| 935 |
py: 1,
|
| 936 |
cursor: isPending ? 'default' : clickable ? 'pointer' : 'default',
|
| 937 |
transition: 'background-color 0.15s',
|
| 938 |
+
bgcolor: lockedToolId === tool.toolCallId ? 'var(--hover-bg)' : 'transparent',
|
| 939 |
+
borderLeft: lockedToolId === tool.toolCallId ? '3px solid var(--accent-yellow)' : '3px solid transparent',
|
| 940 |
'&:hover': clickable && !isPending ? { bgcolor: 'var(--hover-bg)' } : {},
|
| 941 |
}}
|
| 942 |
>
|
| 943 |
<StatusIcon
|
| 944 |
cancelled={cancelled}
|
| 945 |
+
isRejected={isRejected}
|
| 946 |
state={
|
| 947 |
+
hasError
|
| 948 |
? 'output-error'
|
| 949 |
+
: ((tool.toolName === 'hf_jobs' && jobMeta.jobStatus && ['ERROR', 'FAILED', 'CANCELLED'].includes(jobMeta.jobStatus))
|
| 950 |
+
? 'output-error'
|
| 951 |
+
: displayState as ToolPartState)
|
| 952 |
}
|
| 953 |
/>
|
| 954 |
|
|
|
|
| 970 |
</Typography>
|
| 971 |
|
| 972 |
{/* Status chip (non hf_jobs, or hf_jobs without final status) */}
|
| 973 |
+
{(() => {
|
| 974 |
+
// Research tool: override chip label with live stats (but not if cancelled/done)
|
| 975 |
+
const researchDone = cancelled || state === 'output-available' || state === 'output-error' || state === 'output-denied';
|
| 976 |
+
const researchLabel = tool.toolName === 'research' && !researchDone
|
| 977 |
+
? researchChipLabel(researchStats, liveElapsed)
|
| 978 |
+
: (tool.toolName === 'research' && researchDone && researchStats.finalElapsed !== null)
|
| 979 |
+
? researchChipLabel({ ...researchStats, startedAt: null }, null)
|
| 980 |
+
: null;
|
| 981 |
+
const chipLabel = researchLabel || label;
|
| 982 |
+
if (!chipLabel || (tool.toolName === 'hf_jobs' && jobMeta.jobStatus)) return null;
|
| 983 |
+
|
| 984 |
+
return (
|
| 985 |
+
<Chip
|
| 986 |
+
label={chipLabel}
|
| 987 |
+
size="small"
|
| 988 |
+
sx={{
|
| 989 |
+
height: 20,
|
| 990 |
+
fontSize: '0.65rem',
|
| 991 |
+
fontWeight: 600,
|
| 992 |
+
bgcolor: (cancelled || isRejected) ? 'rgba(255,255,255,0.05)'
|
| 993 |
+
: hasError ? 'rgba(224,90,79,0.12)'
|
| 994 |
+
: (researchLabel && displayState === 'output-available') ? 'rgba(47,204,113,0.12)'
|
| 995 |
+
: 'var(--accent-yellow-weak)',
|
| 996 |
+
color: (cancelled || isRejected) ? 'var(--muted-text)'
|
| 997 |
+
: hasError ? 'var(--accent-red)'
|
| 998 |
+
: statusColor(displayState as ToolPartState),
|
| 999 |
+
letterSpacing: '0.03em',
|
| 1000 |
+
}}
|
| 1001 |
+
/>
|
| 1002 |
+
);
|
| 1003 |
+
})()}
|
| 1004 |
|
| 1005 |
{/* HF Jobs: final status chip from job metadata */}
|
| 1006 |
{tool.toolName === 'hf_jobs' && jobMeta.jobStatus && (
|
|
|
|
| 1054 |
)}
|
| 1055 |
</Stack>
|
| 1056 |
|
| 1057 |
+
{/* Research sub-agent rolling steps (visible only while running) */}
|
| 1058 |
+
{tool.toolName === 'research' && !cancelled && state !== 'output-available' && state !== 'output-error' && state !== 'output-denied' && (
|
| 1059 |
<ResearchSteps
|
| 1060 |
steps={researchSteps}
|
| 1061 |
+
isRunning={researchStats.startedAt !== null}
|
| 1062 |
/>
|
| 1063 |
)}
|
| 1064 |
|
frontend/src/components/Chat/UserMessage.tsx
CHANGED
|
@@ -1,5 +1,8 @@
|
|
| 1 |
-
import {
|
|
|
|
| 2 |
import CloseIcon from '@mui/icons-material/Close';
|
|
|
|
|
|
|
| 3 |
import type { UIMessage } from 'ai';
|
| 4 |
import type { MessageMeta } from '@/types/agent';
|
| 5 |
|
|
@@ -7,6 +10,7 @@ interface UserMessageProps {
|
|
| 7 |
message: UIMessage;
|
| 8 |
isLastTurn?: boolean;
|
| 9 |
onUndoTurn?: () => void;
|
|
|
|
| 10 |
isProcessing?: boolean;
|
| 11 |
}
|
| 12 |
|
|
@@ -21,14 +25,57 @@ export default function UserMessage({
|
|
| 21 |
message,
|
| 22 |
isLastTurn = false,
|
| 23 |
onUndoTurn,
|
|
|
|
| 24 |
isProcessing = false,
|
| 25 |
}: UserMessageProps) {
|
| 26 |
const showUndo = isLastTurn && !isProcessing && !!onUndoTurn;
|
|
|
|
| 27 |
const text = extractText(message);
|
| 28 |
const meta = message.metadata as MessageMeta | undefined;
|
| 29 |
const timeStr = meta?.createdAt
|
| 30 |
? new Date(meta.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
| 31 |
: null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
return (
|
| 33 |
<Stack
|
| 34 |
direction="row"
|
|
@@ -36,35 +83,56 @@ export default function UserMessage({
|
|
| 36 |
justifyContent="flex-end"
|
| 37 |
alignItems="flex-start"
|
| 38 |
sx={{
|
| 39 |
-
'& .
|
| 40 |
opacity: 0,
|
| 41 |
transition: 'opacity 0.15s ease',
|
| 42 |
},
|
| 43 |
-
'&:hover .
|
| 44 |
opacity: 1,
|
| 45 |
},
|
| 46 |
}}
|
| 47 |
>
|
| 48 |
-
{showUndo && (
|
| 49 |
-
<
|
| 50 |
-
|
| 51 |
-
<
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
)}
|
| 69 |
|
| 70 |
<Box
|
|
@@ -78,20 +146,66 @@ export default function UserMessage({
|
|
| 78 |
border: '1px solid var(--border)',
|
| 79 |
}}
|
| 80 |
>
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
-
{timeStr && (
|
| 95 |
<Typography
|
| 96 |
variant="caption"
|
| 97 |
sx={{ color: 'var(--muted-text)', mt: 0.5, display: 'block', textAlign: 'right', fontSize: '0.7rem' }}
|
|
|
|
| 1 |
+
import { useState, useRef, useEffect } from 'react';
|
| 2 |
+
import { Box, Stack, Typography, IconButton, Tooltip, TextField } from '@mui/material';
|
| 3 |
import CloseIcon from '@mui/icons-material/Close';
|
| 4 |
+
import EditIcon from '@mui/icons-material/Edit';
|
| 5 |
+
import CheckIcon from '@mui/icons-material/Check';
|
| 6 |
import type { UIMessage } from 'ai';
|
| 7 |
import type { MessageMeta } from '@/types/agent';
|
| 8 |
|
|
|
|
| 10 |
message: UIMessage;
|
| 11 |
isLastTurn?: boolean;
|
| 12 |
onUndoTurn?: () => void;
|
| 13 |
+
onEditAndRegenerate?: (messageId: string, newText: string) => void | Promise<void>;
|
| 14 |
isProcessing?: boolean;
|
| 15 |
}
|
| 16 |
|
|
|
|
| 25 |
message,
|
| 26 |
isLastTurn = false,
|
| 27 |
onUndoTurn,
|
| 28 |
+
onEditAndRegenerate,
|
| 29 |
isProcessing = false,
|
| 30 |
}: UserMessageProps) {
|
| 31 |
const showUndo = isLastTurn && !isProcessing && !!onUndoTurn;
|
| 32 |
+
const showEdit = !isProcessing && !!onEditAndRegenerate;
|
| 33 |
const text = extractText(message);
|
| 34 |
const meta = message.metadata as MessageMeta | undefined;
|
| 35 |
const timeStr = meta?.createdAt
|
| 36 |
? new Date(meta.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
| 37 |
: null;
|
| 38 |
+
|
| 39 |
+
const [isEditing, setIsEditing] = useState(false);
|
| 40 |
+
const [editText, setEditText] = useState(text);
|
| 41 |
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
| 42 |
+
|
| 43 |
+
useEffect(() => {
|
| 44 |
+
if (isEditing && inputRef.current) {
|
| 45 |
+
inputRef.current.focus();
|
| 46 |
+
inputRef.current.selectionStart = inputRef.current.value.length;
|
| 47 |
+
}
|
| 48 |
+
}, [isEditing]);
|
| 49 |
+
|
| 50 |
+
const handleStartEdit = () => {
|
| 51 |
+
setEditText(text);
|
| 52 |
+
setIsEditing(true);
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
const handleConfirmEdit = () => {
|
| 56 |
+
const trimmed = editText.trim();
|
| 57 |
+
if (!trimmed || trimmed === text) {
|
| 58 |
+
setIsEditing(false);
|
| 59 |
+
return;
|
| 60 |
+
}
|
| 61 |
+
setIsEditing(false);
|
| 62 |
+
onEditAndRegenerate?.(message.id, trimmed);
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const handleCancelEdit = () => {
|
| 66 |
+
setIsEditing(false);
|
| 67 |
+
setEditText(text);
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
| 71 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 72 |
+
e.preventDefault();
|
| 73 |
+
handleConfirmEdit();
|
| 74 |
+
} else if (e.key === 'Escape') {
|
| 75 |
+
handleCancelEdit();
|
| 76 |
+
}
|
| 77 |
+
};
|
| 78 |
+
|
| 79 |
return (
|
| 80 |
<Stack
|
| 81 |
direction="row"
|
|
|
|
| 83 |
justifyContent="flex-end"
|
| 84 |
alignItems="flex-start"
|
| 85 |
sx={{
|
| 86 |
+
'& .action-btn': {
|
| 87 |
opacity: 0,
|
| 88 |
transition: 'opacity 0.15s ease',
|
| 89 |
},
|
| 90 |
+
'&:hover .action-btn': {
|
| 91 |
opacity: 1,
|
| 92 |
},
|
| 93 |
}}
|
| 94 |
>
|
| 95 |
+
{!isEditing && (showUndo || showEdit) && (
|
| 96 |
+
<Stack className="action-btn" direction="row" spacing={0.25} sx={{ mt: 0.75 }}>
|
| 97 |
+
{showEdit && (
|
| 98 |
+
<Tooltip title="Edit & regenerate" placement="left">
|
| 99 |
+
<IconButton
|
| 100 |
+
onClick={handleStartEdit}
|
| 101 |
+
size="small"
|
| 102 |
+
sx={{
|
| 103 |
+
width: 24,
|
| 104 |
+
height: 24,
|
| 105 |
+
color: 'var(--muted-text)',
|
| 106 |
+
'&:hover': {
|
| 107 |
+
color: 'var(--accent-yellow)',
|
| 108 |
+
bgcolor: 'rgba(255,157,0,0.08)',
|
| 109 |
+
},
|
| 110 |
+
}}
|
| 111 |
+
>
|
| 112 |
+
<EditIcon sx={{ fontSize: 14 }} />
|
| 113 |
+
</IconButton>
|
| 114 |
+
</Tooltip>
|
| 115 |
+
)}
|
| 116 |
+
{showUndo && (
|
| 117 |
+
<Tooltip title="Remove this turn" placement="left">
|
| 118 |
+
<IconButton
|
| 119 |
+
onClick={onUndoTurn}
|
| 120 |
+
size="small"
|
| 121 |
+
sx={{
|
| 122 |
+
width: 24,
|
| 123 |
+
height: 24,
|
| 124 |
+
color: 'var(--muted-text)',
|
| 125 |
+
'&:hover': {
|
| 126 |
+
color: 'var(--accent-red)',
|
| 127 |
+
bgcolor: 'rgba(244,67,54,0.08)',
|
| 128 |
+
},
|
| 129 |
+
}}
|
| 130 |
+
>
|
| 131 |
+
<CloseIcon sx={{ fontSize: 14 }} />
|
| 132 |
+
</IconButton>
|
| 133 |
+
</Tooltip>
|
| 134 |
+
)}
|
| 135 |
+
</Stack>
|
| 136 |
)}
|
| 137 |
|
| 138 |
<Box
|
|
|
|
| 146 |
border: '1px solid var(--border)',
|
| 147 |
}}
|
| 148 |
>
|
| 149 |
+
{isEditing ? (
|
| 150 |
+
<Stack spacing={1}>
|
| 151 |
+
<TextField
|
| 152 |
+
inputRef={inputRef}
|
| 153 |
+
multiline
|
| 154 |
+
fullWidth
|
| 155 |
+
value={editText}
|
| 156 |
+
onChange={(e) => setEditText(e.target.value)}
|
| 157 |
+
onKeyDown={handleKeyDown}
|
| 158 |
+
variant="outlined"
|
| 159 |
+
size="small"
|
| 160 |
+
sx={{
|
| 161 |
+
'& .MuiOutlinedInput-root': {
|
| 162 |
+
fontFamily: 'inherit',
|
| 163 |
+
fontSize: '0.925rem',
|
| 164 |
+
lineHeight: 1.65,
|
| 165 |
+
color: 'var(--text)',
|
| 166 |
+
'& fieldset': { borderColor: 'var(--accent-yellow)', borderWidth: 1.5 },
|
| 167 |
+
'&:hover fieldset': { borderColor: 'var(--accent-yellow)' },
|
| 168 |
+
'&.Mui-focused fieldset': { borderColor: 'var(--accent-yellow)' },
|
| 169 |
+
},
|
| 170 |
+
}}
|
| 171 |
+
/>
|
| 172 |
+
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
| 173 |
+
<Tooltip title="Cancel (Esc)">
|
| 174 |
+
<IconButton
|
| 175 |
+
onClick={handleCancelEdit}
|
| 176 |
+
size="small"
|
| 177 |
+
sx={{ color: 'var(--muted-text)', '&:hover': { color: 'var(--accent-red)' } }}
|
| 178 |
+
>
|
| 179 |
+
<CloseIcon sx={{ fontSize: 16 }} />
|
| 180 |
+
</IconButton>
|
| 181 |
+
</Tooltip>
|
| 182 |
+
<Tooltip title="Confirm (Enter)">
|
| 183 |
+
<IconButton
|
| 184 |
+
onClick={handleConfirmEdit}
|
| 185 |
+
size="small"
|
| 186 |
+
sx={{ color: 'var(--accent-green)', '&:hover': { bgcolor: 'rgba(47,204,113,0.1)' } }}
|
| 187 |
+
>
|
| 188 |
+
<CheckIcon sx={{ fontSize: 16 }} />
|
| 189 |
+
</IconButton>
|
| 190 |
+
</Tooltip>
|
| 191 |
+
</Stack>
|
| 192 |
+
</Stack>
|
| 193 |
+
) : (
|
| 194 |
+
<Typography
|
| 195 |
+
variant="body1"
|
| 196 |
+
sx={{
|
| 197 |
+
fontSize: '0.925rem',
|
| 198 |
+
lineHeight: 1.65,
|
| 199 |
+
color: 'var(--text)',
|
| 200 |
+
whiteSpace: 'pre-wrap',
|
| 201 |
+
wordBreak: 'break-word',
|
| 202 |
+
}}
|
| 203 |
+
>
|
| 204 |
+
{text}
|
| 205 |
+
</Typography>
|
| 206 |
+
)}
|
| 207 |
|
| 208 |
+
{timeStr && !isEditing && (
|
| 209 |
<Typography
|
| 210 |
variant="caption"
|
| 211 |
sx={{ color: 'var(--muted-text)', mt: 0.5, display: 'block', textAlign: 'right', fontSize: '0.7rem' }}
|
frontend/src/components/SessionChat.tsx
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
* runs β processing events β but only the active session renders visible
|
| 6 |
* UI (MessageList + ChatInput).
|
| 7 |
*/
|
| 8 |
-
import { useCallback, useEffect
|
| 9 |
import { useAgentChat } from '@/hooks/useAgentChat';
|
| 10 |
import { useAgentStore } from '@/store/agentStore';
|
| 11 |
import { useSessionStore } from '@/store/sessionStore';
|
|
@@ -24,9 +24,7 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
|
|
| 24 |
const { isConnected, isProcessing, activityStatus, updateSession } = useAgentStore();
|
| 25 |
const { updateSessionTitle } = useSessionStore();
|
| 26 |
|
| 27 |
-
const
|
| 28 |
-
|
| 29 |
-
const { messages, sendMessage, stop, status, undoLastTurn, approveTools } = useAgentChat({
|
| 30 |
sessionId,
|
| 31 |
isActive,
|
| 32 |
onReady: () => logger.log(`Session ${sessionId} ready`),
|
|
@@ -57,11 +55,11 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
|
|
| 57 |
return () => document.removeEventListener('visibilitychange', onVisible);
|
| 58 |
}, [isActive, sessionId]);
|
| 59 |
|
| 60 |
-
// Wrap stop to
|
| 61 |
const handleStop = useCallback(() => {
|
| 62 |
stop();
|
| 63 |
-
|
| 64 |
-
}, [stop]);
|
| 65 |
|
| 66 |
// SDK status is the ground truth β if it's streaming/submitted, agent is busy
|
| 67 |
const sdkBusy = status === 'streaming' || status === 'submitted';
|
|
@@ -71,12 +69,11 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
|
|
| 71 |
async (text: string) => {
|
| 72 |
if (!text.trim() || busy) return;
|
| 73 |
|
| 74 |
-
|
| 75 |
-
updateSession(sessionId, { isProcessing: true });
|
| 76 |
sendMessage({ text: text.trim(), metadata: { createdAt: new Date().toISOString() } });
|
| 77 |
|
| 78 |
// Auto-title the session from the first user message
|
| 79 |
-
const isFirstMessage = messages.filter((m) => m.role === 'user').length
|
| 80 |
if (isFirstMessage) {
|
| 81 |
apiFetch('/api/title', {
|
| 82 |
method: 'POST',
|
|
@@ -105,6 +102,7 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
|
|
| 105 |
isProcessing={busy}
|
| 106 |
approveTools={approveTools}
|
| 107 |
onUndoLastTurn={undoLastTurn}
|
|
|
|
| 108 |
/>
|
| 109 |
<ChatInput
|
| 110 |
onSend={handleSendMessage}
|
|
@@ -114,9 +112,7 @@ export default function SessionChat({ sessionId, isActive, onSessionDead }: Sess
|
|
| 114 |
placeholder={
|
| 115 |
activityStatus.type === 'waiting-approval'
|
| 116 |
? 'Approve or reject pending tools first...'
|
| 117 |
-
:
|
| 118 |
-
? 'What should the agent do instead?'
|
| 119 |
-
: undefined
|
| 120 |
}
|
| 121 |
/>
|
| 122 |
</>
|
|
|
|
| 5 |
* runs β processing events β but only the active session renders visible
|
| 6 |
* UI (MessageList + ChatInput).
|
| 7 |
*/
|
| 8 |
+
import { useCallback, useEffect } from 'react';
|
| 9 |
import { useAgentChat } from '@/hooks/useAgentChat';
|
| 10 |
import { useAgentStore } from '@/store/agentStore';
|
| 11 |
import { useSessionStore } from '@/store/sessionStore';
|
|
|
|
| 24 |
const { isConnected, isProcessing, activityStatus, updateSession } = useAgentStore();
|
| 25 |
const { updateSessionTitle } = useSessionStore();
|
| 26 |
|
| 27 |
+
const { messages, sendMessage, stop, status, undoLastTurn, editAndRegenerate, approveTools } = useAgentChat({
|
|
|
|
|
|
|
| 28 |
sessionId,
|
| 29 |
isActive,
|
| 30 |
onReady: () => logger.log(`Session ${sessionId} ready`),
|
|
|
|
| 55 |
return () => document.removeEventListener('visibilitychange', onVisible);
|
| 56 |
}, [isActive, sessionId]);
|
| 57 |
|
| 58 |
+
// Wrap stop to show cancelled shimmer
|
| 59 |
const handleStop = useCallback(() => {
|
| 60 |
stop();
|
| 61 |
+
updateSession(sessionId, { activityStatus: { type: 'cancelled' } });
|
| 62 |
+
}, [stop, updateSession, sessionId]);
|
| 63 |
|
| 64 |
// SDK status is the ground truth β if it's streaming/submitted, agent is busy
|
| 65 |
const sdkBusy = status === 'streaming' || status === 'submitted';
|
|
|
|
| 69 |
async (text: string) => {
|
| 70 |
if (!text.trim() || busy) return;
|
| 71 |
|
| 72 |
+
updateSession(sessionId, { isProcessing: true, activityStatus: { type: 'thinking' } });
|
|
|
|
| 73 |
sendMessage({ text: text.trim(), metadata: { createdAt: new Date().toISOString() } });
|
| 74 |
|
| 75 |
// Auto-title the session from the first user message
|
| 76 |
+
const isFirstMessage = messages.filter((m) => m.role === 'user').length === 0;
|
| 77 |
if (isFirstMessage) {
|
| 78 |
apiFetch('/api/title', {
|
| 79 |
method: 'POST',
|
|
|
|
| 102 |
isProcessing={busy}
|
| 103 |
approveTools={approveTools}
|
| 104 |
onUndoLastTurn={undoLastTurn}
|
| 105 |
+
onEditAndRegenerate={editAndRegenerate}
|
| 106 |
/>
|
| 107 |
<ChatInput
|
| 108 |
onSend={handleSendMessage}
|
|
|
|
| 112 |
placeholder={
|
| 113 |
activityStatus.type === 'waiting-approval'
|
| 114 |
? 'Approve or reject pending tools first...'
|
| 115 |
+
: undefined
|
|
|
|
|
|
|
| 116 |
}
|
| 117 |
/>
|
| 118 |
</>
|
frontend/src/components/SessionSidebar/SessionSidebar.tsx
CHANGED
|
@@ -2,6 +2,12 @@ import { useCallback, useState } from 'react';
|
|
| 2 |
import {
|
| 3 |
Alert,
|
| 4 |
Box,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
IconButton,
|
| 6 |
Typography,
|
| 7 |
CircularProgress,
|
|
@@ -51,20 +57,52 @@ export default function SessionSidebar({ onClose }: SessionSidebarProps) {
|
|
| 51 |
}
|
| 52 |
}, [isCreatingSession, createSession, setPlan, clearPanel, onClose]);
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
e.stopPropagation();
|
| 57 |
-
|
| 58 |
-
try {
|
| 59 |
-
await apiFetch(`/api/session/${sessionId}`, { method: 'DELETE' });
|
| 60 |
-
deleteSession(sessionId);
|
| 61 |
-
} catch {
|
| 62 |
-
deleteSession(sessionId);
|
| 63 |
-
}
|
| 64 |
},
|
| 65 |
-
[
|
| 66 |
);
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
const handleSelect = useCallback(
|
| 69 |
(sessionId: string) => {
|
| 70 |
switchSession(sessionId);
|
|
@@ -181,6 +219,7 @@ export default function SessionSidebar({ onClose }: SessionSidebarProps) {
|
|
| 181 |
px: 1.5,
|
| 182 |
py: 0.875,
|
| 183 |
mx: 0.75,
|
|
|
|
| 184 |
borderRadius: '10px',
|
| 185 |
cursor: 'pointer',
|
| 186 |
transition: 'background-color 0.12s ease',
|
|
@@ -256,7 +295,7 @@ export default function SessionSidebar({ onClose }: SessionSidebarProps) {
|
|
| 256 |
<IconButton
|
| 257 |
className="delete-btn"
|
| 258 |
size="small"
|
| 259 |
-
onClick={(e) =>
|
| 260 |
sx={{
|
| 261 |
color: 'var(--muted-text)',
|
| 262 |
width: 26,
|
|
@@ -328,6 +367,89 @@ export default function SessionSidebar({ onClose }: SessionSidebarProps) {
|
|
| 328 |
</Box>
|
| 329 |
|
| 330 |
</Box>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
</Box>
|
| 332 |
);
|
| 333 |
}
|
|
|
|
| 2 |
import {
|
| 3 |
Alert,
|
| 4 |
Box,
|
| 5 |
+
Button,
|
| 6 |
+
Dialog,
|
| 7 |
+
DialogActions,
|
| 8 |
+
DialogContent,
|
| 9 |
+
DialogContentText,
|
| 10 |
+
DialogTitle,
|
| 11 |
IconButton,
|
| 12 |
Typography,
|
| 13 |
CircularProgress,
|
|
|
|
| 57 |
}
|
| 58 |
}, [isCreatingSession, createSession, setPlan, clearPanel, onClose]);
|
| 59 |
|
| 60 |
+
// -- Delete with dialog confirmation ------------------------------------
|
| 61 |
+
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
| 62 |
+
const [isDeleting, setIsDeleting] = useState(false);
|
| 63 |
+
|
| 64 |
+
const handleDeleteClick = useCallback(
|
| 65 |
+
(sessionId: string, e: React.MouseEvent) => {
|
| 66 |
e.stopPropagation();
|
| 67 |
+
setConfirmDeleteId(sessionId);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
},
|
| 69 |
+
[],
|
| 70 |
);
|
| 71 |
|
| 72 |
+
const handleDeleteConfirm = useCallback(async () => {
|
| 73 |
+
if (!confirmDeleteId || isDeleting) return;
|
| 74 |
+
const sessionId = confirmDeleteId;
|
| 75 |
+
setIsDeleting(true);
|
| 76 |
+
|
| 77 |
+
const isLastSession = sessions.length === 1;
|
| 78 |
+
|
| 79 |
+
useAgentStore.getState().clearSessionState(sessionId);
|
| 80 |
+
try {
|
| 81 |
+
await apiFetch(`/api/session/${sessionId}`, { method: 'DELETE' });
|
| 82 |
+
deleteSession(sessionId);
|
| 83 |
+
} catch {
|
| 84 |
+
deleteSession(sessionId);
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// If this was the last session, create a new one
|
| 88 |
+
if (isLastSession) {
|
| 89 |
+
try {
|
| 90 |
+
const response = await apiFetch('/api/session', { method: 'POST' });
|
| 91 |
+
if (response.ok) {
|
| 92 |
+
const data = await response.json();
|
| 93 |
+
createSession(data.session_id);
|
| 94 |
+
setPlan([]);
|
| 95 |
+
clearPanel();
|
| 96 |
+
}
|
| 97 |
+
} catch (error) {
|
| 98 |
+
console.error('Failed to create new session after deleting last one:', error);
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
setIsDeleting(false);
|
| 103 |
+
setConfirmDeleteId(null);
|
| 104 |
+
}, [deleteSession, confirmDeleteId, isDeleting, sessions, createSession, setPlan, clearPanel]);
|
| 105 |
+
|
| 106 |
const handleSelect = useCallback(
|
| 107 |
(sessionId: string) => {
|
| 108 |
switchSession(sessionId);
|
|
|
|
| 219 |
px: 1.5,
|
| 220 |
py: 0.875,
|
| 221 |
mx: 0.75,
|
| 222 |
+
mb: 0.2,
|
| 223 |
borderRadius: '10px',
|
| 224 |
cursor: 'pointer',
|
| 225 |
transition: 'background-color 0.12s ease',
|
|
|
|
| 295 |
<IconButton
|
| 296 |
className="delete-btn"
|
| 297 |
size="small"
|
| 298 |
+
onClick={(e) => handleDeleteClick(session.id, e)}
|
| 299 |
sx={{
|
| 300 |
color: 'var(--muted-text)',
|
| 301 |
width: 26,
|
|
|
|
| 367 |
</Box>
|
| 368 |
|
| 369 |
</Box>
|
| 370 |
+
{/* Delete confirmation dialog */}
|
| 371 |
+
<Dialog
|
| 372 |
+
open={!!confirmDeleteId}
|
| 373 |
+
onClose={() => !isDeleting && setConfirmDeleteId(null)}
|
| 374 |
+
slotProps={{
|
| 375 |
+
backdrop: { sx: { backgroundColor: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' } },
|
| 376 |
+
}}
|
| 377 |
+
PaperProps={{
|
| 378 |
+
sx: {
|
| 379 |
+
bgcolor: 'var(--panel)',
|
| 380 |
+
border: '1px solid var(--border)',
|
| 381 |
+
borderRadius: 'var(--radius-md)',
|
| 382 |
+
boxShadow: 'var(--shadow-1)',
|
| 383 |
+
maxWidth: 340,
|
| 384 |
+
mx: 2,
|
| 385 |
+
},
|
| 386 |
+
}}
|
| 387 |
+
>
|
| 388 |
+
<DialogTitle
|
| 389 |
+
sx={{
|
| 390 |
+
color: 'var(--text)',
|
| 391 |
+
fontWeight: 700,
|
| 392 |
+
fontSize: '0.95rem',
|
| 393 |
+
pb: 0,
|
| 394 |
+
pt: 2.5,
|
| 395 |
+
px: 3,
|
| 396 |
+
}}
|
| 397 |
+
>
|
| 398 |
+
Delete conversation?
|
| 399 |
+
</DialogTitle>
|
| 400 |
+
<DialogContent sx={{ px: 3, pt: 1 }}>
|
| 401 |
+
<DialogContentText
|
| 402 |
+
sx={{
|
| 403 |
+
color: 'var(--muted-text)',
|
| 404 |
+
fontSize: '0.82rem',
|
| 405 |
+
lineHeight: 1.6,
|
| 406 |
+
}}
|
| 407 |
+
>
|
| 408 |
+
This will permanently remove this conversation and its history.
|
| 409 |
+
</DialogContentText>
|
| 410 |
+
</DialogContent>
|
| 411 |
+
<DialogActions sx={{ px: 3, pb: 2.5, gap: 1 }}>
|
| 412 |
+
<Button
|
| 413 |
+
onClick={() => setConfirmDeleteId(null)}
|
| 414 |
+
size="small"
|
| 415 |
+
disabled={isDeleting}
|
| 416 |
+
sx={{
|
| 417 |
+
color: 'var(--muted-text)',
|
| 418 |
+
fontSize: '0.82rem',
|
| 419 |
+
px: 2,
|
| 420 |
+
'&:hover': { bgcolor: 'var(--hover-bg)' },
|
| 421 |
+
}}
|
| 422 |
+
>
|
| 423 |
+
Cancel
|
| 424 |
+
</Button>
|
| 425 |
+
<Button
|
| 426 |
+
onClick={handleDeleteConfirm}
|
| 427 |
+
variant="contained"
|
| 428 |
+
size="small"
|
| 429 |
+
disabled={isDeleting}
|
| 430 |
+
startIcon={isDeleting ? <CircularProgress size={16} sx={{ color: '#fff' }} /> : undefined}
|
| 431 |
+
sx={{
|
| 432 |
+
fontSize: '0.82rem',
|
| 433 |
+
px: 2.5,
|
| 434 |
+
bgcolor: 'var(--accent-red)',
|
| 435 |
+
color: '#fff',
|
| 436 |
+
boxShadow: 'none',
|
| 437 |
+
'&:hover': {
|
| 438 |
+
bgcolor: 'var(--accent-red)',
|
| 439 |
+
filter: 'brightness(1.15)',
|
| 440 |
+
boxShadow: 'none',
|
| 441 |
+
},
|
| 442 |
+
'&.Mui-disabled': {
|
| 443 |
+
bgcolor: 'var(--accent-red)',
|
| 444 |
+
color: '#fff',
|
| 445 |
+
opacity: 0.7,
|
| 446 |
+
},
|
| 447 |
+
}}
|
| 448 |
+
>
|
| 449 |
+
{isDeleting ? 'Deleting...' : 'Delete'}
|
| 450 |
+
</Button>
|
| 451 |
+
</DialogActions>
|
| 452 |
+
</Dialog>
|
| 453 |
</Box>
|
| 454 |
);
|
| 455 |
}
|
frontend/src/components/WelcomeScreen/WelcomeScreen.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useState, useCallback, useEffect, useRef } from 'react';
|
| 2 |
import {
|
| 3 |
Box,
|
| 4 |
Typography,
|
|
@@ -6,53 +6,236 @@ import {
|
|
| 6 |
CircularProgress,
|
| 7 |
Alert,
|
| 8 |
} from '@mui/material';
|
|
|
|
| 9 |
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
| 10 |
import GroupAddIcon from '@mui/icons-material/GroupAdd';
|
|
|
|
|
|
|
| 11 |
import { useSessionStore } from '@/store/sessionStore';
|
| 12 |
import { useAgentStore } from '@/store/agentStore';
|
| 13 |
import { apiFetch } from '@/utils/api';
|
| 14 |
import { isInIframe, triggerLogin } from '@/hooks/useAuth';
|
|
|
|
| 15 |
|
| 16 |
-
/** HF brand orange */
|
| 17 |
const HF_ORANGE = '#FF9D00';
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
|
|
|
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
-
function
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
}
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
export default function WelcomeScreen() {
|
| 31 |
const { createSession } = useSessionStore();
|
| 32 |
const { setPlan, clearPanel, user } = useAgentStore();
|
| 33 |
const [isCreating, setIsCreating] = useState(false);
|
| 34 |
const [error, setError] = useState<string | null>(null);
|
| 35 |
-
const [orgJoined, setOrgJoined] = useState(hasJoinedOrg);
|
| 36 |
-
const joinLinkOpened = useRef(false);
|
| 37 |
|
| 38 |
const inIframe = isInIframe();
|
| 39 |
-
const isAuthenticated = user?.authenticated;
|
| 40 |
const isDevUser = user?.username === 'dev';
|
| 41 |
|
| 42 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
useEffect(() => {
|
|
|
|
| 44 |
const handleVisibility = () => {
|
| 45 |
if (document.visibilityState !== 'visible' || !joinLinkOpened.current) return;
|
| 46 |
joinLinkOpened.current = false;
|
| 47 |
-
|
| 48 |
-
|
| 49 |
};
|
| 50 |
-
|
| 51 |
document.addEventListener('visibilitychange', handleVisibility);
|
| 52 |
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
| 53 |
-
}, []);
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
setIsCreating(true);
|
| 57 |
setError(null);
|
| 58 |
|
|
@@ -80,72 +263,21 @@ export default function WelcomeScreen() {
|
|
| 80 |
} finally {
|
| 81 |
setIsCreating(false);
|
| 82 |
}
|
| 83 |
-
}, [createSession, setPlan, clearPanel]);
|
| 84 |
|
| 85 |
-
|
| 86 |
-
if (isCreating) return;
|
| 87 |
-
|
| 88 |
-
if (!isAuthenticated && !isDevUser) {
|
| 89 |
-
if (inIframe) return;
|
| 90 |
-
triggerLogin();
|
| 91 |
-
return;
|
| 92 |
-
}
|
| 93 |
-
|
| 94 |
-
await tryCreateSession();
|
| 95 |
-
}, [isCreating, isAuthenticated, isDevUser, inIframe, tryCreateSession]);
|
| 96 |
-
|
| 97 |
-
// Build the direct Space URL for the "open in new tab" link
|
| 98 |
-
const spaceHost = typeof window !== 'undefined'
|
| 99 |
-
? window.location.hostname.includes('.hf.space')
|
| 100 |
-
? window.location.origin
|
| 101 |
-
: `https://smolagents-ml-agent.hf.space`
|
| 102 |
-
: '';
|
| 103 |
-
|
| 104 |
-
// Shared button style
|
| 105 |
-
const primaryBtnSx = {
|
| 106 |
-
px: 5,
|
| 107 |
-
py: 1.5,
|
| 108 |
-
fontSize: '1rem',
|
| 109 |
-
fontWeight: 700,
|
| 110 |
-
textTransform: 'none' as const,
|
| 111 |
-
borderRadius: '12px',
|
| 112 |
-
bgcolor: HF_ORANGE,
|
| 113 |
-
color: '#000',
|
| 114 |
-
boxShadow: '0 4px 24px rgba(255, 157, 0, 0.3)',
|
| 115 |
-
textDecoration: 'none',
|
| 116 |
-
'&:hover': {
|
| 117 |
-
bgcolor: '#FFB340',
|
| 118 |
-
boxShadow: '0 6px 32px rgba(255, 157, 0, 0.45)',
|
| 119 |
-
},
|
| 120 |
-
};
|
| 121 |
|
| 122 |
-
|
| 123 |
-
const
|
| 124 |
-
|
| 125 |
-
variant="body1"
|
| 126 |
-
sx={{
|
| 127 |
-
color: 'var(--muted-text)',
|
| 128 |
-
maxWidth: 520,
|
| 129 |
-
mb: 5,
|
| 130 |
-
lineHeight: 1.8,
|
| 131 |
-
fontSize: '0.95rem',
|
| 132 |
-
textAlign: 'center',
|
| 133 |
-
px: 2,
|
| 134 |
-
'& strong': { color: 'var(--text)', fontWeight: 600 },
|
| 135 |
-
}}
|
| 136 |
-
>
|
| 137 |
-
A general-purpose AI agent for <strong>machine learning engineering</strong>.
|
| 138 |
-
It browses <strong>Hugging Face documentation</strong>, manages{' '}
|
| 139 |
-
<strong>repositories</strong>, launches <strong>training jobs</strong>,
|
| 140 |
-
and explores <strong>datasets</strong> β all through natural conversation.
|
| 141 |
-
</Typography>
|
| 142 |
-
);
|
| 143 |
|
| 144 |
-
//
|
| 145 |
-
const
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
| 149 |
|
| 150 |
return (
|
| 151 |
<Box
|
|
@@ -160,12 +292,12 @@ export default function WelcomeScreen() {
|
|
| 160 |
py: 8,
|
| 161 |
}}
|
| 162 |
>
|
| 163 |
-
{/*
|
| 164 |
<Box
|
| 165 |
component="img"
|
| 166 |
src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg"
|
| 167 |
alt="Hugging Face"
|
| 168 |
-
sx={{ width:
|
| 169 |
/>
|
| 170 |
|
| 171 |
{/* Title */}
|
|
@@ -174,120 +306,128 @@ export default function WelcomeScreen() {
|
|
| 174 |
sx={{
|
| 175 |
fontWeight: 800,
|
| 176 |
color: 'var(--text)',
|
| 177 |
-
mb: 1
|
| 178 |
letterSpacing: '-0.02em',
|
| 179 |
-
fontSize: { xs: '
|
| 180 |
}}
|
| 181 |
>
|
| 182 |
HF Agent
|
| 183 |
</Typography>
|
| 184 |
|
| 185 |
-
{/*
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
<Button
|
| 205 |
-
variant="contained"
|
| 206 |
-
size="large"
|
| 207 |
-
component="a"
|
| 208 |
-
href={ORG_JOIN_URL}
|
| 209 |
-
target="_blank"
|
| 210 |
-
rel="noopener noreferrer"
|
| 211 |
-
onClick={() => { joinLinkOpened.current = true; }}
|
| 212 |
-
startIcon={<GroupAddIcon />}
|
| 213 |
-
sx={primaryBtnSx}
|
| 214 |
-
>
|
| 215 |
-
Join ML Agent Explorers
|
| 216 |
-
</Button>
|
| 217 |
-
</>
|
| 218 |
-
)}
|
| 219 |
-
|
| 220 |
-
{/* ββ Iframe: already joined β open Space ββββββββββββββββββββ */}
|
| 221 |
-
{showOpenAgent && (
|
| 222 |
-
<>
|
| 223 |
-
{description}
|
| 224 |
-
<Button
|
| 225 |
-
variant="contained"
|
| 226 |
-
size="large"
|
| 227 |
-
component="a"
|
| 228 |
-
href={spaceHost}
|
| 229 |
-
target="_blank"
|
| 230 |
-
rel="noopener noreferrer"
|
| 231 |
-
endIcon={<OpenInNewIcon />}
|
| 232 |
-
sx={primaryBtnSx}
|
| 233 |
-
>
|
| 234 |
-
Open HF Agent
|
| 235 |
-
</Button>
|
| 236 |
-
</>
|
| 237 |
-
)}
|
| 238 |
-
|
| 239 |
-
{/* ββ Direct: not logged in β sign in ββββββββββββββββββββββββ */}
|
| 240 |
-
{showSignin && (
|
| 241 |
-
<>
|
| 242 |
-
{description}
|
| 243 |
-
<Button
|
| 244 |
-
variant="contained"
|
| 245 |
-
size="large"
|
| 246 |
-
onClick={() => triggerLogin()}
|
| 247 |
-
sx={primaryBtnSx}
|
| 248 |
-
>
|
| 249 |
-
Sign in with Hugging Face
|
| 250 |
-
</Button>
|
| 251 |
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
-
{/*
|
| 269 |
-
{
|
| 270 |
-
<
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
disabled={isCreating}
|
| 277 |
-
startIcon={
|
| 278 |
-
isCreating ? <CircularProgress size={20} color="inherit" /> : null
|
| 279 |
-
}
|
| 280 |
-
sx={{
|
| 281 |
-
...primaryBtnSx,
|
| 282 |
-
'&.Mui-disabled': {
|
| 283 |
-
bgcolor: 'rgba(255, 157, 0, 0.35)',
|
| 284 |
-
color: 'rgba(0,0,0,0.45)',
|
| 285 |
-
},
|
| 286 |
-
}}
|
| 287 |
-
>
|
| 288 |
-
{isCreating ? 'Initializing...' : 'Start Session'}
|
| 289 |
-
</Button>
|
| 290 |
-
</>
|
| 291 |
)}
|
| 292 |
|
| 293 |
{/* Error */}
|
|
@@ -311,7 +451,7 @@ export default function WelcomeScreen() {
|
|
| 311 |
{/* Footnote */}
|
| 312 |
<Typography
|
| 313 |
variant="caption"
|
| 314 |
-
sx={{ mt:
|
| 315 |
>
|
| 316 |
Conversations are stored locally in your browser.
|
| 317 |
</Typography>
|
|
|
|
| 1 |
+
import { useState, useCallback, useEffect, useRef, type ReactNode } from 'react';
|
| 2 |
import {
|
| 3 |
Box,
|
| 4 |
Typography,
|
|
|
|
| 6 |
CircularProgress,
|
| 7 |
Alert,
|
| 8 |
} from '@mui/material';
|
| 9 |
+
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
| 10 |
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
| 11 |
import GroupAddIcon from '@mui/icons-material/GroupAdd';
|
| 12 |
+
import LoginIcon from '@mui/icons-material/Login';
|
| 13 |
+
import RocketLaunchIcon from '@mui/icons-material/RocketLaunch';
|
| 14 |
import { useSessionStore } from '@/store/sessionStore';
|
| 15 |
import { useAgentStore } from '@/store/agentStore';
|
| 16 |
import { apiFetch } from '@/utils/api';
|
| 17 |
import { isInIframe, triggerLogin } from '@/hooks/useAuth';
|
| 18 |
+
import { useOrgMembership } from '@/hooks/useOrgMembership';
|
| 19 |
|
|
|
|
| 20 |
const HF_ORANGE = '#FF9D00';
|
| 21 |
+
const ORG_JOIN_URL =
|
| 22 |
+
'https://huggingface.co/organizations/ml-agent-explorers/share/GzPMJUivoFPlfkvFtIqEouZKSytatKQSZT';
|
| 23 |
|
| 24 |
+
// ---------------------------------------------------------------------------
|
| 25 |
+
// ChecklistStep sub-component
|
| 26 |
+
// ---------------------------------------------------------------------------
|
| 27 |
|
| 28 |
+
type StepStatus = 'completed' | 'active' | 'locked';
|
| 29 |
+
|
| 30 |
+
interface ChecklistStepProps {
|
| 31 |
+
stepNumber: number;
|
| 32 |
+
title: string;
|
| 33 |
+
description: string;
|
| 34 |
+
status: StepStatus;
|
| 35 |
+
lockedReason?: string;
|
| 36 |
+
actionLabel?: string;
|
| 37 |
+
onAction?: () => void;
|
| 38 |
+
actionIcon?: ReactNode;
|
| 39 |
+
actionHref?: string;
|
| 40 |
+
loading?: boolean;
|
| 41 |
+
isLast?: boolean;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function StepIndicator({ status, stepNumber }: { status: StepStatus; stepNumber: number }) {
|
| 45 |
+
if (status === 'completed') {
|
| 46 |
+
return <CheckCircleIcon sx={{ fontSize: 28, color: 'var(--accent-green)' }} />;
|
| 47 |
+
}
|
| 48 |
+
return (
|
| 49 |
+
<Box
|
| 50 |
+
sx={{
|
| 51 |
+
width: 28,
|
| 52 |
+
height: 28,
|
| 53 |
+
borderRadius: '50%',
|
| 54 |
+
display: 'flex',
|
| 55 |
+
alignItems: 'center',
|
| 56 |
+
justifyContent: 'center',
|
| 57 |
+
fontSize: '0.8rem',
|
| 58 |
+
fontWeight: 700,
|
| 59 |
+
...(status === 'active'
|
| 60 |
+
? { bgcolor: HF_ORANGE, color: '#000' }
|
| 61 |
+
: { bgcolor: 'transparent', border: '2px solid var(--border)', color: 'var(--muted-text)' }),
|
| 62 |
+
}}
|
| 63 |
+
>
|
| 64 |
+
{stepNumber}
|
| 65 |
+
</Box>
|
| 66 |
+
);
|
| 67 |
}
|
| 68 |
|
| 69 |
+
function ChecklistStep({
|
| 70 |
+
stepNumber,
|
| 71 |
+
title,
|
| 72 |
+
description,
|
| 73 |
+
status,
|
| 74 |
+
lockedReason,
|
| 75 |
+
actionLabel,
|
| 76 |
+
onAction,
|
| 77 |
+
actionIcon,
|
| 78 |
+
actionHref,
|
| 79 |
+
loading = false,
|
| 80 |
+
isLast = false,
|
| 81 |
+
}: ChecklistStepProps) {
|
| 82 |
+
const btnSx = {
|
| 83 |
+
px: 3,
|
| 84 |
+
py: 0.75,
|
| 85 |
+
fontSize: '0.85rem',
|
| 86 |
+
fontWeight: 700,
|
| 87 |
+
textTransform: 'none' as const,
|
| 88 |
+
borderRadius: '10px',
|
| 89 |
+
whiteSpace: 'nowrap' as const,
|
| 90 |
+
textDecoration: 'none',
|
| 91 |
+
...(status === 'active'
|
| 92 |
+
? {
|
| 93 |
+
bgcolor: HF_ORANGE,
|
| 94 |
+
color: '#000',
|
| 95 |
+
boxShadow: '0 2px 12px rgba(255, 157, 0, 0.25)',
|
| 96 |
+
'&:hover': { bgcolor: '#FFB340', boxShadow: '0 4px 20px rgba(255, 157, 0, 0.4)' },
|
| 97 |
+
}
|
| 98 |
+
: {
|
| 99 |
+
bgcolor: 'rgba(255,255,255,0.04)',
|
| 100 |
+
color: 'var(--muted-text)',
|
| 101 |
+
'&.Mui-disabled': { bgcolor: 'rgba(255,255,255,0.04)', color: 'var(--muted-text)' },
|
| 102 |
+
}),
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
return (
|
| 106 |
+
<Box
|
| 107 |
+
sx={{
|
| 108 |
+
display: 'flex',
|
| 109 |
+
alignItems: 'center',
|
| 110 |
+
gap: 2,
|
| 111 |
+
px: 3,
|
| 112 |
+
py: 2.5,
|
| 113 |
+
borderLeft: '3px solid',
|
| 114 |
+
borderLeftColor:
|
| 115 |
+
status === 'completed'
|
| 116 |
+
? 'var(--accent-green)'
|
| 117 |
+
: status === 'active'
|
| 118 |
+
? HF_ORANGE
|
| 119 |
+
: 'transparent',
|
| 120 |
+
...(!isLast && { borderBottom: '1px solid var(--border)' }),
|
| 121 |
+
opacity: status === 'locked' ? 0.55 : 1,
|
| 122 |
+
transition: 'opacity 0.2s, border-color 0.2s',
|
| 123 |
+
}}
|
| 124 |
+
>
|
| 125 |
+
<StepIndicator status={status} stepNumber={stepNumber} />
|
| 126 |
+
|
| 127 |
+
<Box sx={{ flex: 1, minWidth: 0 }}>
|
| 128 |
+
<Typography
|
| 129 |
+
variant="subtitle2"
|
| 130 |
+
sx={{
|
| 131 |
+
fontWeight: 600,
|
| 132 |
+
fontSize: '0.92rem',
|
| 133 |
+
color: status === 'completed' ? 'var(--muted-text)' : 'var(--text)',
|
| 134 |
+
...(status === 'completed' && { textDecoration: 'line-through', textDecorationColor: 'var(--muted-text)' }),
|
| 135 |
+
}}
|
| 136 |
+
>
|
| 137 |
+
{title}
|
| 138 |
+
</Typography>
|
| 139 |
+
<Typography variant="body2" sx={{ color: 'var(--muted-text)', fontSize: '0.8rem', mt: 0.25, lineHeight: 1.5 }}>
|
| 140 |
+
{status === 'locked' && lockedReason ? lockedReason : description}
|
| 141 |
+
</Typography>
|
| 142 |
+
</Box>
|
| 143 |
+
|
| 144 |
+
{status === 'completed' ? (
|
| 145 |
+
<Typography variant="caption" sx={{ color: 'var(--accent-green)', fontWeight: 600, fontSize: '0.78rem', whiteSpace: 'nowrap' }}>
|
| 146 |
+
Done
|
| 147 |
+
</Typography>
|
| 148 |
+
) : actionLabel ? (
|
| 149 |
+
actionHref ? (
|
| 150 |
+
<Button
|
| 151 |
+
variant="contained"
|
| 152 |
+
size="small"
|
| 153 |
+
component="a"
|
| 154 |
+
href={actionHref}
|
| 155 |
+
target="_blank"
|
| 156 |
+
rel="noopener noreferrer"
|
| 157 |
+
disabled={status === 'locked'}
|
| 158 |
+
startIcon={actionIcon}
|
| 159 |
+
sx={btnSx}
|
| 160 |
+
onClick={onAction}
|
| 161 |
+
>
|
| 162 |
+
{actionLabel}
|
| 163 |
+
</Button>
|
| 164 |
+
) : (
|
| 165 |
+
<Button
|
| 166 |
+
variant="contained"
|
| 167 |
+
size="small"
|
| 168 |
+
disabled={status === 'locked' || loading}
|
| 169 |
+
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : actionIcon}
|
| 170 |
+
onClick={onAction}
|
| 171 |
+
sx={btnSx}
|
| 172 |
+
>
|
| 173 |
+
{loading ? 'Loading...' : actionLabel}
|
| 174 |
+
</Button>
|
| 175 |
+
)
|
| 176 |
+
) : null}
|
| 177 |
+
</Box>
|
| 178 |
+
);
|
| 179 |
}
|
| 180 |
|
| 181 |
+
// ---------------------------------------------------------------------------
|
| 182 |
+
// WelcomeScreen
|
| 183 |
+
// ---------------------------------------------------------------------------
|
| 184 |
+
|
| 185 |
export default function WelcomeScreen() {
|
| 186 |
const { createSession } = useSessionStore();
|
| 187 |
const { setPlan, clearPanel, user } = useAgentStore();
|
| 188 |
const [isCreating, setIsCreating] = useState(false);
|
| 189 |
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
|
|
| 190 |
|
| 191 |
const inIframe = isInIframe();
|
| 192 |
+
const isAuthenticated = !!user?.authenticated;
|
| 193 |
const isDevUser = user?.username === 'dev';
|
| 194 |
|
| 195 |
+
// Iframe: localStorage-based org tracking (no auth token available)
|
| 196 |
+
const [iframeOrgJoined, setIframeOrgJoined] = useState(() => {
|
| 197 |
+
try { return localStorage.getItem('hf-agent-org-joined') === '1'; } catch { return false; }
|
| 198 |
+
});
|
| 199 |
+
const joinLinkOpened = useRef(false);
|
| 200 |
+
|
| 201 |
+
// Auto-advance when user returns from org join link (iframe only)
|
| 202 |
useEffect(() => {
|
| 203 |
+
if (!inIframe) return;
|
| 204 |
const handleVisibility = () => {
|
| 205 |
if (document.visibilityState !== 'visible' || !joinLinkOpened.current) return;
|
| 206 |
joinLinkOpened.current = false;
|
| 207 |
+
try { localStorage.setItem('hf-agent-org-joined', '1'); } catch { /* ignore */ }
|
| 208 |
+
setIframeOrgJoined(true);
|
| 209 |
};
|
|
|
|
| 210 |
document.addEventListener('visibilitychange', handleVisibility);
|
| 211 |
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
| 212 |
+
}, [inIframe]);
|
| 213 |
+
|
| 214 |
+
const isOrgMember = inIframe ? iframeOrgJoined : !!user?.orgMember;
|
| 215 |
|
| 216 |
+
// Poll for org membership once authenticated (skipped in dev mode and iframe)
|
| 217 |
+
const popupRef = useOrgMembership(isAuthenticated && !isDevUser && !inIframe && !isOrgMember);
|
| 218 |
+
|
| 219 |
+
// ---- Actions ----
|
| 220 |
+
|
| 221 |
+
const handleJoinOrg = useCallback(() => {
|
| 222 |
+
if (inIframe) {
|
| 223 |
+
// Iframe: open link, track via visibilitychange + localStorage
|
| 224 |
+
joinLinkOpened.current = true;
|
| 225 |
+
window.open(ORG_JOIN_URL, '_blank', 'noopener,noreferrer');
|
| 226 |
+
return;
|
| 227 |
+
}
|
| 228 |
+
// Direct: open as popup, auto-close via polling
|
| 229 |
+
const popup = window.open(ORG_JOIN_URL, 'hf-org-join', 'noopener');
|
| 230 |
+
if (popup) {
|
| 231 |
+
popupRef.current = popup;
|
| 232 |
+
} else {
|
| 233 |
+
window.open(ORG_JOIN_URL, '_blank', 'noopener,noreferrer');
|
| 234 |
+
}
|
| 235 |
+
}, [popupRef, inIframe]);
|
| 236 |
+
|
| 237 |
+
const handleStartSession = useCallback(async () => {
|
| 238 |
+
if (isCreating) return;
|
| 239 |
setIsCreating(true);
|
| 240 |
setError(null);
|
| 241 |
|
|
|
|
| 263 |
} finally {
|
| 264 |
setIsCreating(false);
|
| 265 |
}
|
| 266 |
+
}, [isCreating, createSession, setPlan, clearPanel]);
|
| 267 |
|
| 268 |
+
// ---- Step status helpers ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
|
| 270 |
+
const signInStatus: StepStatus = isAuthenticated ? 'completed' : 'active';
|
| 271 |
+
const joinOrgStatus: StepStatus = isOrgMember ? 'completed' : isAuthenticated ? 'active' : 'locked';
|
| 272 |
+
const startStatus: StepStatus = isAuthenticated && isOrgMember ? 'active' : 'locked';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
|
| 274 |
+
// Space URL for iframe "Open HF Agent" step
|
| 275 |
+
const spaceHost =
|
| 276 |
+
typeof window !== 'undefined'
|
| 277 |
+
? window.location.hostname.includes('.hf.space')
|
| 278 |
+
? window.location.origin
|
| 279 |
+
: 'https://smolagents-ml-agent.hf.space'
|
| 280 |
+
: '';
|
| 281 |
|
| 282 |
return (
|
| 283 |
<Box
|
|
|
|
| 292 |
py: 8,
|
| 293 |
}}
|
| 294 |
>
|
| 295 |
+
{/* Logo */}
|
| 296 |
<Box
|
| 297 |
component="img"
|
| 298 |
src="https://huggingface.co/front/assets/huggingface_logo-noborder.svg"
|
| 299 |
alt="Hugging Face"
|
| 300 |
+
sx={{ width: 80, height: 80, mb: 2.5, display: 'block' }}
|
| 301 |
/>
|
| 302 |
|
| 303 |
{/* Title */}
|
|
|
|
| 306 |
sx={{
|
| 307 |
fontWeight: 800,
|
| 308 |
color: 'var(--text)',
|
| 309 |
+
mb: 1,
|
| 310 |
letterSpacing: '-0.02em',
|
| 311 |
+
fontSize: { xs: '1.8rem', md: '2.4rem' },
|
| 312 |
}}
|
| 313 |
>
|
| 314 |
HF Agent
|
| 315 |
</Typography>
|
| 316 |
|
| 317 |
+
{/* Description */}
|
| 318 |
+
<Typography
|
| 319 |
+
variant="body1"
|
| 320 |
+
sx={{
|
| 321 |
+
color: 'var(--muted-text)',
|
| 322 |
+
maxWidth: 480,
|
| 323 |
+
mb: 4,
|
| 324 |
+
lineHeight: 1.7,
|
| 325 |
+
fontSize: '0.9rem',
|
| 326 |
+
textAlign: 'center',
|
| 327 |
+
px: 2,
|
| 328 |
+
'& strong': { color: 'var(--text)', fontWeight: 600 },
|
| 329 |
+
}}
|
| 330 |
+
>
|
| 331 |
+
A general-purpose AI agent for <strong>machine learning engineering</strong>.
|
| 332 |
+
It browses <strong>Hugging Face docs</strong>, manages <strong>repos</strong>,
|
| 333 |
+
launches <strong>training jobs</strong>, and explores <strong>datasets</strong>.
|
| 334 |
+
</Typography>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
|
| 336 |
+
{/* ββ Checklist ββββββββββββββββββββββββββββββββββββββββββββ */}
|
| 337 |
+
<Box
|
| 338 |
+
sx={{
|
| 339 |
+
width: '100%',
|
| 340 |
+
maxWidth: 520,
|
| 341 |
+
bgcolor: 'var(--surface)',
|
| 342 |
+
border: '1px solid var(--border)',
|
| 343 |
+
borderRadius: '12px',
|
| 344 |
+
overflow: 'hidden',
|
| 345 |
+
mx: 2,
|
| 346 |
+
}}
|
| 347 |
+
>
|
| 348 |
+
{isDevUser ? (
|
| 349 |
+
/* Dev mode: single step */
|
| 350 |
+
<ChecklistStep
|
| 351 |
+
stepNumber={1}
|
| 352 |
+
title="Start Session"
|
| 353 |
+
description="Launch an AI agent session for ML engineering."
|
| 354 |
+
status="active"
|
| 355 |
+
actionLabel="Start Session"
|
| 356 |
+
actionIcon={<RocketLaunchIcon sx={{ fontSize: 16 }} />}
|
| 357 |
+
onAction={handleStartSession}
|
| 358 |
+
loading={isCreating}
|
| 359 |
+
isLast
|
| 360 |
+
/>
|
| 361 |
+
) : inIframe ? (
|
| 362 |
+
/* Iframe: 2 steps */
|
| 363 |
+
<>
|
| 364 |
+
<ChecklistStep
|
| 365 |
+
stepNumber={1}
|
| 366 |
+
title="Join ML Agent Explorers"
|
| 367 |
+
description="Get free access to GPUs, inference APIs, and Hub resources."
|
| 368 |
+
status={isOrgMember ? 'completed' : 'active'}
|
| 369 |
+
actionLabel="Join Organization"
|
| 370 |
+
actionIcon={<GroupAddIcon sx={{ fontSize: 16 }} />}
|
| 371 |
+
onAction={handleJoinOrg}
|
| 372 |
+
/>
|
| 373 |
+
<ChecklistStep
|
| 374 |
+
stepNumber={2}
|
| 375 |
+
title="Open HF Agent"
|
| 376 |
+
description="Open the agent in a full browser tab to get started."
|
| 377 |
+
status={isOrgMember ? 'active' : 'locked'}
|
| 378 |
+
lockedReason="Join the organization first."
|
| 379 |
+
actionLabel="Open HF Agent"
|
| 380 |
+
actionIcon={<OpenInNewIcon sx={{ fontSize: 16 }} />}
|
| 381 |
+
actionHref={spaceHost}
|
| 382 |
+
isLast
|
| 383 |
+
/>
|
| 384 |
+
</>
|
| 385 |
+
) : (
|
| 386 |
+
/* Direct access: 3 steps */
|
| 387 |
+
<>
|
| 388 |
+
<ChecklistStep
|
| 389 |
+
stepNumber={1}
|
| 390 |
+
title="Sign in with Hugging Face"
|
| 391 |
+
description="Authenticate to access GPU resources and model APIs."
|
| 392 |
+
status={signInStatus}
|
| 393 |
+
actionLabel="Sign in"
|
| 394 |
+
actionIcon={<LoginIcon sx={{ fontSize: 16 }} />}
|
| 395 |
+
onAction={() => triggerLogin()}
|
| 396 |
+
/>
|
| 397 |
+
<ChecklistStep
|
| 398 |
+
stepNumber={2}
|
| 399 |
+
title="Join ML Agent Explorers"
|
| 400 |
+
description="Get free access to GPUs, inference APIs, and Hub resources."
|
| 401 |
+
status={joinOrgStatus}
|
| 402 |
+
lockedReason="Sign in first to continue."
|
| 403 |
+
actionLabel="Join Organization"
|
| 404 |
+
actionIcon={<GroupAddIcon sx={{ fontSize: 16 }} />}
|
| 405 |
+
onAction={handleJoinOrg}
|
| 406 |
+
/>
|
| 407 |
+
<ChecklistStep
|
| 408 |
+
stepNumber={3}
|
| 409 |
+
title="Start Session"
|
| 410 |
+
description="Launch an AI agent session for ML engineering."
|
| 411 |
+
status={startStatus}
|
| 412 |
+
lockedReason="Complete the steps above to continue."
|
| 413 |
+
actionLabel="Start Session"
|
| 414 |
+
actionIcon={<RocketLaunchIcon sx={{ fontSize: 16 }} />}
|
| 415 |
+
onAction={handleStartSession}
|
| 416 |
+
loading={isCreating}
|
| 417 |
+
isLast
|
| 418 |
+
/>
|
| 419 |
+
</>
|
| 420 |
+
)}
|
| 421 |
+
</Box>
|
| 422 |
|
| 423 |
+
{/* Polling hint when waiting for org join */}
|
| 424 |
+
{isAuthenticated && !isOrgMember && !isDevUser && !inIframe && (
|
| 425 |
+
<Typography
|
| 426 |
+
variant="caption"
|
| 427 |
+
sx={{ mt: 2, color: 'var(--muted-text)', fontSize: '0.75rem', textAlign: 'center' }}
|
| 428 |
+
>
|
| 429 |
+
This page updates automatically when you join the organization.
|
| 430 |
+
</Typography>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
)}
|
| 432 |
|
| 433 |
{/* Error */}
|
|
|
|
| 451 |
{/* Footnote */}
|
| 452 |
<Typography
|
| 453 |
variant="caption"
|
| 454 |
+
sx={{ mt: 4, color: 'var(--muted-text)', opacity: 0.5, fontSize: '0.7rem' }}
|
| 455 |
>
|
| 456 |
Conversations are stored locally in your browser.
|
| 457 |
</Typography>
|
frontend/src/hooks/useAgentChat.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { useChat } from '@ai-sdk/react';
|
|
| 12 |
import { type UIMessage, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai';
|
| 13 |
import { SSEChatTransport, type SideChannelCallbacks } from '@/lib/sse-chat-transport';
|
| 14 |
import { loadMessages, saveMessages } from '@/lib/chat-message-store';
|
|
|
|
| 15 |
import { llmMessagesToUIMessages } from '@/lib/convert-llm-messages';
|
| 16 |
import { apiFetch } from '@/utils/api';
|
| 17 |
import { useAgentStore } from '@/store/agentStore';
|
|
@@ -86,14 +87,46 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
|
|
| 86 |
}
|
| 87 |
},
|
| 88 |
onToolLog: (tool: string, log: string) => {
|
| 89 |
-
// Research sub-agent:
|
| 90 |
if (tool === 'research') {
|
| 91 |
const sessState = useAgentStore.getState().getSessionState(sessionId);
|
| 92 |
-
const
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
return;
|
| 98 |
}
|
| 99 |
|
|
@@ -235,8 +268,11 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
|
|
| 235 |
const updates: Partial<import('@/store/agentStore').PerSessionState> = {
|
| 236 |
activityStatus: { type: 'tool', toolName, description },
|
| 237 |
};
|
| 238 |
-
// Clear research steps when a new research call starts
|
| 239 |
-
if (toolName === 'research')
|
|
|
|
|
|
|
|
|
|
| 240 |
updateSession(sessionId, updates);
|
| 241 |
},
|
| 242 |
onInterrupted: () => { /* no-op β handled by stop() caller */ },
|
|
@@ -332,7 +368,7 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
|
|
| 332 |
if (msgsRes.ok) {
|
| 333 |
const data = await msgsRes.json();
|
| 334 |
if (cancelled || !Array.isArray(data) || data.length === 0) return;
|
| 335 |
-
const uiMsgs = llmMessagesToUIMessages(data, pendingIds);
|
| 336 |
if (uiMsgs.length > 0) {
|
| 337 |
chat.setMessages(uiMsgs);
|
| 338 |
saveMessages(sessionId, uiMsgs);
|
|
@@ -344,9 +380,25 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
|
|
| 344 |
// results make tools look "done" even when the agent is still
|
| 345 |
// mid-turn and about to call more tools.
|
| 346 |
if (backendIsProcessing) {
|
| 347 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
} else if (pendingIds && pendingIds.size > 0) {
|
| 349 |
updateSession(sessionId, { activityStatus: { type: 'waiting-approval' } });
|
|
|
|
|
|
|
|
|
|
| 350 |
}
|
| 351 |
} catch {
|
| 352 |
/* backend unreachable -- localStorage fallback is fine */
|
|
@@ -453,7 +505,7 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
|
|
| 453 |
// Final hydration to get the complete message state
|
| 454 |
const result = await hydrateMessages();
|
| 455 |
if (result) {
|
| 456 |
-
const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds);
|
| 457 |
if (uiMsgs.length > 0) {
|
| 458 |
chat.setMessages(uiMsgs);
|
| 459 |
saveMessages(sessionId, uiMsgs);
|
|
@@ -467,7 +519,7 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
|
|
| 467 |
stopReconnect();
|
| 468 |
const result = await hydrateMessages();
|
| 469 |
if (result) {
|
| 470 |
-
const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds);
|
| 471 |
if (uiMsgs.length > 0) {
|
| 472 |
chat.setMessages(uiMsgs);
|
| 473 |
saveMessages(sessionId, uiMsgs);
|
|
@@ -491,7 +543,7 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
|
|
| 491 |
if (!result) return;
|
| 492 |
|
| 493 |
const { data, pendingIds, info } = result;
|
| 494 |
-
const uiMsgs = llmMessagesToUIMessages(data, pendingIds);
|
| 495 |
if (uiMsgs.length > 0) {
|
| 496 |
chat.setMessages(uiMsgs);
|
| 497 |
saveMessages(sessionId, uiMsgs);
|
|
@@ -514,11 +566,14 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
|
|
| 514 |
pollTimerRef.current = setInterval(async () => {
|
| 515 |
const fresh = await hydrateMessages();
|
| 516 |
if (!fresh) return;
|
| 517 |
-
const msgs = llmMessagesToUIMessages(fresh.data, fresh.pendingIds);
|
| 518 |
-
|
|
|
|
|
|
|
| 519 |
chat.setMessages(msgs);
|
| 520 |
saveMessages(sessionId, msgs);
|
| 521 |
-
}
|
|
|
|
| 522 |
// If backend stopped processing, clean up
|
| 523 |
if (fresh.info && !fresh.info.is_processing) {
|
| 524 |
updateSession(sessionId, { isProcessing: false });
|
|
@@ -542,7 +597,7 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
|
|
| 542 |
if (chat.messages.length !== prevLenRef.current) {
|
| 543 |
prevLenRef.current = chat.messages.length;
|
| 544 |
saveMessages(sessionId, chat.messages);
|
| 545 |
-
}
|
| 546 |
}, [sessionId, chat.messages]);
|
| 547 |
|
| 548 |
// -- Undo last turn (REST call + client-side message removal) -----------
|
|
@@ -598,6 +653,11 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
|
|
| 598 |
if (hasApproved) {
|
| 599 |
updateSession(sessionId, { isProcessing: true });
|
| 600 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 601 |
return true;
|
| 602 |
},
|
| 603 |
[sessionId, chat, updateSession, setNeedsAttention],
|
|
@@ -612,12 +672,52 @@ export function useAgentChat({ sessionId, isActive, onReady, onError, onSessionD
|
|
| 612 |
apiFetch(`/api/interrupt/${sessionId}`, { method: 'POST' }).catch(() => {});
|
| 613 |
}, [sessionId, updateSession]);
|
| 614 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 615 |
return {
|
| 616 |
messages: chat.messages,
|
| 617 |
sendMessage: chat.sendMessage,
|
| 618 |
stop,
|
| 619 |
status: chat.status,
|
| 620 |
undoLastTurn,
|
|
|
|
| 621 |
approveTools,
|
| 622 |
};
|
| 623 |
}
|
|
|
|
| 12 |
import { type UIMessage, lastAssistantMessageIsCompleteWithApprovalResponses } from 'ai';
|
| 13 |
import { SSEChatTransport, type SideChannelCallbacks } from '@/lib/sse-chat-transport';
|
| 14 |
import { loadMessages, saveMessages } from '@/lib/chat-message-store';
|
| 15 |
+
import { saveResearch, loadResearch, clearResearch, RESEARCH_MAX_STEPS } from '@/lib/research-store';
|
| 16 |
import { llmMessagesToUIMessages } from '@/lib/convert-llm-messages';
|
| 17 |
import { apiFetch } from '@/utils/api';
|
| 18 |
import { useAgentStore } from '@/store/agentStore';
|
|
|
|
| 87 |
}
|
| 88 |
},
|
| 89 |
onToolLog: (tool: string, log: string) => {
|
| 90 |
+
// Research sub-agent: parse stats vs step logs
|
| 91 |
if (tool === 'research') {
|
| 92 |
const sessState = useAgentStore.getState().getSessionState(sessionId);
|
| 93 |
+
const stats = { ...sessState.researchStats };
|
| 94 |
+
|
| 95 |
+
if (log === 'Starting research sub-agent...') {
|
| 96 |
+
const newStats = { toolCount: 0, tokenCount: 0, startedAt: Date.now(), finalElapsed: null };
|
| 97 |
+
updateSession(sessionId, {
|
| 98 |
+
researchSteps: [],
|
| 99 |
+
researchStats: newStats,
|
| 100 |
+
activityStatus: { type: 'tool', toolName: 'research', description: log },
|
| 101 |
+
});
|
| 102 |
+
saveResearch(sessionId, [], newStats);
|
| 103 |
+
} else if (log.startsWith('tokens:')) {
|
| 104 |
+
stats.tokenCount = parseInt(log.slice(7), 10);
|
| 105 |
+
updateSession(sessionId, { researchStats: stats });
|
| 106 |
+
saveResearch(sessionId, sessState.researchSteps, stats);
|
| 107 |
+
} else if (log.startsWith('tools:')) {
|
| 108 |
+
stats.toolCount = parseInt(log.slice(6), 10);
|
| 109 |
+
updateSession(sessionId, { researchStats: stats });
|
| 110 |
+
saveResearch(sessionId, sessState.researchSteps, stats);
|
| 111 |
+
} else if (log === 'Research complete.') {
|
| 112 |
+
const elapsed = stats.startedAt
|
| 113 |
+
? Math.round((Date.now() - stats.startedAt) / 1000)
|
| 114 |
+
: null;
|
| 115 |
+
const doneStats = { ...stats, startedAt: null, finalElapsed: elapsed };
|
| 116 |
+
updateSession(sessionId, {
|
| 117 |
+
researchStats: doneStats,
|
| 118 |
+
activityStatus: { type: 'tool', toolName: 'research', description: log },
|
| 119 |
+
});
|
| 120 |
+
clearResearch(sessionId);
|
| 121 |
+
} else {
|
| 122 |
+
// Regular tool call step β append (trim to max)
|
| 123 |
+
const steps = [...sessState.researchSteps, log].slice(-RESEARCH_MAX_STEPS);
|
| 124 |
+
updateSession(sessionId, {
|
| 125 |
+
researchSteps: steps,
|
| 126 |
+
activityStatus: { type: 'tool', toolName: 'research', description: log },
|
| 127 |
+
});
|
| 128 |
+
saveResearch(sessionId, steps, stats);
|
| 129 |
+
}
|
| 130 |
return;
|
| 131 |
}
|
| 132 |
|
|
|
|
| 268 |
const updates: Partial<import('@/store/agentStore').PerSessionState> = {
|
| 269 |
activityStatus: { type: 'tool', toolName, description },
|
| 270 |
};
|
| 271 |
+
// Clear research steps + stats when a new research call starts
|
| 272 |
+
if (toolName === 'research') {
|
| 273 |
+
updates.researchSteps = [];
|
| 274 |
+
updates.researchStats = { toolCount: 0, tokenCount: 0, startedAt: null, finalElapsed: null };
|
| 275 |
+
}
|
| 276 |
updateSession(sessionId, updates);
|
| 277 |
},
|
| 278 |
onInterrupted: () => { /* no-op β handled by stop() caller */ },
|
|
|
|
| 368 |
if (msgsRes.ok) {
|
| 369 |
const data = await msgsRes.json();
|
| 370 |
if (cancelled || !Array.isArray(data) || data.length === 0) return;
|
| 371 |
+
const uiMsgs = llmMessagesToUIMessages(data, pendingIds, chatActionsRef.current.messages);
|
| 372 |
if (uiMsgs.length > 0) {
|
| 373 |
chat.setMessages(uiMsgs);
|
| 374 |
saveMessages(sessionId, uiMsgs);
|
|
|
|
| 380 |
// results make tools look "done" even when the agent is still
|
| 381 |
// mid-turn and about to call more tools.
|
| 382 |
if (backendIsProcessing) {
|
| 383 |
+
// Restore research sub-agent state alongside isProcessing in one
|
| 384 |
+
// atomic update so the UI never sees isProcessing=false with stale
|
| 385 |
+
// tool states (which would coerce them to 'output-available').
|
| 386 |
+
const savedResearch = loadResearch(sessionId);
|
| 387 |
+
updateSession(sessionId, {
|
| 388 |
+
isProcessing: true,
|
| 389 |
+
activityStatus: savedResearch?.stats.startedAt
|
| 390 |
+
? { type: 'tool', toolName: 'research', description: 'Resuming research...' }
|
| 391 |
+
: { type: 'thinking' },
|
| 392 |
+
...(savedResearch && {
|
| 393 |
+
researchSteps: savedResearch.steps,
|
| 394 |
+
researchStats: savedResearch.stats,
|
| 395 |
+
}),
|
| 396 |
+
});
|
| 397 |
} else if (pendingIds && pendingIds.size > 0) {
|
| 398 |
updateSession(sessionId, { activityStatus: { type: 'waiting-approval' } });
|
| 399 |
+
clearResearch(sessionId);
|
| 400 |
+
} else {
|
| 401 |
+
clearResearch(sessionId);
|
| 402 |
}
|
| 403 |
} catch {
|
| 404 |
/* backend unreachable -- localStorage fallback is fine */
|
|
|
|
| 505 |
// Final hydration to get the complete message state
|
| 506 |
const result = await hydrateMessages();
|
| 507 |
if (result) {
|
| 508 |
+
const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds, chatActionsRef.current.messages);
|
| 509 |
if (uiMsgs.length > 0) {
|
| 510 |
chat.setMessages(uiMsgs);
|
| 511 |
saveMessages(sessionId, uiMsgs);
|
|
|
|
| 519 |
stopReconnect();
|
| 520 |
const result = await hydrateMessages();
|
| 521 |
if (result) {
|
| 522 |
+
const uiMsgs = llmMessagesToUIMessages(result.data, result.pendingIds, chatActionsRef.current.messages);
|
| 523 |
if (uiMsgs.length > 0) {
|
| 524 |
chat.setMessages(uiMsgs);
|
| 525 |
saveMessages(sessionId, uiMsgs);
|
|
|
|
| 543 |
if (!result) return;
|
| 544 |
|
| 545 |
const { data, pendingIds, info } = result;
|
| 546 |
+
const uiMsgs = llmMessagesToUIMessages(data, pendingIds, chatActionsRef.current.messages);
|
| 547 |
if (uiMsgs.length > 0) {
|
| 548 |
chat.setMessages(uiMsgs);
|
| 549 |
saveMessages(sessionId, uiMsgs);
|
|
|
|
| 566 |
pollTimerRef.current = setInterval(async () => {
|
| 567 |
const fresh = await hydrateMessages();
|
| 568 |
if (!fresh) return;
|
| 569 |
+
const msgs = llmMessagesToUIMessages(fresh.data, fresh.pendingIds, chatActionsRef.current.messages);
|
| 570 |
+
|
| 571 |
+
const currentCount = chatActionsRef.current.messages.length;
|
| 572 |
+
if (msgs.length > currentCount || currentCount === 0) {
|
| 573 |
chat.setMessages(msgs);
|
| 574 |
saveMessages(sessionId, msgs);
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
// If backend stopped processing, clean up
|
| 578 |
if (fresh.info && !fresh.info.is_processing) {
|
| 579 |
updateSession(sessionId, { isProcessing: false });
|
|
|
|
| 597 |
if (chat.messages.length !== prevLenRef.current) {
|
| 598 |
prevLenRef.current = chat.messages.length;
|
| 599 |
saveMessages(sessionId, chat.messages);
|
| 600 |
+
}
|
| 601 |
}, [sessionId, chat.messages]);
|
| 602 |
|
| 603 |
// -- Undo last turn (REST call + client-side message removal) -----------
|
|
|
|
| 653 |
if (hasApproved) {
|
| 654 |
updateSession(sessionId, { isProcessing: true });
|
| 655 |
}
|
| 656 |
+
|
| 657 |
+
// Persist updated tool states so a page refresh during execution
|
| 658 |
+
// won't restore stale approval-requested state from localStorage.
|
| 659 |
+
saveMessages(sessionId, chatActionsRef.current.messages);
|
| 660 |
+
|
| 661 |
return true;
|
| 662 |
},
|
| 663 |
[sessionId, chat, updateSession, setNeedsAttention],
|
|
|
|
| 672 |
apiFetch(`/api/interrupt/${sessionId}`, { method: 'POST' }).catch(() => {});
|
| 673 |
}, [sessionId, updateSession]);
|
| 674 |
|
| 675 |
+
// -- Edit message + regenerate from that point ----------------------------
|
| 676 |
+
const editAndRegenerate = useCallback(async (messageId: string, newText: string) => {
|
| 677 |
+
try {
|
| 678 |
+
const msgs = chatActionsRef.current.messages;
|
| 679 |
+
const setMsgs = chatActionsRef.current.setMessages;
|
| 680 |
+
if (!setMsgs) return;
|
| 681 |
+
|
| 682 |
+
// Find the target message and compute user message index (0-indexed, skipping system)
|
| 683 |
+
const msgIndex = msgs.findIndex(m => m.id === messageId);
|
| 684 |
+
if (msgIndex < 0) return;
|
| 685 |
+
|
| 686 |
+
let userMsgIndex = 0;
|
| 687 |
+
for (let i = 0; i < msgIndex; i++) {
|
| 688 |
+
if (msgs[i].role === 'user') userMsgIndex++;
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
// 1. Truncate backend history
|
| 692 |
+
const res = await apiFetch(`/api/truncate/${sessionId}`, {
|
| 693 |
+
method: 'POST',
|
| 694 |
+
body: JSON.stringify({ user_message_index: userMsgIndex }),
|
| 695 |
+
headers: { 'Content-Type': 'application/json' },
|
| 696 |
+
});
|
| 697 |
+
if (!res.ok) {
|
| 698 |
+
logger.error('Truncate API returned', res.status);
|
| 699 |
+
return;
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
// 2. Truncate frontend messages
|
| 703 |
+
const truncated = msgs.slice(0, msgIndex);
|
| 704 |
+
setMsgs(truncated);
|
| 705 |
+
saveMessages(sessionId, truncated);
|
| 706 |
+
|
| 707 |
+
// 3. Send the edited message (reuses existing transport + /api/chat)
|
| 708 |
+
chat.sendMessage({ text: newText, metadata: { createdAt: new Date().toISOString() } });
|
| 709 |
+
} catch (e) {
|
| 710 |
+
logger.error('Edit and regenerate failed:', e);
|
| 711 |
+
}
|
| 712 |
+
}, [sessionId, chat]);
|
| 713 |
+
|
| 714 |
return {
|
| 715 |
messages: chat.messages,
|
| 716 |
sendMessage: chat.sendMessage,
|
| 717 |
stop,
|
| 718 |
status: chat.status,
|
| 719 |
undoLastTurn,
|
| 720 |
+
editAndRegenerate,
|
| 721 |
approveTools,
|
| 722 |
};
|
| 723 |
}
|
frontend/src/hooks/useOrgMembership.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Polls backend for org membership status.
|
| 3 |
+
* When membership is detected, updates the user in the agent store
|
| 4 |
+
* and closes any org-join popup that was opened.
|
| 5 |
+
*/
|
| 6 |
+
import { useEffect, useRef } from 'react';
|
| 7 |
+
import { useAgentStore } from '@/store/agentStore';
|
| 8 |
+
|
| 9 |
+
const POLL_INTERVAL_MS = 3000;
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* @param enabled Only poll when true (user is authenticated but not yet confirmed as org member)
|
| 13 |
+
* @returns popupRef β assign `window.open()` result to `.current` so the hook can auto-close it
|
| 14 |
+
*/
|
| 15 |
+
export function useOrgMembership(enabled: boolean) {
|
| 16 |
+
const user = useAgentStore((s) => s.user);
|
| 17 |
+
const setUser = useAgentStore((s) => s.setUser);
|
| 18 |
+
const popupRef = useRef<Window | null>(null);
|
| 19 |
+
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
if (!enabled || user?.orgMember) return;
|
| 22 |
+
|
| 23 |
+
let cancelled = false;
|
| 24 |
+
|
| 25 |
+
const check = async () => {
|
| 26 |
+
try {
|
| 27 |
+
const res = await fetch('/auth/org-membership', { credentials: 'include' });
|
| 28 |
+
if (!res.ok || cancelled) return;
|
| 29 |
+
const data = await res.json();
|
| 30 |
+
if (cancelled) return;
|
| 31 |
+
if (data.is_member && user) {
|
| 32 |
+
setUser({ ...user, orgMember: true });
|
| 33 |
+
try { popupRef.current?.close(); } catch { /* cross-origin or already closed */ }
|
| 34 |
+
popupRef.current = null;
|
| 35 |
+
}
|
| 36 |
+
} catch { /* backend unreachable β skip */ }
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
check();
|
| 40 |
+
const id = setInterval(check, POLL_INTERVAL_MS);
|
| 41 |
+
return () => { cancelled = true; clearInterval(id); };
|
| 42 |
+
}, [enabled, user?.orgMember, user, setUser]);
|
| 43 |
+
|
| 44 |
+
return popupRef;
|
| 45 |
+
}
|
frontend/src/lib/chat-message-store.ts
CHANGED
|
@@ -38,7 +38,8 @@ function writeAll(map: MessagesMap): void {
|
|
| 38 |
|
| 39 |
export function loadMessages(sessionId: string): UIMessage[] {
|
| 40 |
const map = readAll();
|
| 41 |
-
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
export function saveMessages(sessionId: string, messages: UIMessage[]): void {
|
|
|
|
| 38 |
|
| 39 |
export function loadMessages(sessionId: string): UIMessage[] {
|
| 40 |
const map = readAll();
|
| 41 |
+
const messages = map[sessionId] ?? [];
|
| 42 |
+
return messages;
|
| 43 |
}
|
| 44 |
|
| 45 |
export function saveMessages(sessionId: string, messages: UIMessage[]): void {
|
frontend/src/lib/convert-llm-messages.ts
CHANGED
|
@@ -16,19 +16,24 @@ interface LLMMessage {
|
|
| 16 |
name?: string | null;
|
| 17 |
}
|
| 18 |
|
| 19 |
-
|
|
|
|
|
|
|
| 20 |
function nextId(): string {
|
| 21 |
-
return `msg-${
|
| 22 |
}
|
| 23 |
|
| 24 |
/**
|
| 25 |
* @param pendingApprovalIds - Set of tool_call_ids that are waiting for approval.
|
| 26 |
* When provided, matching tool calls without results will get state
|
| 27 |
* 'approval-requested' instead of 'input-available'.
|
|
|
|
|
|
|
| 28 |
*/
|
| 29 |
export function llmMessagesToUIMessages(
|
| 30 |
messages: LLMMessage[],
|
| 31 |
pendingApprovalIds?: Set<string>,
|
|
|
|
| 32 |
): UIMessage[] {
|
| 33 |
// Build a map of tool_call_id -> tool result for pairing
|
| 34 |
const toolResults = new Map<string, { output: string; isError: boolean }>();
|
|
@@ -43,13 +48,22 @@ export function llmMessagesToUIMessages(
|
|
| 43 |
|
| 44 |
const uiMessages: UIMessage[] = [];
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
for (const msg of messages) {
|
| 47 |
if (msg.role === 'system') continue;
|
| 48 |
if (msg.role === 'tool') continue; // handled via tool_calls pairing
|
| 49 |
|
| 50 |
if (msg.role === 'user') {
|
|
|
|
|
|
|
| 51 |
uiMessages.push({
|
| 52 |
-
id: nextId(),
|
| 53 |
role: 'user',
|
| 54 |
parts: [{ type: 'text', text: msg.content || '' }],
|
| 55 |
});
|
|
@@ -109,8 +123,11 @@ export function llmMessagesToUIMessages(
|
|
| 109 |
if (prev && prev.role === 'assistant') {
|
| 110 |
prev.parts.push(...parts);
|
| 111 |
} else {
|
|
|
|
|
|
|
|
|
|
| 112 |
uiMessages.push({
|
| 113 |
-
id:
|
| 114 |
role: 'assistant',
|
| 115 |
parts,
|
| 116 |
});
|
|
|
|
| 16 |
name?: string | null;
|
| 17 |
}
|
| 18 |
|
| 19 |
+
// Generate stable IDs based on message position to prevent duplicate renders
|
| 20 |
+
// when the same message is re-converted multiple times (e.g., during polling)
|
| 21 |
+
let uiMessageCounter = 0;
|
| 22 |
function nextId(): string {
|
| 23 |
+
return `msg-${++uiMessageCounter}`;
|
| 24 |
}
|
| 25 |
|
| 26 |
/**
|
| 27 |
* @param pendingApprovalIds - Set of tool_call_ids that are waiting for approval.
|
| 28 |
* When provided, matching tool calls without results will get state
|
| 29 |
* 'approval-requested' instead of 'input-available'.
|
| 30 |
+
* @param existingUIMessages - Current UI messages to preserve IDs when content matches.
|
| 31 |
+
* This prevents React from re-rendering messages with new IDs during polling.
|
| 32 |
*/
|
| 33 |
export function llmMessagesToUIMessages(
|
| 34 |
messages: LLMMessage[],
|
| 35 |
pendingApprovalIds?: Set<string>,
|
| 36 |
+
existingUIMessages?: UIMessage[],
|
| 37 |
): UIMessage[] {
|
| 38 |
// Build a map of tool_call_id -> tool result for pairing
|
| 39 |
const toolResults = new Map<string, { output: string; isError: boolean }>();
|
|
|
|
| 48 |
|
| 49 |
const uiMessages: UIMessage[] = [];
|
| 50 |
|
| 51 |
+
// Helper to get existing message ID at a given position if roles match
|
| 52 |
+
const getExistingId = (index: number, role: 'user' | 'assistant'): string | null => {
|
| 53 |
+
if (!existingUIMessages || index >= existingUIMessages.length) return null;
|
| 54 |
+
const existing = existingUIMessages[index];
|
| 55 |
+
return existing.role === role ? existing.id : null;
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
for (const msg of messages) {
|
| 59 |
if (msg.role === 'system') continue;
|
| 60 |
if (msg.role === 'tool') continue; // handled via tool_calls pairing
|
| 61 |
|
| 62 |
if (msg.role === 'user') {
|
| 63 |
+
// Try to reuse existing ID if the message at this position matches
|
| 64 |
+
const existingId = getExistingId(uiMessages.length, 'user');
|
| 65 |
uiMessages.push({
|
| 66 |
+
id: existingId || nextId(),
|
| 67 |
role: 'user',
|
| 68 |
parts: [{ type: 'text', text: msg.content || '' }],
|
| 69 |
});
|
|
|
|
| 123 |
if (prev && prev.role === 'assistant') {
|
| 124 |
prev.parts.push(...parts);
|
| 125 |
} else {
|
| 126 |
+
// Try to reuse existing ID if the message at this position matches
|
| 127 |
+
const existingId = getExistingId(uiMessages.length, 'assistant');
|
| 128 |
+
const newId = existingId || nextId();
|
| 129 |
uiMessages.push({
|
| 130 |
+
id: newId,
|
| 131 |
role: 'assistant',
|
| 132 |
parts,
|
| 133 |
});
|
frontend/src/lib/research-store.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Persist research sub-agent state (steps + stats) per session.
|
| 3 |
+
* Survives page refresh so the rolling display isn't lost mid-research.
|
| 4 |
+
*/
|
| 5 |
+
import type { PerSessionState } from '@/store/agentStore';
|
| 6 |
+
|
| 7 |
+
/** Max steps to keep in storage and display. Single source of truth. */
|
| 8 |
+
export const RESEARCH_MAX_STEPS = 40;
|
| 9 |
+
|
| 10 |
+
const STORAGE_KEY = 'hf-agent-research';
|
| 11 |
+
|
| 12 |
+
type ResearchState = {
|
| 13 |
+
steps: string[];
|
| 14 |
+
stats: PerSessionState['researchStats'];
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
type ResearchMap = Record<string, ResearchState>;
|
| 18 |
+
|
| 19 |
+
function readAll(): ResearchMap {
|
| 20 |
+
try {
|
| 21 |
+
const raw = localStorage.getItem(STORAGE_KEY);
|
| 22 |
+
return raw ? JSON.parse(raw) : {};
|
| 23 |
+
} catch {
|
| 24 |
+
return {};
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function writeAll(map: ResearchMap): void {
|
| 29 |
+
try {
|
| 30 |
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
| 31 |
+
} catch { /* quota exceeded β ignore */ }
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export function saveResearch(
|
| 35 |
+
sessionId: string,
|
| 36 |
+
steps: string[],
|
| 37 |
+
stats: PerSessionState['researchStats'],
|
| 38 |
+
): void {
|
| 39 |
+
const map = readAll();
|
| 40 |
+
map[sessionId] = {
|
| 41 |
+
steps: steps.slice(-RESEARCH_MAX_STEPS),
|
| 42 |
+
stats,
|
| 43 |
+
};
|
| 44 |
+
writeAll(map);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
export function loadResearch(sessionId: string): ResearchState | null {
|
| 48 |
+
const map = readAll();
|
| 49 |
+
return map[sessionId] ?? null;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
export function clearResearch(sessionId: string): void {
|
| 53 |
+
const map = readAll();
|
| 54 |
+
delete map[sessionId];
|
| 55 |
+
writeAll(map);
|
| 56 |
+
}
|
frontend/src/lib/sse-chat-transport.ts
CHANGED
|
@@ -277,7 +277,8 @@ export class SSEChatTransport implements ChatTransport<UIMessage> {
|
|
| 277 |
this.sessionId = sessionId;
|
| 278 |
this.sideChannel = sideChannel;
|
| 279 |
// Mark as connected immediately β no persistent connection to establish
|
| 280 |
-
|
|
|
|
| 281 |
}
|
| 282 |
|
| 283 |
updateSideChannel(sideChannel: SideChannelCallbacks): void {
|
|
|
|
| 277 |
this.sessionId = sessionId;
|
| 278 |
this.sideChannel = sideChannel;
|
| 279 |
// Mark as connected immediately β no persistent connection to establish
|
| 280 |
+
// Defer to avoid setState during render
|
| 281 |
+
queueMicrotask(() => sideChannel.onConnectionChange(true));
|
| 282 |
}
|
| 283 |
|
| 284 |
updateSideChannel(sideChannel: SideChannelCallbacks): void {
|
frontend/src/store/agentStore.ts
CHANGED
|
@@ -50,7 +50,8 @@ export type ActivityStatus =
|
|
| 50 |
| { type: 'thinking' }
|
| 51 |
| { type: 'tool'; toolName: string; description?: string }
|
| 52 |
| { type: 'waiting-approval' }
|
| 53 |
-
| { type: 'streaming' }
|
|
|
|
| 54 |
|
| 55 |
/** State that is tracked per-session (each session has its own copy). */
|
| 56 |
export interface PerSessionState {
|
|
@@ -62,6 +63,8 @@ export interface PerSessionState {
|
|
| 62 |
plan: PlanItem[];
|
| 63 |
/** Steps completed by the research sub-agent (tool_log events). */
|
| 64 |
researchSteps: string[];
|
|
|
|
|
|
|
| 65 |
}
|
| 66 |
|
| 67 |
const defaultSessionState: PerSessionState = {
|
|
@@ -72,6 +75,7 @@ const defaultSessionState: PerSessionState = {
|
|
| 72 |
panelEditable: false,
|
| 73 |
plan: [],
|
| 74 |
researchSteps: [],
|
|
|
|
| 75 |
};
|
| 76 |
|
| 77 |
interface AgentStore {
|
|
@@ -101,6 +105,15 @@ interface AgentStore {
|
|
| 101 |
// Job URLs (tool_call_id -> job URL) for HF jobs
|
| 102 |
jobUrls: Record<string, string>;
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
// ββ Per-session actions βββββββββββββββββββββββββββββββββββββββββββββ
|
| 105 |
|
| 106 |
/** Update a session's state. If it's the active session, also update flat state. */
|
|
@@ -138,6 +151,15 @@ interface AgentStore {
|
|
| 138 |
|
| 139 |
setJobUrl: (toolCallId: string, jobUrl: string) => void;
|
| 140 |
getJobUrl: (toolCallId: string) => string | undefined;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
}
|
| 142 |
|
| 143 |
/**
|
|
@@ -159,6 +181,44 @@ function syncSnapshot(
|
|
| 159 |
};
|
| 160 |
}
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
export const useAgentStore = create<AgentStore>()((set, get) => ({
|
| 163 |
sessionStates: {},
|
| 164 |
activeSessionId: null,
|
|
@@ -178,6 +238,9 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
|
|
| 178 |
|
| 179 |
editedScripts: {},
|
| 180 |
jobUrls: {},
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
// ββ Per-session state management ββββββββββββββββββββββββββββββββββ
|
| 183 |
|
|
@@ -189,7 +252,7 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
|
|
| 189 |
// Apply the processingβidle side effect
|
| 190 |
const processingCleared = 'isProcessing' in updates && !updates.isProcessing;
|
| 191 |
if (processingCleared) {
|
| 192 |
-
if (updated.activityStatus.type !== 'waiting-approval') {
|
| 193 |
updated.activityStatus = { type: 'idle' };
|
| 194 |
}
|
| 195 |
}
|
|
@@ -237,6 +300,7 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
|
|
| 237 |
panelEditable: state.panelEditable,
|
| 238 |
plan: state.plan,
|
| 239 |
researchSteps: state.sessionStates[state.activeSessionId]?.researchSteps ?? [],
|
|
|
|
| 240 |
};
|
| 241 |
}
|
| 242 |
|
|
@@ -267,7 +331,7 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
|
|
| 267 |
|
| 268 |
setProcessing: (isProcessing) => {
|
| 269 |
const current = get().activityStatus;
|
| 270 |
-
const preserveStatus = current.type === 'waiting-approval';
|
| 271 |
set({ isProcessing, ...(!isProcessing && !preserveStatus ? { activityStatus: { type: 'idle' } } : {}) });
|
| 272 |
},
|
| 273 |
setConnected: (isConnected) => set({ isConnected }),
|
|
@@ -349,4 +413,38 @@ export const useAgentStore = create<AgentStore>()((set, get) => ({
|
|
| 349 |
},
|
| 350 |
|
| 351 |
getJobUrl: (toolCallId) => get().jobUrls[toolCallId],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
}));
|
|
|
|
| 50 |
| { type: 'thinking' }
|
| 51 |
| { type: 'tool'; toolName: string; description?: string }
|
| 52 |
| { type: 'waiting-approval' }
|
| 53 |
+
| { type: 'streaming' }
|
| 54 |
+
| { type: 'cancelled' };
|
| 55 |
|
| 56 |
/** State that is tracked per-session (each session has its own copy). */
|
| 57 |
export interface PerSessionState {
|
|
|
|
| 63 |
plan: PlanItem[];
|
| 64 |
/** Steps completed by the research sub-agent (tool_log events). */
|
| 65 |
researchSteps: string[];
|
| 66 |
+
/** Live stats from the research sub-agent. */
|
| 67 |
+
researchStats: { toolCount: number; tokenCount: number; startedAt: number | null; finalElapsed: number | null };
|
| 68 |
}
|
| 69 |
|
| 70 |
const defaultSessionState: PerSessionState = {
|
|
|
|
| 75 |
panelEditable: false,
|
| 76 |
plan: [],
|
| 77 |
researchSteps: [],
|
| 78 |
+
researchStats: { toolCount: 0, tokenCount: 0, startedAt: null, finalElapsed: null },
|
| 79 |
};
|
| 80 |
|
| 81 |
interface AgentStore {
|
|
|
|
| 105 |
// Job URLs (tool_call_id -> job URL) for HF jobs
|
| 106 |
jobUrls: Record<string, string>;
|
| 107 |
|
| 108 |
+
// Job statuses (tool_call_id -> job status) for HF jobs
|
| 109 |
+
jobStatuses: Record<string, string>;
|
| 110 |
+
|
| 111 |
+
// Tool error states (tool_call_id -> true if errored) - persisted across renders
|
| 112 |
+
toolErrors: Record<string, boolean>;
|
| 113 |
+
|
| 114 |
+
// Tool rejected states (tool_call_id -> true if rejected by user) - persisted across renders
|
| 115 |
+
rejectedTools: Record<string, boolean>;
|
| 116 |
+
|
| 117 |
// ββ Per-session actions βββββββββββββββββββββββββββββββββββββββββββββ
|
| 118 |
|
| 119 |
/** Update a session's state. If it's the active session, also update flat state. */
|
|
|
|
| 151 |
|
| 152 |
setJobUrl: (toolCallId: string, jobUrl: string) => void;
|
| 153 |
getJobUrl: (toolCallId: string) => string | undefined;
|
| 154 |
+
|
| 155 |
+
setJobStatus: (toolCallId: string, status: string) => void;
|
| 156 |
+
getJobStatus: (toolCallId: string) => string | undefined;
|
| 157 |
+
|
| 158 |
+
setToolError: (toolCallId: string, hasError: boolean) => void;
|
| 159 |
+
getToolError: (toolCallId: string) => boolean | undefined;
|
| 160 |
+
|
| 161 |
+
setToolRejected: (toolCallId: string, isRejected: boolean) => void;
|
| 162 |
+
getToolRejected: (toolCallId: string) => boolean | undefined;
|
| 163 |
}
|
| 164 |
|
| 165 |
/**
|
|
|
|
| 181 |
};
|
| 182 |
}
|
| 183 |
|
| 184 |
+
// Load persisted tool errors from localStorage
|
| 185 |
+
function loadToolErrors(): Record<string, boolean> {
|
| 186 |
+
try {
|
| 187 |
+
const stored = localStorage.getItem('hf-agent-tool-errors');
|
| 188 |
+
return stored ? JSON.parse(stored) : {};
|
| 189 |
+
} catch {
|
| 190 |
+
return {};
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
// Save tool errors to localStorage
|
| 195 |
+
function saveToolErrors(errors: Record<string, boolean>): void {
|
| 196 |
+
try {
|
| 197 |
+
localStorage.setItem('hf-agent-tool-errors', JSON.stringify(errors));
|
| 198 |
+
} catch (e) {
|
| 199 |
+
console.warn('Failed to persist tool errors:', e);
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
// Load persisted rejected tools from localStorage
|
| 204 |
+
function loadRejectedTools(): Record<string, boolean> {
|
| 205 |
+
try {
|
| 206 |
+
const stored = localStorage.getItem('hf-agent-rejected-tools');
|
| 207 |
+
return stored ? JSON.parse(stored) : {};
|
| 208 |
+
} catch {
|
| 209 |
+
return {};
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
// Save rejected tools to localStorage
|
| 214 |
+
function saveRejectedTools(rejected: Record<string, boolean>): void {
|
| 215 |
+
try {
|
| 216 |
+
localStorage.setItem('hf-agent-rejected-tools', JSON.stringify(rejected));
|
| 217 |
+
} catch (e) {
|
| 218 |
+
console.warn('Failed to persist rejected tools:', e);
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
export const useAgentStore = create<AgentStore>()((set, get) => ({
|
| 223 |
sessionStates: {},
|
| 224 |
activeSessionId: null,
|
|
|
|
| 238 |
|
| 239 |
editedScripts: {},
|
| 240 |
jobUrls: {},
|
| 241 |
+
jobStatuses: {},
|
| 242 |
+
toolErrors: loadToolErrors(),
|
| 243 |
+
rejectedTools: loadRejectedTools(),
|
| 244 |
|
| 245 |
// ββ Per-session state management ββββββββββββββββββββββββββββββββββ
|
| 246 |
|
|
|
|
| 252 |
// Apply the processingβidle side effect
|
| 253 |
const processingCleared = 'isProcessing' in updates && !updates.isProcessing;
|
| 254 |
if (processingCleared) {
|
| 255 |
+
if (updated.activityStatus.type !== 'waiting-approval' && updated.activityStatus.type !== 'cancelled') {
|
| 256 |
updated.activityStatus = { type: 'idle' };
|
| 257 |
}
|
| 258 |
}
|
|
|
|
| 300 |
panelEditable: state.panelEditable,
|
| 301 |
plan: state.plan,
|
| 302 |
researchSteps: state.sessionStates[state.activeSessionId]?.researchSteps ?? [],
|
| 303 |
+
researchStats: state.sessionStates[state.activeSessionId]?.researchStats ?? defaultSessionState.researchStats,
|
| 304 |
};
|
| 305 |
}
|
| 306 |
|
|
|
|
| 331 |
|
| 332 |
setProcessing: (isProcessing) => {
|
| 333 |
const current = get().activityStatus;
|
| 334 |
+
const preserveStatus = current.type === 'waiting-approval' || current.type === 'cancelled';
|
| 335 |
set({ isProcessing, ...(!isProcessing && !preserveStatus ? { activityStatus: { type: 'idle' } } : {}) });
|
| 336 |
},
|
| 337 |
setConnected: (isConnected) => set({ isConnected }),
|
|
|
|
| 413 |
},
|
| 414 |
|
| 415 |
getJobUrl: (toolCallId) => get().jobUrls[toolCallId],
|
| 416 |
+
|
| 417 |
+
// ββ Job Statuses ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 418 |
+
|
| 419 |
+
setJobStatus: (toolCallId, status) => {
|
| 420 |
+
set((state) => ({
|
| 421 |
+
jobStatuses: { ...state.jobStatuses, [toolCallId]: status },
|
| 422 |
+
}));
|
| 423 |
+
},
|
| 424 |
+
|
| 425 |
+
getJobStatus: (toolCallId) => get().jobStatuses[toolCallId],
|
| 426 |
+
|
| 427 |
+
// ββ Tool Errors βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 428 |
+
|
| 429 |
+
setToolError: (toolCallId, hasError) => {
|
| 430 |
+
set((state) => {
|
| 431 |
+
const updated = { ...state.toolErrors, [toolCallId]: hasError };
|
| 432 |
+
saveToolErrors(updated);
|
| 433 |
+
return { toolErrors: updated };
|
| 434 |
+
});
|
| 435 |
+
},
|
| 436 |
+
|
| 437 |
+
getToolError: (toolCallId) => get().toolErrors[toolCallId],
|
| 438 |
+
|
| 439 |
+
// ββ Tool Rejections ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 440 |
+
|
| 441 |
+
setToolRejected: (toolCallId, isRejected) => {
|
| 442 |
+
set((state) => {
|
| 443 |
+
const updated = { ...state.rejectedTools, [toolCallId]: isRejected };
|
| 444 |
+
saveRejectedTools(updated);
|
| 445 |
+
return { rejectedTools: updated };
|
| 446 |
+
});
|
| 447 |
+
},
|
| 448 |
+
|
| 449 |
+
getToolRejected: (toolCallId) => get().rejectedTools[toolCallId],
|
| 450 |
}));
|
frontend/src/types/agent.ts
CHANGED
|
@@ -29,4 +29,5 @@ export interface User {
|
|
| 29 |
username?: string;
|
| 30 |
name?: string;
|
| 31 |
picture?: string;
|
|
|
|
| 32 |
}
|
|
|
|
| 29 |
username?: string;
|
| 30 |
name?: string;
|
| 31 |
picture?: string;
|
| 32 |
+
orgMember?: boolean;
|
| 33 |
}
|
uv.lock
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|