mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 12:48:54 +08:00
Compare commits
6 Commits
dependabot
...
test-save-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38368d17da | ||
|
|
07ac185904 | ||
|
|
3acf73161f | ||
|
|
dd60c49bb8 | ||
|
|
6fe4821926 | ||
|
|
d986bb0c6d |
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -125,7 +125,7 @@ jobs:
|
||||
# (including PRs) get balanced slicing.
|
||||
save-durations:
|
||||
needs: test
|
||||
if: always() && github.ref == 'refs/heads/main'
|
||||
if: needs.test.result == 'success' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all slice durations
|
||||
|
||||
144
docs/design/profile-builder.md
Normal file
144
docs/design/profile-builder.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# Profile Builder — Dashboard-Native, Full-Featured Profile Creation
|
||||
|
||||
Status: design proposal (not yet implemented)
|
||||
Author: drafted for Teknium
|
||||
Supersedes: PR #31781 (prompt_toolkit `hermes profile wizard`)
|
||||
|
||||
## Why this, not the CLI wizard
|
||||
|
||||
PR #31781 added a keyboard-driven `hermes profile wizard` in the terminal.
|
||||
The decision is to **not** build the profile-creation experience in the CLI.
|
||||
The dashboard already owns mature, separate pages for every element a profile
|
||||
needs, and a profile is just a HERMES_HOME directory — so the dashboard is the
|
||||
right home for a full-featured builder, and it can reuse everything that
|
||||
already exists.
|
||||
|
||||
A profile = a full `~/.hermes/profiles/<name>/` directory with its own:
|
||||
- `config.yaml` — holds `model`/`provider`, `mcp_servers`, enabled skills
|
||||
- `skills/` — physical SKILL.md files (built-in seed + optional + hub installs)
|
||||
- `.env` — secrets
|
||||
- `SOUL.md` / `USER.md` — identity
|
||||
|
||||
So per-profile scoping of Model, MCPs, and Skills is **native** — no data-model
|
||||
change needed. The gap is purely UX: creation today is a thin modal
|
||||
(name + clone + model + description), and you can only compose skills/MCPs
|
||||
*after* the profile exists, by visiting other pages and remembering to scope
|
||||
them.
|
||||
|
||||
## What already exists (reuse, don't rebuild)
|
||||
|
||||
| Element | Existing page | Existing API | Profile-scopable? |
|
||||
|---|---|---|---|
|
||||
| Name / Description | ProfilesPage create modal | `POST /api/profiles` (`create_profile`) | yes (args) |
|
||||
| Model + Provider | ModelsPage | `_write_profile_model(profile_dir, …)` | yes — HERMES_HOME override, already wired into create endpoint |
|
||||
| MCPs | McpPage | `mcp_config._save_mcp_server` + `/api/mcp/catalog` | yes — wrap with HERMES_HOME override |
|
||||
| Skills (built-in/optional) | SkillsPage | `GET /api/skills`, `/api/skills/toggle` | yes — config write |
|
||||
| Skills (hub) | SkillsPage | `/api/skills/hub/search`, `/api/skills/hub/install` | **only via subprocess** — see seam #1 |
|
||||
|
||||
## Two architectural seams found while grounding this design
|
||||
|
||||
These are load-bearing — they change the implementation, not just the polish.
|
||||
|
||||
### Seam #1 — hub-skill install cannot use the HERMES_HOME override
|
||||
|
||||
`tools/skills_hub.py` binds `SKILLS_DIR = HERMES_HOME / "skills"` at **module
|
||||
import time**. The context-local `set_hermes_home_override()` swap (which makes
|
||||
`_write_profile_model` and the MCP write land in the target profile) does NOT
|
||||
retroactively rebind that already-imported module global. So a data-layer wrap
|
||||
of hub install would write into the dashboard's *own* active profile, not the
|
||||
new one.
|
||||
|
||||
The correct mechanism is the existing subprocess path: `_spawn_hermes_action`
|
||||
runs `python -m hermes_cli.main <subcommand>`, and `_apply_profile_override()`
|
||||
re-reads `sys.argv` at import in the fresh child. Prepend `-p <profile>`:
|
||||
|
||||
```python
|
||||
_spawn_hermes_action(["-p", profile, "skills", "install", identifier], "skills-install")
|
||||
```
|
||||
|
||||
A fresh subprocess re-imports `skills_hub` with the profile's HERMES_HOME bound
|
||||
from the start, so `SKILLS_DIR` resolves to `<profile>/skills/`. Correct by
|
||||
construction.
|
||||
|
||||
### Seam #2 — hub installs are async, so create cannot be fully atomic
|
||||
|
||||
Built-in/optional skill enabling and MCP writes are **synchronous config ops**
|
||||
and can be part of the create call. Hub installs are long-running git fetches
|
||||
spawned detached (`_spawn_hermes_action` returns a PID immediately). So the
|
||||
create flow is:
|
||||
|
||||
1. `create_profile()` — make the dir (synchronous)
|
||||
2. write model (synchronous, HERMES_HOME override)
|
||||
3. write selected MCP servers (synchronous, HERMES_HOME override)
|
||||
4. seed/enable selected built-in + optional skills (synchronous)
|
||||
5. spawn `hermes -p <profile> skills install <id>` per hub skill (async, returns PIDs)
|
||||
|
||||
Steps 1–4 commit before the response; step 5 returns a list of action PIDs the
|
||||
UI polls (same pattern as today's SkillsPage hub install). The builder's
|
||||
"Review → Create" returns `{ok, name, path, hub_installs: [{id, pid}]}` and the
|
||||
final screen shows live install progress for the hub skills.
|
||||
|
||||
## Proposed backend change (small, follows existing patterns)
|
||||
|
||||
Extend `ProfileCreate` and the create endpoint — no new endpoints, no rewrite:
|
||||
|
||||
```python
|
||||
class ProfileCreate(BaseModel):
|
||||
name: str
|
||||
clone_from_default: bool = False
|
||||
clone_all: bool = False
|
||||
no_skills: bool = False
|
||||
description: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
# NEW — all optional, all best-effort post-create (profile already exists)
|
||||
mcp_servers: List[MCPServerCreate] = [] # synchronous, HERMES_HOME override
|
||||
builtin_skills: List[str] = [] # synchronous enable/seed
|
||||
hub_skills: List[str] = [] # async spawn, returns PIDs
|
||||
```
|
||||
|
||||
The endpoint already does best-effort post-create steps (`seed_profile_skills`,
|
||||
`_write_profile_model`). Add two more best-effort blocks (MCP write, hub-skill
|
||||
spawn) in the same style — a failure in any of them must not 500 the create,
|
||||
since the profile dir already exists and the user can fix it from the relevant
|
||||
page afterward. Mirror `_write_profile_model`'s HERMES_HOME-override helper for
|
||||
the MCP write (`_write_profile_mcp_servers(profile_dir, servers)`).
|
||||
|
||||
## Proposed frontend — dedicated builder page `/profiles/new`
|
||||
|
||||
A full page (not the cramped modal), stepped, each step reusing the existing
|
||||
page's component + API, targeted at the new profile:
|
||||
|
||||
```
|
||||
① Identity Name + Description (+ optional clone-from existing profile)
|
||||
② Model Provider + model picker (reuse ModelsPage picker)
|
||||
③ Skills Tabs: Built-in · Optional · Hub-search
|
||||
multi-select; "Start from default bundle" preset button
|
||||
④ MCPs Tabs: Catalog browse · Manual add (reuse McpPage form)
|
||||
⑤ Review Blueprint preview → Create
|
||||
→ progress screen for async hub installs
|
||||
```
|
||||
|
||||
Nothing writes to disk until ⑤.
|
||||
|
||||
## Open product decisions (need Teknium)
|
||||
|
||||
1. **Skills seeding default.** Fresh profiles auto-seed the default bundle
|
||||
today. In the builder, should the skill step **replace** the bundle (pick
|
||||
exactly what you want; offer a "start from default bundle" preset) or
|
||||
**augment** it? Recommendation: replace + preset button.
|
||||
|
||||
2. **Page vs richer modal.** Dedicated `/profiles/new` page (room to grow:
|
||||
SOUL editing, multi-agent fleets later) vs a bigger create modal on
|
||||
ProfilesPage. Recommendation: dedicated page — matches "full-featured / way
|
||||
more options."
|
||||
|
||||
## Verification plan (when built)
|
||||
|
||||
- Backend E2E with isolated HERMES_HOME: POST a full create body
|
||||
(name + model + 2 MCPs + 3 builtin skills + 1 hub skill), assert the new
|
||||
profile dir has the model in config.yaml, both MCP servers in config.yaml,
|
||||
the builtin skills enabled, and a spawned PID for the hub skill. Negative:
|
||||
a bad MCP entry must not 500 the create.
|
||||
- `cd web && npm run build` (no JS test suite in web/).
|
||||
- Targeted: `pytest tests/<web_server profile tests> -k profile_create`.
|
||||
@@ -20,9 +20,11 @@ import hmac
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -657,6 +659,21 @@ class AudioTranscriptionRequest(BaseModel):
|
||||
mime_type: Optional[str] = None
|
||||
|
||||
|
||||
class ManagedFileUpload(BaseModel):
|
||||
path: str
|
||||
data_url: str
|
||||
overwrite: bool = True
|
||||
|
||||
|
||||
class ManagedDirectoryCreate(BaseModel):
|
||||
path: str
|
||||
|
||||
|
||||
class ManagedFileDelete(BaseModel):
|
||||
path: str
|
||||
recursive: bool = False
|
||||
|
||||
|
||||
_AUDIO_MIME_EXTENSIONS: Dict[str, str] = {
|
||||
"audio/aac": ".aac",
|
||||
"audio/flac": ".flac",
|
||||
@@ -819,6 +836,16 @@ _MEDIA_CONTENT_TYPES = {
|
||||
".ico": "image/x-icon",
|
||||
}
|
||||
_MEDIA_MAX_BYTES = 25 * 1024 * 1024
|
||||
_MANAGED_FILES_ROOT_ENV = "HERMES_DASHBOARD_FILES_ROOT"
|
||||
_MANAGED_FILE_MAX_BYTES = 100 * 1024 * 1024
|
||||
_HOSTED_MANAGED_FILES_ROOT = Path("/opt/data")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManagedFilesPolicy:
|
||||
default_path: Path
|
||||
locked_root: Path | None
|
||||
can_change_path: bool
|
||||
|
||||
|
||||
def _media_serve_roots() -> list[Path]:
|
||||
@@ -874,6 +901,297 @@ async def get_media(path: str):
|
||||
return {"data_url": f"data:{_MEDIA_CONTENT_TYPES[target.suffix.lower()]};base64,{encoded}"}
|
||||
|
||||
|
||||
def _canonical_path(path: Path, *, require_exists: bool = False) -> Path:
|
||||
try:
|
||||
return path.expanduser().resolve(strict=require_exists)
|
||||
except FileNotFoundError:
|
||||
if require_exists:
|
||||
raise HTTPException(status_code=404, detail="Path not found")
|
||||
raise
|
||||
except (OSError, RuntimeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
|
||||
|
||||
def _ensure_managed_root(raw_path: str | Path) -> Path:
|
||||
root = Path(raw_path).expanduser()
|
||||
try:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
resolved = root.resolve()
|
||||
except (OSError, RuntimeError) as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Managed files root is unavailable: {exc}")
|
||||
if not resolved.is_dir():
|
||||
raise HTTPException(status_code=500, detail="Managed files root is not a directory")
|
||||
return resolved
|
||||
|
||||
|
||||
def _path_is_under(root: Path, target: Path) -> bool:
|
||||
return target == root or root in target.parents
|
||||
|
||||
|
||||
def _path_text(raw_path: str | None) -> str:
|
||||
text = str(raw_path or "").strip()
|
||||
if "\x00" in text:
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
return text
|
||||
|
||||
|
||||
def _local_dashboard_request(request: Request) -> bool:
|
||||
if getattr(request.app.state, "auth_required", False):
|
||||
return False
|
||||
host = (request.url.hostname or "").lower()
|
||||
client_host = (request.client.host if request.client else "").lower()
|
||||
local_hosts = {"", "localhost", "127.0.0.1", "::1", "testserver", "testclient"}
|
||||
return host in local_hosts or client_host in local_hosts
|
||||
|
||||
|
||||
def _default_hermes_root_is_opt_data() -> bool:
|
||||
raw = os.environ.get("HERMES_HOME", "").strip()
|
||||
if not raw:
|
||||
return False
|
||||
try:
|
||||
from hermes_constants import get_default_hermes_root
|
||||
|
||||
root = get_default_hermes_root().expanduser().resolve(strict=False)
|
||||
except (OSError, RuntimeError):
|
||||
root = Path(raw).expanduser().resolve(strict=False)
|
||||
return root == _HOSTED_MANAGED_FILES_ROOT
|
||||
|
||||
|
||||
def _managed_files_policy(request: Request, *, create_root: bool = True) -> ManagedFilesPolicy:
|
||||
raw_forced_root = os.environ.get(_MANAGED_FILES_ROOT_ENV, "").strip()
|
||||
if raw_forced_root:
|
||||
root = _ensure_managed_root(raw_forced_root) if create_root else _canonical_path(Path(raw_forced_root))
|
||||
return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False)
|
||||
|
||||
if not _local_dashboard_request(request) or _default_hermes_root_is_opt_data():
|
||||
root = _ensure_managed_root(_HOSTED_MANAGED_FILES_ROOT) if create_root else _HOSTED_MANAGED_FILES_ROOT
|
||||
return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False)
|
||||
|
||||
home = _canonical_path(Path.home())
|
||||
return ManagedFilesPolicy(default_path=home, locked_root=None, can_change_path=True)
|
||||
|
||||
|
||||
def _resolve_managed_path(
|
||||
raw_path: str | None,
|
||||
request: Request,
|
||||
*,
|
||||
for_write: bool = False,
|
||||
) -> tuple[ManagedFilesPolicy, Path, str]:
|
||||
policy = _managed_files_policy(request)
|
||||
text = _path_text(raw_path)
|
||||
root = policy.locked_root
|
||||
|
||||
if root is not None and (not text or text in {".", "/"}):
|
||||
candidate = root
|
||||
elif not text:
|
||||
candidate = policy.default_path
|
||||
else:
|
||||
candidate = Path(text).expanduser()
|
||||
if root is not None and not candidate.is_absolute():
|
||||
if any(part == ".." for part in candidate.parts):
|
||||
raise HTTPException(status_code=400, detail="Path cannot contain '..'")
|
||||
candidate = root / candidate
|
||||
elif not candidate.is_absolute():
|
||||
raise HTTPException(status_code=400, detail="Path must be absolute")
|
||||
|
||||
if ".." in candidate.parts:
|
||||
raise HTTPException(status_code=400, detail="Path cannot contain '..'")
|
||||
|
||||
if for_write and not candidate.exists():
|
||||
parent = _canonical_path(candidate.parent)
|
||||
resolved = parent / candidate.name
|
||||
else:
|
||||
resolved = _canonical_path(candidate, require_exists=not for_write)
|
||||
|
||||
if root is not None and not _path_is_under(root, resolved):
|
||||
raise HTTPException(status_code=403, detail="Path outside managed files root")
|
||||
|
||||
return policy, resolved, str(resolved)
|
||||
|
||||
|
||||
def _managed_response_meta(policy: ManagedFilesPolicy) -> Dict[str, Any]:
|
||||
locked_root = str(policy.locked_root) if policy.locked_root is not None else None
|
||||
return {
|
||||
"root": locked_root,
|
||||
"locked_root": locked_root,
|
||||
"can_change_path": policy.can_change_path,
|
||||
}
|
||||
|
||||
|
||||
def _managed_file_entry(policy: ManagedFilesPolicy, target: Path) -> Dict[str, Any]:
|
||||
try:
|
||||
resolved = target.resolve()
|
||||
except (OSError, RuntimeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
if policy.locked_root is not None and not _path_is_under(policy.locked_root, resolved):
|
||||
raise HTTPException(status_code=403, detail="Path outside managed files root")
|
||||
|
||||
try:
|
||||
st = resolved.stat()
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not stat path: {exc}")
|
||||
|
||||
is_dir = resolved.is_dir()
|
||||
mime_type = None if is_dir else (mimetypes.guess_type(resolved.name)[0] or "application/octet-stream")
|
||||
return {
|
||||
"name": target.name or resolved.name or str(resolved),
|
||||
"path": str(resolved),
|
||||
"is_directory": is_dir,
|
||||
"size": None if is_dir else st.st_size,
|
||||
"mtime": st.st_mtime,
|
||||
"mime_type": mime_type,
|
||||
}
|
||||
|
||||
|
||||
def _decode_data_url(data_url: str) -> tuple[bytes, str]:
|
||||
text = (data_url or "").strip()
|
||||
if not text.startswith("data:") or "," not in text:
|
||||
raise HTTPException(status_code=400, detail="Upload payload must be a data URL")
|
||||
header, encoded = text.split(",", 1)
|
||||
mime_type = header[5:].split(";", 1)[0] or "application/octet-stream"
|
||||
if ";base64" not in header:
|
||||
raise HTTPException(status_code=400, detail="Upload payload must be base64 encoded")
|
||||
try:
|
||||
data = base64.b64decode(encoded, validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
raise HTTPException(status_code=400, detail="Upload payload is not valid base64")
|
||||
if len(data) > _MANAGED_FILE_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail="File is too large")
|
||||
return data, mime_type
|
||||
|
||||
|
||||
@app.get("/api/files")
|
||||
async def list_managed_files(request: Request, path: Optional[str] = None):
|
||||
policy, target, display_path = _resolve_managed_path(path, request)
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="Path not found")
|
||||
if not target.is_dir():
|
||||
raise HTTPException(status_code=400, detail="Path is not a directory")
|
||||
|
||||
try:
|
||||
entries = [_managed_file_entry(policy, child) for child in target.iterdir()]
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Directory is not readable")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not read directory: {exc}")
|
||||
|
||||
entries.sort(key=lambda item: (not item["is_directory"], str(item["name"]).lower()))
|
||||
locked_root = policy.locked_root
|
||||
parent = None
|
||||
if target.parent != target and (locked_root is None or target != locked_root):
|
||||
parent = str(target.parent)
|
||||
return {
|
||||
"path": display_path,
|
||||
"parent": parent,
|
||||
"entries": entries,
|
||||
**_managed_response_meta(policy),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/files/read")
|
||||
async def read_managed_file(request: Request, path: str):
|
||||
policy, target, display_path = _resolve_managed_path(path, request)
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
if not target.is_file():
|
||||
raise HTTPException(status_code=400, detail="Path is not a file")
|
||||
|
||||
try:
|
||||
size = target.stat().st_size
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}")
|
||||
if size > _MANAGED_FILE_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail="File is too large")
|
||||
|
||||
mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
|
||||
try:
|
||||
encoded = base64.b64encode(target.read_bytes()).decode("ascii")
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="File is not readable")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not read file: {exc}")
|
||||
|
||||
return {
|
||||
"name": target.name,
|
||||
"path": display_path,
|
||||
"size": size,
|
||||
"mime_type": mime_type,
|
||||
"data_url": f"data:{mime_type};base64,{encoded}",
|
||||
**_managed_response_meta(policy),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/files/upload")
|
||||
async def upload_managed_file(payload: ManagedFileUpload, request: Request):
|
||||
policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True)
|
||||
if target.exists() and target.is_dir():
|
||||
raise HTTPException(status_code=409, detail="A directory already exists at that path")
|
||||
if target.exists() and not payload.overwrite:
|
||||
raise HTTPException(status_code=409, detail="File already exists")
|
||||
|
||||
data, _mime_type = _decode_data_url(payload.data_url)
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(data)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="File is not writable")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not write file: {exc}")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"entry": _managed_file_entry(policy, target),
|
||||
"path": display_path,
|
||||
**_managed_response_meta(policy),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/files/mkdir")
|
||||
async def create_managed_directory(payload: ManagedDirectoryCreate, request: Request):
|
||||
policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True)
|
||||
if target.exists() and not target.is_dir():
|
||||
raise HTTPException(status_code=409, detail="A file already exists at that path")
|
||||
|
||||
try:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Directory is not writable")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not create directory: {exc}")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"entry": _managed_file_entry(policy, target),
|
||||
"path": display_path,
|
||||
**_managed_response_meta(policy),
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/api/files")
|
||||
async def delete_managed_file(payload: ManagedFileDelete, request: Request):
|
||||
policy, target, display_path = _resolve_managed_path(payload.path, request)
|
||||
if policy.locked_root is not None and target == policy.locked_root:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the managed files root")
|
||||
if target.parent == target:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the filesystem root")
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="Path not found")
|
||||
|
||||
try:
|
||||
if target.is_dir():
|
||||
if payload.recursive:
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.rmdir()
|
||||
else:
|
||||
target.unlink()
|
||||
except OSError as exc:
|
||||
status_code = 409 if target.is_dir() and not payload.recursive else 500
|
||||
raise HTTPException(status_code=status_code, detail=f"Could not delete path: {exc}")
|
||||
|
||||
return {"ok": True, "path": display_path, **_managed_response_meta(policy)}
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def get_status():
|
||||
current_ver, latest_ver = check_config_version()
|
||||
@@ -7422,6 +7740,21 @@ class ProfileCreate(BaseModel):
|
||||
clone_from: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
# Profile-builder additions — all optional, all applied best-effort AFTER
|
||||
# the profile directory exists, so a hiccup in any of them never 500s the
|
||||
# create (the user can fix it from the relevant dashboard page afterward).
|
||||
# MCP servers to write into the new profile's config.yaml.
|
||||
mcp_servers: List["MCPServerCreate"] = []
|
||||
# Built-in / optional skills to KEEP active. When this list is non-empty,
|
||||
# the builder uses "replace" semantics: the bundle is seeded, then every
|
||||
# seeded skill NOT in this list is added to the profile's disabled list.
|
||||
# Empty list = leave the seeded bundle untouched (legacy behaviour).
|
||||
keep_skills: List[str] = []
|
||||
# Skills-hub identifiers to install into the new profile. Installed async
|
||||
# via a subprocess scoped to the profile (`hermes -p <name> skills install`)
|
||||
# because skills_hub.SKILLS_DIR is import-time-bound and the HERMES_HOME
|
||||
# override can't redirect it. Returns spawned PIDs for the UI to poll.
|
||||
hub_skills: List[str] = []
|
||||
|
||||
|
||||
class ProfileRename(BaseModel):
|
||||
@@ -7567,6 +7900,94 @@ def _write_profile_model(profile_dir: Path, provider: str, model: str) -> None:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
|
||||
def _write_profile_mcp_servers(profile_dir: Path, servers: List["MCPServerCreate"]) -> int:
|
||||
"""Write MCP server entries into a specific profile's config.yaml.
|
||||
|
||||
Scopes ``load_config``/``save_config`` to ``profile_dir`` via the
|
||||
context-local HERMES_HOME override (same mechanism as
|
||||
``_write_profile_model``) so the entries land in the target profile's
|
||||
config rather than the dashboard process's active profile.
|
||||
|
||||
Mirrors the per-server shape the ``POST /api/mcp/servers`` endpoint builds,
|
||||
but batched so the whole profile-create write is a single config save.
|
||||
Returns the number of servers written.
|
||||
"""
|
||||
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
|
||||
|
||||
written = 0
|
||||
token = set_hermes_home_override(str(profile_dir))
|
||||
try:
|
||||
cfg = load_config()
|
||||
mcp = cfg.setdefault("mcp_servers", {})
|
||||
for server in servers:
|
||||
name = (server.name or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
entry: Dict[str, Any] = {}
|
||||
if server.url:
|
||||
entry["url"] = server.url
|
||||
if server.command:
|
||||
entry["command"] = server.command
|
||||
if server.args:
|
||||
entry["args"] = list(server.args)
|
||||
if server.env:
|
||||
entry["env"] = dict(server.env)
|
||||
if server.auth:
|
||||
entry["auth"] = server.auth
|
||||
if not entry:
|
||||
# Nothing usable to write (neither url nor command) — skip
|
||||
# rather than persist an empty, unusable server stanza.
|
||||
continue
|
||||
mcp[name] = entry
|
||||
written += 1
|
||||
if written:
|
||||
save_config(cfg)
|
||||
elif not mcp:
|
||||
# We created an empty mcp_servers dict but wrote nothing — don't
|
||||
# leave a stray empty key in the new profile's config.
|
||||
cfg.pop("mcp_servers", None)
|
||||
save_config(cfg)
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
return written
|
||||
|
||||
|
||||
def _disable_unselected_skills(profile_dir: Path, keep: List[str]) -> int:
|
||||
"""Disable every installed skill in ``profile_dir`` not in ``keep``.
|
||||
|
||||
Profiles manage skill activation via a *disabled* list — all installed
|
||||
skills are active by default and users opt out. The builder's skill step
|
||||
uses "replace" semantics: the user picks exactly which seeded built-in /
|
||||
optional skills stay active, and everything else gets added to the disabled
|
||||
list. (Hub skills are installed separately via subprocess and are active on
|
||||
install.) Scoped to the profile via the HERMES_HOME override. Returns the
|
||||
number of skills newly disabled.
|
||||
"""
|
||||
from hermes_constants import set_hermes_home_override, reset_hermes_home_override
|
||||
from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
|
||||
|
||||
keep_set = {s.strip() for s in keep if s and s.strip()}
|
||||
disabled_count = 0
|
||||
token = set_hermes_home_override(str(profile_dir))
|
||||
try:
|
||||
installed: List[str] = []
|
||||
skills_root = profile_dir / "skills"
|
||||
if skills_root.is_dir():
|
||||
for md in skills_root.rglob("SKILL.md"):
|
||||
installed.append(md.parent.name)
|
||||
cfg = load_config()
|
||||
disabled = get_disabled_skills(cfg)
|
||||
for name in installed:
|
||||
if name not in keep_set and name not in disabled:
|
||||
disabled.add(name)
|
||||
disabled_count += 1
|
||||
if disabled_count:
|
||||
save_disabled_skills(cfg, disabled)
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
return disabled_count
|
||||
|
||||
|
||||
@app.get("/api/profiles")
|
||||
async def list_profiles_endpoint():
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
@@ -7633,7 +8054,55 @@ async def create_profile_endpoint(body: ProfileCreate):
|
||||
except Exception:
|
||||
_log.exception("Setting model for new profile %s failed", body.name)
|
||||
|
||||
return {"ok": True, "name": body.name, "path": str(path), "model_set": model_set}
|
||||
# Optional MCP servers. Best-effort, same rationale as model assignment.
|
||||
mcp_written = 0
|
||||
if body.mcp_servers:
|
||||
try:
|
||||
mcp_written = _write_profile_mcp_servers(path, body.mcp_servers)
|
||||
except Exception:
|
||||
_log.exception("Writing MCP servers for new profile %s failed", body.name)
|
||||
|
||||
# Optional "keep" skill selection — replace semantics. When the builder
|
||||
# sends an explicit keep list, disable every seeded skill not in it.
|
||||
# Best-effort. Skipped when keep_skills is empty (legacy: keep the bundle).
|
||||
skills_disabled = 0
|
||||
if body.keep_skills:
|
||||
try:
|
||||
skills_disabled = _disable_unselected_skills(path, body.keep_skills)
|
||||
except Exception:
|
||||
_log.exception("Applying skill selection for new profile %s failed", body.name)
|
||||
|
||||
# Optional skills-hub installs. Spawned async, scoped to the new profile
|
||||
# via `-p <name>` (a fresh subprocess re-binds skills_hub.SKILLS_DIR to the
|
||||
# profile's HERMES_HOME at import). Returns PIDs for the UI to poll.
|
||||
hub_installs: List[Dict[str, Any]] = []
|
||||
for identifier in body.hub_skills:
|
||||
ident = (identifier or "").strip()
|
||||
if not ident:
|
||||
continue
|
||||
try:
|
||||
proc = _spawn_hermes_action(
|
||||
["-p", body.name, "skills", "install", ident],
|
||||
"skills-install",
|
||||
)
|
||||
hub_installs.append({"identifier": ident, "pid": proc.pid})
|
||||
except Exception:
|
||||
_log.exception(
|
||||
"Spawning hub-skill install %s for new profile %s failed",
|
||||
ident,
|
||||
body.name,
|
||||
)
|
||||
hub_installs.append({"identifier": ident, "pid": None})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"name": body.name,
|
||||
"path": str(path),
|
||||
"model_set": model_set,
|
||||
"mcp_written": mcp_written,
|
||||
"skills_disabled": skills_disabled,
|
||||
"hub_installs": hub_installs,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/profiles/active")
|
||||
|
||||
@@ -395,6 +395,38 @@ def _run_one_file(
|
||||
timeout_note=f"per-file timeout on exit-4 retry {attempt}",
|
||||
)
|
||||
|
||||
if rc == 4:
|
||||
# Exit-4 survived the retries (or the file was judged absent).
|
||||
# Capture filesystem forensics so a CI-only "file not found" can
|
||||
# be diagnosed from the log instead of guessed at: does the file
|
||||
# exist NOW, what does the parent dir hold, and is the git tree
|
||||
# clean? (June 2026: a PR-added test file repeatedly hit exit 4
|
||||
# on one CI shard while passing locally — these lines exist so
|
||||
# the next occurrence is attributable.)
|
||||
forensics = [f"--- exit-4 forensics for {file} ---"]
|
||||
try:
|
||||
forensics.append(f"exists={file.exists()} retries_used={attempt}")
|
||||
parent = file.parent
|
||||
if parent.exists():
|
||||
names = sorted(p.name for p in parent.iterdir())
|
||||
sibling_hint = [n for n in names if file.stem[:12] in n]
|
||||
forensics.append(
|
||||
f"parent={parent} entries={len(names)} "
|
||||
f"similar={sibling_hint[:5]}"
|
||||
)
|
||||
else:
|
||||
forensics.append(f"parent={parent} MISSING")
|
||||
git_st = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
cwd=repo_root, capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
dirty = git_st.stdout.strip().splitlines()
|
||||
forensics.append(f"git_dirty_entries={len(dirty)}")
|
||||
forensics.extend(f" {line}" for line in dirty[:10])
|
||||
except Exception as exc: # noqa: BLE001 — forensics must never mask rc=4
|
||||
forensics.append(f"(forensics error: {exc})")
|
||||
output = output + "\n" + "\n".join(forensics)
|
||||
|
||||
if rc == 5:
|
||||
# No tests collected — every test in the file was filtered out.
|
||||
# Treat as a pass; surface info in a slightly distinct status
|
||||
|
||||
@@ -2425,6 +2425,83 @@ class TestNewEndpoints:
|
||||
profiles = {p["name"]: p for p in self.client.get("/api/profiles").json()["profiles"]}
|
||||
assert profiles["fresh"]["skill_count"] == 1
|
||||
|
||||
def test_profiles_create_builder_fields_model_mcp_and_keep_skills(self, monkeypatch):
|
||||
"""Profile-builder create: model + MCP servers + keep-skills selection
|
||||
all land in the NEW profile's config, and hub installs are spawned
|
||||
scoped to that profile via ``-p <name>``."""
|
||||
from hermes_constants import (
|
||||
get_hermes_home,
|
||||
set_hermes_home_override,
|
||||
reset_hermes_home_override,
|
||||
)
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.skills_config import get_disabled_skills
|
||||
import hermes_cli.profiles as profiles_mod
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None)
|
||||
|
||||
# Seed two known skills so keep-skills "replace" has something to act on.
|
||||
def fake_seed(profile_dir, quiet=False):
|
||||
for skill in ("keep-me", "drop-me"):
|
||||
d = profile_dir / "skills" / "custom" / skill
|
||||
d.mkdir(parents=True)
|
||||
(d / "SKILL.md").write_text(f"---\nname: {skill}\n---\n", encoding="utf-8")
|
||||
return {"copied": ["keep-me", "drop-me"]}
|
||||
|
||||
monkeypatch.setattr(profiles_mod, "seed_profile_skills", fake_seed)
|
||||
|
||||
# Capture hub-install spawns instead of launching real subprocesses.
|
||||
spawned = []
|
||||
|
||||
class _FakeProc:
|
||||
pid = 4321
|
||||
|
||||
def fake_spawn(subcommand, name):
|
||||
spawned.append((list(subcommand), name))
|
||||
return _FakeProc()
|
||||
|
||||
monkeypatch.setattr(web_server, "_spawn_hermes_action", fake_spawn)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/profiles",
|
||||
json={
|
||||
"name": "builder",
|
||||
"provider": "openrouter",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"mcp_servers": [
|
||||
{"name": "ctx7", "url": "https://mcp.context7.com/mcp"},
|
||||
{"name": "bogus"}, # no url/command -> must be skipped, no 500
|
||||
],
|
||||
"keep_skills": ["keep-me"],
|
||||
"hub_skills": ["someuser/some-skill"],
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["model_set"] is True
|
||||
assert data["mcp_written"] == 1 # bogus skipped
|
||||
assert data["skills_disabled"] == 1 # drop-me disabled, keep-me kept
|
||||
assert data["hub_installs"] == [{"identifier": "someuser/some-skill", "pid": 4321}]
|
||||
|
||||
# Hub install was scoped to the new profile.
|
||||
assert spawned == [(["-p", "builder", "skills", "install", "someuser/some-skill"], "skills-install")]
|
||||
|
||||
# Verify the writes landed in the NEW profile's config, not the root.
|
||||
prof_dir = get_hermes_home() / "profiles" / "builder"
|
||||
token = set_hermes_home_override(str(prof_dir))
|
||||
try:
|
||||
cfg = load_config()
|
||||
assert cfg["model"]["default"] == "anthropic/claude-sonnet-4.6"
|
||||
assert cfg["model"]["provider"] == "openrouter"
|
||||
assert sorted((cfg.get("mcp_servers") or {}).keys()) == ["ctx7"]
|
||||
disabled = get_disabled_skills(cfg)
|
||||
assert "drop-me" in disabled
|
||||
assert "keep-me" not in disabled
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
|
||||
def test_profile_open_terminal_uses_macos_terminal(self, monkeypatch):
|
||||
from hermes_constants import get_hermes_home
|
||||
import hermes_cli.web_server as web_server
|
||||
|
||||
246
tests/hermes_cli/test_web_server_files.py
Normal file
246
tests/hermes_cli/test_web_server_files.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Tests for the dashboard-managed file browser API."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from hermes_cli import web_server
|
||||
|
||||
|
||||
def _client_with_app_state():
|
||||
prev_auth_required = getattr(web_server.app.state, "auth_required", None)
|
||||
prev_bound_host = getattr(web_server.app.state, "bound_host", None)
|
||||
web_server.app.state.auth_required = False
|
||||
web_server.app.state.bound_host = None
|
||||
|
||||
client = TestClient(web_server.app)
|
||||
client.headers[web_server._SESSION_HEADER_NAME] = web_server._SESSION_TOKEN
|
||||
return client, prev_auth_required, prev_bound_host
|
||||
|
||||
|
||||
def _restore_app_state(prev_auth_required, prev_bound_host):
|
||||
if prev_auth_required is None:
|
||||
delattr(web_server.app.state, "auth_required")
|
||||
else:
|
||||
web_server.app.state.auth_required = prev_auth_required
|
||||
if prev_bound_host is None:
|
||||
if hasattr(web_server.app.state, "bound_host"):
|
||||
delattr(web_server.app.state, "bound_host")
|
||||
else:
|
||||
web_server.app.state.bound_host = prev_bound_host
|
||||
|
||||
|
||||
def _close_client(client):
|
||||
close = getattr(client, "close", None)
|
||||
if close is not None:
|
||||
close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def forced_files_client(monkeypatch, tmp_path):
|
||||
root = tmp_path / "data"
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_FILES_ROOT", str(root))
|
||||
|
||||
client, prev_auth_required, prev_bound_host = _client_with_app_state()
|
||||
try:
|
||||
yield client, root
|
||||
finally:
|
||||
_close_client(client)
|
||||
_restore_app_state(prev_auth_required, prev_bound_host)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_files_client(monkeypatch, tmp_path):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False)
|
||||
monkeypatch.delenv("HERMES_HOME", raising=False)
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
|
||||
client, prev_auth_required, prev_bound_host = _client_with_app_state()
|
||||
try:
|
||||
yield client, home
|
||||
finally:
|
||||
_close_client(client)
|
||||
_restore_app_state(prev_auth_required, prev_bound_host)
|
||||
|
||||
|
||||
def test_forced_root_file_upload_list_read_delete_roundtrip(forced_files_client):
|
||||
client, root = forced_files_client
|
||||
file_path = root / "out" / "hello.txt"
|
||||
|
||||
created = client.post(
|
||||
"/api/files/upload",
|
||||
json={
|
||||
"path": str(file_path),
|
||||
"data_url": "data:text/plain;base64,aGVsbG8=",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
assert created.json()["entry"]["path"] == str(file_path)
|
||||
assert created.json()["locked_root"] == str(root)
|
||||
assert created.json()["can_change_path"] is False
|
||||
assert file_path.read_text() == "hello"
|
||||
|
||||
listing = client.get("/api/files", params={"path": str(root / "out")})
|
||||
assert listing.status_code == 200
|
||||
assert listing.json()["path"] == str(root / "out")
|
||||
assert listing.json()["parent"] == str(root)
|
||||
assert listing.json()["entries"] == [
|
||||
{
|
||||
"name": "hello.txt",
|
||||
"path": str(file_path),
|
||||
"is_directory": False,
|
||||
"size": 5,
|
||||
"mtime": pytest.approx(file_path.stat().st_mtime),
|
||||
"mime_type": "text/plain",
|
||||
}
|
||||
]
|
||||
|
||||
read = client.get("/api/files/read", params={"path": str(file_path)})
|
||||
assert read.status_code == 200
|
||||
assert read.json()["data_url"] == "data:text/plain;base64,aGVsbG8="
|
||||
|
||||
deleted = client.request(
|
||||
"DELETE",
|
||||
"/api/files",
|
||||
json={"path": str(file_path)},
|
||||
)
|
||||
assert deleted.status_code == 200
|
||||
assert not file_path.exists()
|
||||
|
||||
|
||||
def test_directory_management_requires_recursive_delete_for_nonempty_dirs(forced_files_client):
|
||||
client, root = forced_files_client
|
||||
runs_path = root / "runs"
|
||||
checkpoints_path = runs_path / "checkpoints"
|
||||
|
||||
created = client.post("/api/files/mkdir", json={"path": str(checkpoints_path)})
|
||||
assert created.status_code == 200
|
||||
assert checkpoints_path.is_dir()
|
||||
|
||||
listing = client.get("/api/files", params={"path": str(runs_path)})
|
||||
assert listing.status_code == 200
|
||||
assert listing.json()["entries"][0]["path"] == str(checkpoints_path)
|
||||
assert listing.json()["entries"][0]["is_directory"] is True
|
||||
|
||||
non_recursive = client.request(
|
||||
"DELETE",
|
||||
"/api/files",
|
||||
json={"path": str(runs_path), "recursive": False},
|
||||
)
|
||||
assert non_recursive.status_code == 409
|
||||
|
||||
recursive = client.request(
|
||||
"DELETE",
|
||||
"/api/files",
|
||||
json={"path": str(runs_path), "recursive": True},
|
||||
)
|
||||
assert recursive.status_code == 200
|
||||
assert not runs_path.exists()
|
||||
|
||||
|
||||
def test_forced_root_paths_stay_under_root(forced_files_client, tmp_path):
|
||||
client, root = forced_files_client
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret.txt").write_text("do not leak")
|
||||
|
||||
traversal = client.get("/api/files", params={"path": "../outside"})
|
||||
assert traversal.status_code == 400
|
||||
|
||||
outside_absolute = client.get("/api/files", params={"path": str(outside)})
|
||||
assert outside_absolute.status_code == 403
|
||||
|
||||
root_delete = client.request(
|
||||
"DELETE",
|
||||
"/api/files",
|
||||
json={"path": str(root), "recursive": True},
|
||||
)
|
||||
assert root_delete.status_code == 400
|
||||
|
||||
root.mkdir(exist_ok=True)
|
||||
link = root / "escape"
|
||||
try:
|
||||
link.symlink_to(outside, target_is_directory=True)
|
||||
except OSError:
|
||||
pytest.skip("filesystem does not allow directory symlinks")
|
||||
|
||||
escaped = client.get("/api/files", params={"path": str(link)})
|
||||
assert escaped.status_code == 403
|
||||
|
||||
|
||||
def test_local_mode_defaults_to_home_and_can_jump_to_absolute_path(local_files_client, tmp_path):
|
||||
client, home = local_files_client
|
||||
(home / "home.txt").write_text("home")
|
||||
|
||||
default_listing = client.get("/api/files")
|
||||
assert default_listing.status_code == 200
|
||||
assert default_listing.json()["path"] == str(home)
|
||||
assert default_listing.json()["locked_root"] is None
|
||||
assert default_listing.json()["can_change_path"] is True
|
||||
assert default_listing.json()["entries"][0]["path"] == str(home / "home.txt")
|
||||
|
||||
other = tmp_path / "other"
|
||||
other.mkdir()
|
||||
(other / "other.txt").write_text("other")
|
||||
|
||||
other_listing = client.get("/api/files", params={"path": str(other)})
|
||||
assert other_listing.status_code == 200
|
||||
assert other_listing.json()["path"] == str(other)
|
||||
assert other_listing.json()["parent"] == str(tmp_path)
|
||||
assert other_listing.json()["entries"][0]["path"] == str(other / "other.txt")
|
||||
|
||||
|
||||
def test_local_mode_upload_read_mkdir_delete_roundtrip(local_files_client):
|
||||
client, home = local_files_client
|
||||
folder = home / "workspace"
|
||||
file_path = folder / "note.txt"
|
||||
|
||||
created_folder = client.post("/api/files/mkdir", json={"path": str(folder)})
|
||||
assert created_folder.status_code == 200
|
||||
assert created_folder.json()["locked_root"] is None
|
||||
assert created_folder.json()["can_change_path"] is True
|
||||
assert folder.is_dir()
|
||||
|
||||
uploaded = client.post(
|
||||
"/api/files/upload",
|
||||
json={
|
||||
"path": str(file_path),
|
||||
"data_url": "data:text/plain;base64,bG9jYWw=",
|
||||
},
|
||||
)
|
||||
assert uploaded.status_code == 200
|
||||
assert file_path.read_text() == "local"
|
||||
|
||||
read = client.get("/api/files/read", params={"path": str(file_path)})
|
||||
assert read.status_code == 200
|
||||
assert read.json()["data_url"] == "data:text/plain;base64,bG9jYWw="
|
||||
|
||||
deleted = client.request(
|
||||
"DELETE",
|
||||
"/api/files",
|
||||
json={"path": str(folder), "recursive": True},
|
||||
)
|
||||
assert deleted.status_code == 200
|
||||
assert not folder.exists()
|
||||
|
||||
|
||||
def test_hosted_policy_locks_to_opt_data(monkeypatch):
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False)
|
||||
monkeypatch.setenv("HERMES_HOME", "/opt/data")
|
||||
client, prev_auth_required, prev_bound_host = _client_with_app_state()
|
||||
try:
|
||||
request = SimpleNamespace(
|
||||
app=web_server.app,
|
||||
client=SimpleNamespace(host="127.0.0.1"),
|
||||
url=SimpleNamespace(hostname="127.0.0.1"),
|
||||
)
|
||||
policy = web_server._managed_files_policy(request, create_root=False)
|
||||
finally:
|
||||
_restore_app_state(prev_auth_required, prev_bound_host)
|
||||
client.close()
|
||||
|
||||
assert str(policy.locked_root) == "/opt/data"
|
||||
assert policy.can_change_path is False
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Database,
|
||||
Download,
|
||||
Eye,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
Globe,
|
||||
Heart,
|
||||
@@ -68,12 +69,14 @@ import type { SystemAction } from "@/contexts/system-actions-context";
|
||||
import ConfigPage from "@/pages/ConfigPage";
|
||||
import DocsPage from "@/pages/DocsPage";
|
||||
import EnvPage from "@/pages/EnvPage";
|
||||
import FilesPage from "@/pages/FilesPage";
|
||||
import SessionsPage from "@/pages/SessionsPage";
|
||||
import LogsPage from "@/pages/LogsPage";
|
||||
import AnalyticsPage from "@/pages/AnalyticsPage";
|
||||
import ModelsPage from "@/pages/ModelsPage";
|
||||
import CronPage from "@/pages/CronPage";
|
||||
import ProfilesPage from "@/pages/ProfilesPage";
|
||||
import ProfileBuilderPage from "@/pages/ProfileBuilderPage";
|
||||
import SkillsPage from "@/pages/SkillsPage";
|
||||
import PluginsPage from "@/pages/PluginsPage";
|
||||
import McpPage from "@/pages/McpPage";
|
||||
@@ -124,6 +127,7 @@ const CHAT_NAV_ITEM: NavItem = {
|
||||
const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
||||
"/": RootRedirect,
|
||||
"/sessions": SessionsPage,
|
||||
"/files": FilesPage,
|
||||
"/analytics": AnalyticsPage,
|
||||
"/models": ModelsPage,
|
||||
"/logs": LogsPage,
|
||||
@@ -136,6 +140,7 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
||||
"/webhooks": WebhooksPage,
|
||||
"/system": SystemPage,
|
||||
"/profiles": ProfilesPage,
|
||||
"/profiles/new": ProfileBuilderPage,
|
||||
"/config": ConfigPage,
|
||||
"/env": EnvPage,
|
||||
"/docs": DocsPage,
|
||||
@@ -156,6 +161,7 @@ const BUILTIN_NAV_REST: NavItem[] = [
|
||||
label: "Sessions",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{ path: "/files", label: "Files", icon: FolderOpen },
|
||||
{
|
||||
path: "/analytics",
|
||||
labelKey: "analytics",
|
||||
@@ -194,6 +200,7 @@ const ICON_MAP: Record<string, ComponentType<{ className?: string }>> = {
|
||||
Clock,
|
||||
Cpu,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
KeyRound,
|
||||
MessageSquare,
|
||||
Package,
|
||||
|
||||
@@ -325,6 +325,32 @@ export const api = {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ older_than_days, source }),
|
||||
}),
|
||||
listFiles: (path?: string) => {
|
||||
const query = path ? `?path=${encodeURIComponent(path)}` : "";
|
||||
return fetchJSON<ManagedFilesResponse>(`/api/files${query}`);
|
||||
},
|
||||
readFile: (path: string) =>
|
||||
fetchJSON<ManagedFileReadResponse>(
|
||||
`/api/files/read?path=${encodeURIComponent(path)}`,
|
||||
),
|
||||
uploadFile: (path: string, dataUrl: string, overwrite = true) =>
|
||||
fetchJSON<ManagedFileWriteResponse>("/api/files/upload", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, data_url: dataUrl, overwrite }),
|
||||
}),
|
||||
createDirectory: (path: string) =>
|
||||
fetchJSON<ManagedFileWriteResponse>("/api/files/mkdir", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path }),
|
||||
}),
|
||||
deleteFile: (path: string, recursive = false) =>
|
||||
fetchJSON<{ ok: boolean; path: string }>("/api/files", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, recursive }),
|
||||
}),
|
||||
getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.file) qs.set("file", params.file);
|
||||
@@ -439,8 +465,19 @@ export const api = {
|
||||
description?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
mcp_servers?: McpServerCreate[];
|
||||
keep_skills?: string[];
|
||||
hub_skills?: string[];
|
||||
}) =>
|
||||
fetchJSON<{ ok: boolean; name: string; path: string; model_set?: boolean }>("/api/profiles", {
|
||||
fetchJSON<{
|
||||
ok: boolean;
|
||||
name: string;
|
||||
path: string;
|
||||
model_set?: boolean;
|
||||
mcp_written?: number;
|
||||
skills_disabled?: number;
|
||||
hub_installs?: Array<{ identifier: string; pid: number | null }>;
|
||||
}>("/api/profiles", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
@@ -1504,6 +1541,44 @@ export interface LogsResponse {
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
export interface ManagedFileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_directory: boolean;
|
||||
size: number | null;
|
||||
mtime: number;
|
||||
mime_type: string | null;
|
||||
}
|
||||
|
||||
export interface ManagedFilesResponse {
|
||||
root: string | null;
|
||||
path: string;
|
||||
parent: string | null;
|
||||
locked_root: string | null;
|
||||
can_change_path: boolean;
|
||||
entries: ManagedFileEntry[];
|
||||
}
|
||||
|
||||
export interface ManagedFileReadResponse {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
mime_type: string;
|
||||
data_url: string;
|
||||
root: string | null;
|
||||
locked_root: string | null;
|
||||
can_change_path: boolean;
|
||||
}
|
||||
|
||||
export interface ManagedFileWriteResponse {
|
||||
ok: boolean;
|
||||
path: string;
|
||||
entry: ManagedFileEntry;
|
||||
root: string | null;
|
||||
locked_root: string | null;
|
||||
can_change_path: boolean;
|
||||
}
|
||||
|
||||
export interface AnalyticsDailyEntry {
|
||||
day: string;
|
||||
input_tokens: number;
|
||||
|
||||
538
web/src/pages/FilesPage.tsx
Normal file
538
web/src/pages/FilesPage.tsx
Normal file
@@ -0,0 +1,538 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type DragEvent as ReactDragEvent,
|
||||
} from "react";
|
||||
import {
|
||||
ArrowUp,
|
||||
Download,
|
||||
FileIcon,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@nous-research/ui/ui/components/dialog";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
|
||||
import { usePageHeader } from "@/contexts/usePageHeader";
|
||||
import { api } from "@/lib/api";
|
||||
import type { ManagedFileEntry, ManagedFilesResponse } from "@/lib/api";
|
||||
import { PluginSlot } from "@/plugins";
|
||||
|
||||
const DATE_FORMAT = new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
|
||||
function joinPath(base: string, name: string): string {
|
||||
const cleanName = name.trim().replace(/^[\\/]+/, "");
|
||||
if (!cleanName) return base;
|
||||
const separator = base.includes("\\") && !base.includes("/") ? "\\" : "/";
|
||||
if (!base || base.endsWith("/") || base.endsWith("\\")) return `${base}${cleanName}`;
|
||||
return `${base}${separator}${cleanName}`;
|
||||
}
|
||||
|
||||
function formatBytes(size: number | null): string {
|
||||
if (size === null) return "-";
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
if (size < 1024 * 1024 * 1024) return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function readAsDataUrl(file: globalThis.File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener("load", () => {
|
||||
if (typeof reader.result === "string") resolve(reader.result);
|
||||
else reject(new Error("Could not read file"));
|
||||
});
|
||||
reader.addEventListener("error", () => reject(reader.error ?? new Error("Could not read file")));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function downloadDataUrl(dataUrl: string, name: string) {
|
||||
const link = document.createElement("a");
|
||||
link.href = dataUrl;
|
||||
link.download = name || "download";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
|
||||
function displayPath(path: string | null | undefined): string {
|
||||
return path?.trim() || "Files";
|
||||
}
|
||||
|
||||
function transferHasFiles(event: ReactDragEvent<HTMLElement>): boolean {
|
||||
return Array.from(event.dataTransfer.types).includes("Files");
|
||||
}
|
||||
|
||||
export default function FilesPage() {
|
||||
const { toast, showToast } = useToast();
|
||||
const { setAfterTitle, setEnd } = usePageHeader();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const dragDepthRef = useRef(0);
|
||||
const [currentPath, setCurrentPath] = useState<string | undefined>(undefined);
|
||||
const [pathInput, setPathInput] = useState("");
|
||||
const [listing, setListing] = useState<ManagedFilesResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [draggingFiles, setDraggingFiles] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [folderName, setFolderName] = useState("");
|
||||
const [pendingDelete, setPendingDelete] = useState<ManagedFileEntry | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const activePath = listing?.path ?? currentPath ?? "";
|
||||
const canChangePath = listing?.can_change_path ?? false;
|
||||
const canUpload = Boolean(activePath) && !uploading;
|
||||
const headerPath = displayPath(listing?.locked_root ?? listing?.path ?? currentPath);
|
||||
|
||||
const load = useCallback(
|
||||
async (path = currentPath) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await api.listFiles(path);
|
||||
setListing(result);
|
||||
setCurrentPath(result.path);
|
||||
setPathInput(result.path);
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[currentPath],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Existing dashboard data pages fetch from effects; keep this local and explicit
|
||||
// until the shared lint profile is updated for async page loaders.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void load(currentPath);
|
||||
}, [currentPath]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
setAfterTitle(
|
||||
<Badge tone="outline" className="max-w-[22rem] truncate text-xs" title={headerPath}>
|
||||
{headerPath}
|
||||
</Badge>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => void load()}
|
||||
disabled={loading}
|
||||
aria-label="Refresh files"
|
||||
>
|
||||
{loading ? <Spinner /> : <RefreshCw />}
|
||||
</Button>
|
||||
</div>,
|
||||
);
|
||||
return () => {
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
};
|
||||
}, [headerPath, load, loading, setAfterTitle, setEnd]);
|
||||
|
||||
const openDirectory = (entry: ManagedFileEntry) => {
|
||||
if (entry.is_directory) {
|
||||
setCurrentPath(entry.path);
|
||||
}
|
||||
};
|
||||
|
||||
const goToPath = async () => {
|
||||
const nextPath = pathInput.trim();
|
||||
if (!nextPath) {
|
||||
showToast("Path required", "error");
|
||||
return;
|
||||
}
|
||||
await load(nextPath);
|
||||
};
|
||||
|
||||
const createDirectory = async () => {
|
||||
const name = folderName.trim();
|
||||
if (!activePath) {
|
||||
showToast("Directory unavailable", "error");
|
||||
return;
|
||||
}
|
||||
if (!name) {
|
||||
showToast("Folder name required", "error");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.createDirectory(joinPath(activePath, name));
|
||||
setFolderName("");
|
||||
setCreateDialogOpen(false);
|
||||
showToast("Folder created", "success");
|
||||
await load();
|
||||
} catch (e) {
|
||||
showToast(`Create failed: ${e}`, "error");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFiles = async (files: FileList | null) => {
|
||||
if (!files?.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const dataUrl = await readAsDataUrl(file);
|
||||
await api.uploadFile(joinPath(activePath, file.name), dataUrl, true);
|
||||
}
|
||||
showToast(`${files.length} file${files.length === 1 ? "" : "s"} uploaded`, "success");
|
||||
await load();
|
||||
} catch (e) {
|
||||
showToast(`Upload failed: ${e}`, "error");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!canUpload || !transferHasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
setDraggingFiles(true);
|
||||
};
|
||||
|
||||
const handleDragOver = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!canUpload || !transferHasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!canUpload || !transferHasFiles(event)) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0) {
|
||||
setDraggingFiles(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!canUpload) return;
|
||||
event.preventDefault();
|
||||
dragDepthRef.current = 0;
|
||||
setDraggingFiles(false);
|
||||
void uploadFiles(event.dataTransfer.files);
|
||||
};
|
||||
|
||||
const downloadFile = async (entry: ManagedFileEntry) => {
|
||||
if (entry.is_directory) return;
|
||||
try {
|
||||
const file = await api.readFile(entry.path);
|
||||
downloadDataUrl(file.data_url, file.name);
|
||||
} catch (e) {
|
||||
showToast(`Download failed: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!pendingDelete) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteFile(pendingDelete.path, pendingDelete.is_directory);
|
||||
showToast("Deleted", "success");
|
||||
setPendingDelete(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
showToast(`Delete failed: ${e}`, "error");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 max-w-full flex-col gap-4">
|
||||
<Toast toast={toast} />
|
||||
<PluginSlot name="files:top" />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => void uploadFiles(event.currentTarget.files)}
|
||||
/>
|
||||
|
||||
<div className="flex min-w-0 flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
{canChangePath ? (
|
||||
<form
|
||||
className="flex min-w-0 flex-1 items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void goToPath();
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={pathInput}
|
||||
onChange={(event) => setPathInput(event.target.value)}
|
||||
aria-label="Path"
|
||||
placeholder="Path"
|
||||
className="h-9 min-w-0 flex-1 font-mono"
|
||||
/>
|
||||
<Button type="submit" size="sm" outlined className="uppercase">
|
||||
Go
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="min-w-0 truncate font-mono text-sm text-text-secondary" title={activePath}>
|
||||
{activePath}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!canUpload}
|
||||
size="sm"
|
||||
outlined
|
||||
className="uppercase"
|
||||
prefix={uploading ? <Spinner /> : <Upload />}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
disabled={!activePath}
|
||||
size="sm"
|
||||
outlined
|
||||
className="uppercase"
|
||||
prefix={<FolderPlus />}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canUpload && fileInputRef.current?.click()}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
disabled={!canUpload}
|
||||
aria-label="Upload files"
|
||||
className={`flex min-h-20 w-full min-w-0 items-center justify-between gap-4 border border-dashed px-4 py-3 text-left transition ${
|
||||
draggingFiles
|
||||
? "border-primary bg-primary/10 text-foreground"
|
||||
: "border-border bg-background/20 text-text-secondary hover:border-text-tertiary hover:bg-background/35"
|
||||
} disabled:cursor-not-allowed disabled:opacity-60`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center border border-border bg-background/45 text-text-tertiary">
|
||||
{uploading ? <Spinner /> : <Upload className="h-4 w-4" />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold uppercase tracking-[0.08em] text-foreground">
|
||||
{uploading ? "Uploading" : draggingFiles ? "Release to upload" : "Drop files here"}
|
||||
</span>
|
||||
<span className="block truncate font-mono text-xs text-text-secondary" title={activePath}>
|
||||
{activePath || "Loading"}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span className="hidden shrink-0 text-xs font-semibold uppercase tracking-[0.08em] text-text-tertiary sm:block">
|
||||
Choose files
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||
<CardContent className="overflow-x-auto p-0">
|
||||
{error && (
|
||||
<div className="border-b border-destructive/20 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border px-4 py-2 text-xs font-semibold uppercase tracking-[0.08em] text-text-tertiary">
|
||||
<span>Name</span>
|
||||
<span>Size</span>
|
||||
<span>Modified</span>
|
||||
<span className="text-right">Actions</span>
|
||||
</div>
|
||||
|
||||
{listing?.parent && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPath(listing.parent ?? undefined)}
|
||||
className="grid w-full min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border/60 px-4 py-2 text-left text-sm transition hover:bg-background/40"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2 font-mono text-text-secondary">
|
||||
<ArrowUp className="h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
..
|
||||
</span>
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{loading && !listing ? (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-sm text-muted-foreground">
|
||||
<Spinner />
|
||||
Loading files...
|
||||
</div>
|
||||
) : listing && listing.entries.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">No files</div>
|
||||
) : (
|
||||
listing?.entries.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className="grid min-w-[42rem] grid-cols-[minmax(12rem,1fr)_7rem_10rem_5.5rem] items-center gap-3 border-b border-border/60 px-4 py-2 text-sm last:border-b-0 hover:bg-background/35"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => (entry.is_directory ? openDirectory(entry) : void downloadFile(entry))}
|
||||
className="flex min-w-0 items-center gap-2 text-left font-mono text-foreground"
|
||||
>
|
||||
{entry.is_directory ? (
|
||||
<Folder className="h-4 w-4 shrink-0 text-warning" />
|
||||
) : (
|
||||
<FileIcon className="h-4 w-4 shrink-0 text-text-tertiary" />
|
||||
)}
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
<span className="text-xs tabular-nums text-text-secondary">{formatBytes(entry.size)}</span>
|
||||
<span className="truncate text-xs text-text-secondary">
|
||||
{Number.isFinite(entry.mtime) ? DATE_FORMAT.format(entry.mtime * 1000) : "-"}
|
||||
</span>
|
||||
<span className="flex justify-end gap-1">
|
||||
{entry.is_directory ? (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => openDirectory(entry)}
|
||||
aria-label={`Open ${entry.name}`}
|
||||
>
|
||||
<FolderOpen />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => void downloadFile(entry)}
|
||||
aria-label={`Download ${entry.name}`}
|
||||
>
|
||||
<Download />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => setPendingDelete(entry)}
|
||||
aria-label={`Delete ${entry.name}`}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<PluginSlot name="files:bottom" />
|
||||
|
||||
<Dialog
|
||||
open={createDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (creating) return;
|
||||
setCreateDialogOpen(open);
|
||||
if (!open) setFolderName("");
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create folder</DialogTitle>
|
||||
<DialogDescription>
|
||||
Target: {activePath || "Loading"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="p-4">
|
||||
<Input
|
||||
autoFocus
|
||||
value={folderName}
|
||||
onChange={(event) => setFolderName(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void createDirectory();
|
||||
}}
|
||||
placeholder="Folder name"
|
||||
disabled={creating}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
outlined
|
||||
onClick={() => {
|
||||
setCreateDialogOpen(false);
|
||||
setFolderName("");
|
||||
}}
|
||||
disabled={creating}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void createDirectory()}
|
||||
disabled={creating}
|
||||
prefix={creating ? <Spinner /> : <FolderPlus />}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={Boolean(pendingDelete)}
|
||||
loading={deleting}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
onConfirm={() => void confirmDelete()}
|
||||
title={pendingDelete ? `Delete ${pendingDelete.name}?` : "Delete item?"}
|
||||
description={
|
||||
pendingDelete?.is_directory
|
||||
? "This removes the folder and everything inside it."
|
||||
: "This removes the file."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
611
web/src/pages/ProfileBuilderPage.tsx
Normal file
611
web/src/pages/ProfileBuilderPage.tsx
Normal file
@@ -0,0 +1,611 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
|
||||
import { Badge } from "@nous-research/ui/ui/components/badge";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { api } from "@/lib/api";
|
||||
import type { McpServerCreate, SkillInfo, SkillHubResult } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Profile name rule mirrors the backend (`^[a-z0-9][a-z0-9_-]{0,63}$`).
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||
|
||||
type StepId = "identity" | "model" | "skills" | "mcp" | "review";
|
||||
|
||||
const STEPS: { id: StepId; label: string }[] = [
|
||||
{ id: "identity", label: "Identity" },
|
||||
{ id: "model", label: "Model" },
|
||||
{ id: "skills", label: "Skills" },
|
||||
{ id: "mcp", label: "MCPs" },
|
||||
{ id: "review", label: "Review" },
|
||||
];
|
||||
|
||||
interface ModelChoice {
|
||||
provider: string;
|
||||
model: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard-native, full-featured profile builder.
|
||||
*
|
||||
* Composes the same elements the standalone Models / Skills / MCP pages
|
||||
* manage — Name, Description, Model+Provider, Skills (built-in/optional +
|
||||
* hub), MCP servers — into one stepped create flow. Nothing is written to
|
||||
* disk until "Create profile" on the final step; the single POST /api/profiles
|
||||
* call commits model + MCPs + skill selection synchronously and spawns any
|
||||
* hub-skill installs (which the success toast reports as in-progress).
|
||||
*
|
||||
* Skills use REPLACE semantics: the default bundle is seeded server-side, then
|
||||
* every seeded skill the user did NOT keep is disabled. The "Start from full
|
||||
* bundle" toggle keeps everything (sends no keep list).
|
||||
*/
|
||||
export default function ProfileBuilderPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast, showToast } = useToast();
|
||||
|
||||
const [step, setStep] = useState<StepId>("identity");
|
||||
|
||||
// ── Step 1: identity ──────────────────────────────────────────────
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// ── Step 2: model ─────────────────────────────────────────────────
|
||||
const [modelChoices, setModelChoices] = useState<ModelChoice[] | null>(null);
|
||||
const [modelChoice, setModelChoice] = useState(""); // `${provider}\u0000${model}`
|
||||
const [modelFilter, setModelFilter] = useState("");
|
||||
const modelLoading = useRef(false);
|
||||
|
||||
// ── Step 3: skills ────────────────────────────────────────────────
|
||||
const [skills, setSkills] = useState<SkillInfo[] | null>(null);
|
||||
// keepAll = true: don't send a keep list (full bundle stays active).
|
||||
const [keepAll, setKeepAll] = useState(true);
|
||||
const [keptSkills, setKeptSkills] = useState<Set<string>>(new Set());
|
||||
const [skillFilter, setSkillFilter] = useState("");
|
||||
const skillsLoading = useRef(false);
|
||||
// Hub search
|
||||
const [hubQuery, setHubQuery] = useState("");
|
||||
const [hubResults, setHubResults] = useState<SkillHubResult[]>([]);
|
||||
const [hubSearching, setHubSearching] = useState(false);
|
||||
const [hubSkills, setHubSkills] = useState<SkillHubResult[]>([]);
|
||||
|
||||
// ── Step 4: MCPs ──────────────────────────────────────────────────
|
||||
const [mcpServers, setMcpServers] = useState<McpServerCreate[]>([]);
|
||||
const [mcpDraft, setMcpDraft] = useState<{
|
||||
name: string;
|
||||
url: string;
|
||||
command: string;
|
||||
args: string;
|
||||
}>({ name: "", url: "", command: "", args: "" });
|
||||
|
||||
// ── Submit ────────────────────────────────────────────────────────
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
const nameValid = PROFILE_NAME_RE.test(name.trim());
|
||||
|
||||
// Lazy-load model choices when the model step is first shown.
|
||||
const loadModels = useCallback(() => {
|
||||
if (modelChoices !== null || modelLoading.current) return;
|
||||
modelLoading.current = true;
|
||||
api
|
||||
.getModelOptions()
|
||||
.then((res) => {
|
||||
const flat: ModelChoice[] = [];
|
||||
for (const prov of res.providers ?? []) {
|
||||
for (const m of prov.models ?? []) {
|
||||
flat.push({ provider: prov.slug, model: m, label: `${prov.name} · ${m}` });
|
||||
}
|
||||
}
|
||||
setModelChoices(flat);
|
||||
})
|
||||
.catch(() => setModelChoices([]))
|
||||
.finally(() => {
|
||||
modelLoading.current = false;
|
||||
});
|
||||
}, [modelChoices]);
|
||||
|
||||
const loadSkills = useCallback(() => {
|
||||
if (skills !== null || skillsLoading.current) return;
|
||||
skillsLoading.current = true;
|
||||
api
|
||||
.getSkills()
|
||||
.then((res) => {
|
||||
setSkills(res);
|
||||
// Default keep = all currently-enabled skills (matches the seeded set).
|
||||
setKeptSkills(new Set(res.filter((s) => s.enabled).map((s) => s.name)));
|
||||
})
|
||||
.catch(() => setSkills([]))
|
||||
.finally(() => {
|
||||
skillsLoading.current = false;
|
||||
});
|
||||
}, [skills]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step === "model") loadModels();
|
||||
if (step === "skills") loadSkills();
|
||||
}, [step, loadModels, loadSkills]);
|
||||
|
||||
const runHubSearch = useCallback(() => {
|
||||
const q = hubQuery.trim();
|
||||
if (!q) return;
|
||||
setHubSearching(true);
|
||||
api
|
||||
.searchSkillsHub(q, "all", 20)
|
||||
.then((res) => setHubResults(res.results ?? []))
|
||||
.catch(() => setHubResults([]))
|
||||
.finally(() => setHubSearching(false));
|
||||
}, [hubQuery]);
|
||||
|
||||
const toggleKeep = (skillName: string) => {
|
||||
setKeptSkills((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(skillName)) next.delete(skillName);
|
||||
else next.add(skillName);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addHubSkill = (r: SkillHubResult) => {
|
||||
setHubSkills((prev) =>
|
||||
prev.some((x) => x.identifier === r.identifier) ? prev : [...prev, r],
|
||||
);
|
||||
};
|
||||
const removeHubSkill = (identifier: string) =>
|
||||
setHubSkills((prev) => prev.filter((x) => x.identifier !== identifier));
|
||||
|
||||
const addMcpDraft = () => {
|
||||
const n = mcpDraft.name.trim();
|
||||
if (!n) {
|
||||
showToast("MCP server needs a name", "error");
|
||||
return;
|
||||
}
|
||||
if (!mcpDraft.url.trim() && !mcpDraft.command.trim()) {
|
||||
showToast("Give the MCP server a URL or a command", "error");
|
||||
return;
|
||||
}
|
||||
const entry: McpServerCreate = { name: n };
|
||||
if (mcpDraft.url.trim()) entry.url = mcpDraft.url.trim();
|
||||
if (mcpDraft.command.trim()) {
|
||||
entry.command = mcpDraft.command.trim();
|
||||
const args = mcpDraft.args.trim();
|
||||
if (args) entry.args = args.split(/\s+/);
|
||||
}
|
||||
setMcpServers((prev) => [...prev.filter((s) => s.name !== n), entry]);
|
||||
setMcpDraft({ name: "", url: "", command: "", args: "" });
|
||||
};
|
||||
const removeMcp = (n: string) =>
|
||||
setMcpServers((prev) => prev.filter((s) => s.name !== n));
|
||||
|
||||
const filteredModels = useMemo(() => {
|
||||
if (!modelChoices) return [];
|
||||
const f = modelFilter.trim().toLowerCase();
|
||||
if (!f) return modelChoices;
|
||||
return modelChoices.filter((c) => c.label.toLowerCase().includes(f));
|
||||
}, [modelChoices, modelFilter]);
|
||||
|
||||
const filteredSkills = useMemo(() => {
|
||||
if (!skills) return [];
|
||||
const f = skillFilter.trim().toLowerCase();
|
||||
if (!f) return skills;
|
||||
return skills.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(f) ||
|
||||
(s.description || "").toLowerCase().includes(f) ||
|
||||
(s.category || "").toLowerCase().includes(f),
|
||||
);
|
||||
}, [skills, skillFilter]);
|
||||
|
||||
const pickedModel = useMemo(
|
||||
() =>
|
||||
modelChoice
|
||||
? modelChoices?.find((c) => `${c.provider}\u0000${c.model}` === modelChoice)
|
||||
: undefined,
|
||||
[modelChoice, modelChoices],
|
||||
);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const n = name.trim();
|
||||
if (!PROFILE_NAME_RE.test(n)) {
|
||||
showToast("Invalid profile name (lowercase, digits, - and _)", "error");
|
||||
setStep("identity");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await api.createProfile({
|
||||
name: n,
|
||||
clone_from_default: false,
|
||||
description: description.trim() || undefined,
|
||||
provider: pickedModel?.provider,
|
||||
model: pickedModel?.model,
|
||||
mcp_servers: mcpServers.length ? mcpServers : undefined,
|
||||
keep_skills: keepAll ? undefined : Array.from(keptSkills),
|
||||
hub_skills: hubSkills.length ? hubSkills.map((s) => s.identifier) : undefined,
|
||||
});
|
||||
const pending = (res.hub_installs ?? []).filter((h) => h.pid).length;
|
||||
showToast(
|
||||
pending
|
||||
? `Profile "${n}" created — ${pending} hub skill${pending === 1 ? "" : "s"} installing`
|
||||
: `Profile "${n}" created`,
|
||||
"success",
|
||||
);
|
||||
navigate("/profiles");
|
||||
} catch (e) {
|
||||
showToast(`Create failed: ${e}`, "error");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stepIndex = STEPS.findIndex((s) => s.id === step);
|
||||
const canAdvance = step !== "identity" || nameValid;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<H2>New profile</H2>
|
||||
<Button ghost onClick={() => navigate("/profiles")}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stepper */}
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{STEPS.map((s, i) => (
|
||||
<button
|
||||
key={s.id}
|
||||
// Identity must be valid before jumping ahead.
|
||||
disabled={i > 0 && !nameValid}
|
||||
onClick={() => setStep(s.id)}
|
||||
className={cn(
|
||||
"rounded-full px-3 py-1 transition-colors",
|
||||
s.id === step
|
||||
? "bg-primary text-primary-foreground"
|
||||
: i <= stepIndex
|
||||
? "bg-muted text-foreground"
|
||||
: "text-muted-foreground",
|
||||
i > 0 && !nameValid && "cursor-not-allowed opacity-50",
|
||||
)}
|
||||
>
|
||||
{i + 1}. {s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="space-y-4 p-5">
|
||||
{step === "identity" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pb-name">Profile name</Label>
|
||||
<Input
|
||||
id="pb-name"
|
||||
placeholder="coder"
|
||||
value={name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setName(e.target.value)}
|
||||
/>
|
||||
{name && !nameValid && (
|
||||
<p className="text-xs text-destructive">
|
||||
Lowercase letters, digits, hyphens and underscores; must start with a letter or digit.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="pb-desc">Description (optional)</Label>
|
||||
<Input
|
||||
id="pb-desc"
|
||||
placeholder="What this agent profile is for"
|
||||
value={description}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setDescription(e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "model" && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Pick the model+provider for this profile. Skip to use the default.
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Filter models…"
|
||||
value={modelFilter}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setModelFilter(e.target.value)
|
||||
}
|
||||
/>
|
||||
{modelChoices === null ? (
|
||||
<p className="text-sm text-muted-foreground">Loading models…</p>
|
||||
) : (
|
||||
<div className="max-h-72 space-y-1 overflow-y-auto">
|
||||
<button
|
||||
onClick={() => setModelChoice("")}
|
||||
className={cn(
|
||||
"block w-full rounded px-3 py-2 text-left text-sm",
|
||||
modelChoice === "" ? "bg-primary/10" : "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
Use default (set later)
|
||||
</button>
|
||||
{filteredModels.map((c) => {
|
||||
const key = `${c.provider}\u0000${c.model}`;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setModelChoice(key)}
|
||||
className={cn(
|
||||
"block w-full rounded px-3 py-2 text-left text-sm",
|
||||
modelChoice === key ? "bg-primary/10" : "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "skills" && (
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={keepAll}
|
||||
onCheckedChange={(v) => setKeepAll(Boolean(v))}
|
||||
/>
|
||||
Start from the full default skill bundle (recommended)
|
||||
</label>
|
||||
{!keepAll && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose which built-in / optional skills to keep active. Unchecked skills are disabled in the new profile.
|
||||
</p>
|
||||
<Input
|
||||
placeholder="Filter skills…"
|
||||
value={skillFilter}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setSkillFilter(e.target.value)
|
||||
}
|
||||
/>
|
||||
{skills === null ? (
|
||||
<p className="text-sm text-muted-foreground">Loading skills…</p>
|
||||
) : (
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto">
|
||||
{filteredSkills.map((s) => (
|
||||
<label
|
||||
key={s.name}
|
||||
className="flex items-start gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted"
|
||||
>
|
||||
<Checkbox
|
||||
checked={keptSkills.has(s.name)}
|
||||
onCheckedChange={() => toggleKeep(s.name)}
|
||||
/>
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">{s.name}</span>
|
||||
{s.category && (
|
||||
<Badge tone="secondary" className="ml-2">
|
||||
{s.category}
|
||||
</Badge>
|
||||
)}
|
||||
{s.description && (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{s.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills hub */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Label>Add from the skills hub</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Search the hub (e.g. linear, hyperliquid)…"
|
||||
value={hubQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setHubQuery(e.target.value)
|
||||
}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") runHubSearch();
|
||||
}}
|
||||
/>
|
||||
<Button outlined onClick={runHubSearch} disabled={hubSearching}>
|
||||
{hubSearching ? "Searching…" : "Search"}
|
||||
</Button>
|
||||
</div>
|
||||
{hubResults.length > 0 && (
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{hubResults.map((r) => (
|
||||
<div
|
||||
key={r.identifier}
|
||||
className="flex items-center justify-between rounded px-2 py-1.5 text-sm hover:bg-muted"
|
||||
>
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">{r.name}</span>
|
||||
<Badge tone="secondary" className="ml-2">
|
||||
{r.source}
|
||||
</Badge>
|
||||
{r.description && (
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{r.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<Button size="sm" ghost onClick={() => addHubSkill(r)}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hubSkills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{hubSkills.map((r) => (
|
||||
<Badge key={r.identifier} className="gap-1">
|
||||
{r.name}
|
||||
<button
|
||||
className="ml-1 text-xs"
|
||||
onClick={() => removeHubSkill(r.identifier)}
|
||||
aria-label={`Remove ${r.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "mcp" && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Add MCP servers for this profile. HTTP servers take a URL; stdio servers take a command + args.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
placeholder="Server name"
|
||||
value={mcpDraft.name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMcpDraft({ ...mcpDraft, name: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="URL (https://…/mcp)"
|
||||
value={mcpDraft.url}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMcpDraft({ ...mcpDraft, url: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Command (e.g. npx)"
|
||||
value={mcpDraft.command}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMcpDraft({ ...mcpDraft, command: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Args (space-separated)"
|
||||
value={mcpDraft.args}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMcpDraft({ ...mcpDraft, args: e.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button outlined onClick={addMcpDraft}>
|
||||
Add server
|
||||
</Button>
|
||||
{mcpServers.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{mcpServers.map((s) => (
|
||||
<div
|
||||
key={s.name}
|
||||
className="flex items-center justify-between rounded bg-muted px-3 py-1.5 text-sm"
|
||||
>
|
||||
<span>
|
||||
<span className="font-medium">{s.name}</span>{" "}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{s.url || `${s.command} ${(s.args || []).join(" ")}`}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
className="text-xs text-destructive"
|
||||
onClick={() => removeMcp(s.name)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "review" && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<ReviewRow label="Name" value={name.trim() || "—"} />
|
||||
<ReviewRow label="Description" value={description.trim() || "—"} />
|
||||
<ReviewRow
|
||||
label="Model"
|
||||
value={pickedModel ? pickedModel.label : "Default (set later)"}
|
||||
/>
|
||||
<ReviewRow
|
||||
label="Skills"
|
||||
value={
|
||||
keepAll
|
||||
? "Full default bundle"
|
||||
: `${keptSkills.size} built-in/optional kept` +
|
||||
(hubSkills.length ? ` + ${hubSkills.length} hub` : "")
|
||||
}
|
||||
/>
|
||||
{!keepAll && hubSkills.length > 0 && (
|
||||
<p className="pl-24 text-xs text-muted-foreground">
|
||||
Hub: {hubSkills.map((s) => s.name).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{keepAll && hubSkills.length > 0 && (
|
||||
<ReviewRow
|
||||
label="Hub skills"
|
||||
value={hubSkills.map((s) => s.name).join(", ")}
|
||||
/>
|
||||
)}
|
||||
<ReviewRow
|
||||
label="MCP servers"
|
||||
value={mcpServers.length ? mcpServers.map((s) => s.name).join(", ") : "None"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Nav buttons */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
ghost
|
||||
disabled={stepIndex === 0}
|
||||
onClick={() => setStep(STEPS[Math.max(0, stepIndex - 1)].id)}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
{step === "review" ? (
|
||||
<Button onClick={handleCreate} disabled={creating || !nameValid}>
|
||||
{creating ? "Creating…" : "Create profile"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!canAdvance}
|
||||
onClick={() => setStep(STEPS[Math.min(STEPS.length - 1, stepIndex + 1)].id)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Toast toast={toast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReviewRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<span className="w-24 shrink-0 text-muted-foreground">{label}</span>
|
||||
<span className="flex-1 break-words">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AlignLeft,
|
||||
Check,
|
||||
@@ -246,6 +247,7 @@ export default function ProfilesPage() {
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { setEnd } = usePageHeader();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Locale strings with English fallbacks. The enriched keys are optional in
|
||||
// the i18n type so untranslated locales don't break the build — they render
|
||||
@@ -722,21 +724,31 @@ export default function ProfilesPage() {
|
||||
: base;
|
||||
})();
|
||||
|
||||
// Put "Create" button in page header
|
||||
// Put "Build" (full builder) + "Create" (quick modal) buttons in header
|
||||
useLayoutEffect(() => {
|
||||
setEnd(
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
{t.common.create}
|
||||
</Button>,
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
outlined
|
||||
onClick={() => navigate("/profiles/new")}
|
||||
>
|
||||
Build
|
||||
</Button>
|
||||
<Button
|
||||
className="uppercase"
|
||||
size="sm"
|
||||
onClick={() => setCreateModalOpen(true)}
|
||||
>
|
||||
{t.common.create}
|
||||
</Button>
|
||||
</div>,
|
||||
);
|
||||
return () => {
|
||||
setEnd(null);
|
||||
};
|
||||
}, [setEnd, t.common.create, loading]);
|
||||
}, [setEnd, t.common.create, loading, navigate]);
|
||||
|
||||
const cloning = cloneAll || cloneFromDefault;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user