mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 10:22:18 +00:00
Merge remote-tracking branch 'origin/main' into hermes/hermes-6fe26723
This commit is contained in:
commit
e1e53bff9d
21 changed files with 3503 additions and 114 deletions
|
|
@ -1156,6 +1156,9 @@ def init_agent(
|
|||
"hermes_home": str(get_hermes_home()),
|
||||
"agent_context": "primary",
|
||||
}
|
||||
if _init_kwargs["platform"] == "cli":
|
||||
_init_kwargs["warning_callback"] = agent._emit_warning
|
||||
_init_kwargs["status_callback"] = agent._emit_status
|
||||
# Thread session title for memory provider scoping
|
||||
# (e.g. honcho uses this to derive chat-scoped session keys)
|
||||
if agent._session_db:
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 389 KiB |
|
|
@ -15,24 +15,50 @@ from pathlib import Path
|
|||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.secret_prompt import masked_secret_prompt
|
||||
|
||||
_CANCELLED = -1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Curses-based interactive picker (same pattern as hermes tools)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _curses_select(title: str, items: list[tuple[str, str]], default: int = 0) -> int:
|
||||
def _curses_select(
|
||||
title: str,
|
||||
items: list[tuple[str, str]],
|
||||
default: int = 0,
|
||||
*,
|
||||
cancel_returns: int | None = None,
|
||||
) -> int:
|
||||
"""Interactive single-select with arrow keys.
|
||||
|
||||
items: list of (label, description) tuples.
|
||||
Returns selected index, or default on escape/quit.
|
||||
Returns selected index, or cancel_returns/default on escape/quit.
|
||||
"""
|
||||
from hermes_cli.curses_ui import curses_radiolist
|
||||
|
||||
if cancel_returns is None:
|
||||
cancel_returns = default
|
||||
|
||||
# Format (label, desc) tuples into display strings
|
||||
display_items = [
|
||||
f"{label} {desc}" if desc else label
|
||||
f"{label} - {desc}" if desc else label
|
||||
for label, desc in items
|
||||
]
|
||||
return curses_radiolist(title, display_items, selected=default, cancel_returns=default)
|
||||
result = curses_radiolist(title, display_items, selected=default, cancel_returns=cancel_returns)
|
||||
_clear_interactive_transition()
|
||||
return result
|
||||
|
||||
|
||||
def _print_cancelled_setup() -> None:
|
||||
print("\n Cancelled. No changes saved.\n")
|
||||
|
||||
|
||||
def _clear_interactive_transition() -> None:
|
||||
"""Clear stale curses content before entering a follow-up setup screen."""
|
||||
if not sys.stdout.isatty():
|
||||
return
|
||||
sys.stdout.write("\033[2J\033[H")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:
|
||||
|
|
@ -205,6 +231,8 @@ def cmd_setup_provider(provider_name: str) -> None:
|
|||
|
||||
name, _, provider = match
|
||||
|
||||
_clear_interactive_transition()
|
||||
|
||||
_install_dependencies(name)
|
||||
|
||||
config = load_config()
|
||||
|
|
@ -241,14 +269,17 @@ def cmd_setup(args) -> None:
|
|||
items.append(("Built-in only", "— MEMORY.md / USER.md (default)"))
|
||||
|
||||
builtin_idx = len(items) - 1
|
||||
selected = _curses_select("Memory provider setup", items, default=builtin_idx)
|
||||
selected = _curses_select("Memory provider setup", items, default=builtin_idx, cancel_returns=_CANCELLED)
|
||||
if selected == _CANCELLED:
|
||||
_print_cancelled_setup()
|
||||
return
|
||||
|
||||
config = load_config()
|
||||
if not isinstance(config.get("memory"), dict):
|
||||
config["memory"] = {}
|
||||
|
||||
# Built-in only
|
||||
if selected >= len(providers) or selected < 0:
|
||||
if selected >= len(providers):
|
||||
config["memory"]["provider"] = ""
|
||||
save_config(config)
|
||||
print("\n ✓ Memory provider: built-in only")
|
||||
|
|
@ -257,6 +288,8 @@ def cmd_setup(args) -> None:
|
|||
|
||||
name, _, provider = providers[selected]
|
||||
|
||||
_clear_interactive_transition()
|
||||
|
||||
# Install pip dependencies if declared in plugin.yaml
|
||||
_install_dependencies(name)
|
||||
|
||||
|
|
@ -309,7 +342,10 @@ def cmd_setup(args) -> None:
|
|||
current_idx = 0
|
||||
if current and current in choices:
|
||||
current_idx = choices.index(current)
|
||||
sel = _curses_select(f" {desc}", choice_items, default=current_idx)
|
||||
sel = _curses_select(f" {desc}", choice_items, default=current_idx, cancel_returns=_CANCELLED)
|
||||
if sel == _CANCELLED:
|
||||
_print_cancelled_setup()
|
||||
return
|
||||
provider_config[key] = choices[sel]
|
||||
elif is_secret:
|
||||
# Prompt for secret
|
||||
|
|
@ -407,43 +443,53 @@ def cmd_status(args) -> None:
|
|||
print(f" Built-in: always active")
|
||||
print(f" Provider: {provider_name or '(none — built-in only)'}")
|
||||
|
||||
providers = _get_available_providers()
|
||||
provider = None
|
||||
for pname, _, candidate in providers:
|
||||
if pname == provider_name:
|
||||
provider = candidate
|
||||
break
|
||||
|
||||
if provider_name:
|
||||
provider_config = mem_config.get(provider_name, {})
|
||||
if provider_config:
|
||||
display_config = provider_config
|
||||
if provider and hasattr(provider, "get_status_config"):
|
||||
try:
|
||||
display_config = provider.get_status_config(provider_config)
|
||||
except Exception as e:
|
||||
display_config = dict(provider_config) if isinstance(provider_config, dict) else provider_config
|
||||
if isinstance(display_config, dict):
|
||||
display_config["status_config_error"] = str(e)
|
||||
|
||||
if display_config:
|
||||
print(f"\n {provider_name} config:")
|
||||
for key, val in provider_config.items():
|
||||
for key, val in display_config.items():
|
||||
print(f" {key}: {val}")
|
||||
|
||||
providers = _get_available_providers()
|
||||
found = any(name == provider_name for name, _, _ in providers)
|
||||
if found:
|
||||
if provider:
|
||||
print(f"\n Plugin: installed ✓")
|
||||
for pname, _, p in providers:
|
||||
if pname == provider_name:
|
||||
if p.is_available():
|
||||
print(f" Status: available ✓")
|
||||
else:
|
||||
print(f" Status: not available ✗")
|
||||
schema = p.get_config_schema() if hasattr(p, "get_config_schema") else []
|
||||
# Check all fields that have env_var (both secret and non-secret)
|
||||
required_fields = [f for f in schema if f.get("env_var")]
|
||||
if required_fields:
|
||||
print(f" Missing:")
|
||||
for f in required_fields:
|
||||
env_var = f.get("env_var", "")
|
||||
url = f.get("url", "")
|
||||
is_set = bool(os.environ.get(env_var))
|
||||
mark = "✓" if is_set else "✗"
|
||||
line = f" {mark} {env_var}"
|
||||
if url and not is_set:
|
||||
line += f" → {url}"
|
||||
print(line)
|
||||
break
|
||||
if provider.is_available():
|
||||
print(f" Status: available ✓")
|
||||
else:
|
||||
print(f" Status: not available ✗")
|
||||
schema = provider.get_config_schema() if hasattr(provider, "get_config_schema") else []
|
||||
# Check all fields that have env_var (both secret and non-secret)
|
||||
required_fields = [f for f in schema if f.get("env_var")]
|
||||
if required_fields:
|
||||
print(f" Missing:")
|
||||
for f in required_fields:
|
||||
env_var = f.get("env_var", "")
|
||||
url = f.get("url", "")
|
||||
is_set = bool(os.environ.get(env_var))
|
||||
mark = "✓" if is_set else "✗"
|
||||
line = f" {mark} {env_var}"
|
||||
if url and not is_set:
|
||||
line += f" → {url}"
|
||||
print(line)
|
||||
else:
|
||||
print(f"\n Plugin: NOT installed ✗")
|
||||
print(f" Install the '{provider_name}' memory plugin to ~/.hermes/plugins/")
|
||||
|
||||
providers = _get_available_providers()
|
||||
if providers:
|
||||
print(f"\n Installed plugins:")
|
||||
for pname, desc, _ in providers:
|
||||
|
|
|
|||
|
|
@ -27,16 +27,16 @@ def _collect_masked_input(
|
|||
while True:
|
||||
ch = read_char()
|
||||
if ch == "":
|
||||
write("\n")
|
||||
write("\r\n")
|
||||
raise EOFError
|
||||
if ch in _ENTER_CHARS:
|
||||
write("\n")
|
||||
write("\r\n")
|
||||
return "".join(value)
|
||||
if ch == "\x03":
|
||||
write("\n")
|
||||
write("\r\n")
|
||||
raise KeyboardInterrupt
|
||||
if ch in _EOF_CHARS:
|
||||
write("\n")
|
||||
write("\r\n")
|
||||
raise EOFError
|
||||
if ch in _BACKSPACE_CHARS:
|
||||
if value:
|
||||
|
|
|
|||
|
|
@ -70,7 +70,10 @@ from gateway.status import (
|
|||
from utils import env_var_enabled
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi import (
|
||||
FastAPI, File, Form, HTTPException, Request, UploadFile,
|
||||
WebSocket, WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
|
@ -82,7 +85,10 @@ except ImportError:
|
|||
try:
|
||||
from tools.lazy_deps import ensure as _lazy_ensure
|
||||
_lazy_ensure("tool.dashboard", prompt=False)
|
||||
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi import (
|
||||
FastAPI, File, Form, HTTPException, Request, UploadFile,
|
||||
WebSocket, WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
|
@ -1483,6 +1489,74 @@ async def upload_managed_file(payload: ManagedFileUpload, request: Request):
|
|||
}
|
||||
|
||||
|
||||
# Stream uploads to disk in fixed-size chunks. The legacy JSON endpoint above
|
||||
# buffers the whole file as a base64 data URL in a JSON body, which (a) inflates
|
||||
# the payload ~33%, (b) holds the entire file (plus its decoded copy) in memory,
|
||||
# and (c) reliably trips upstream proxy body-size/timeout limits with a 502 on
|
||||
# large backup archives (NS-501). This multipart endpoint reads the request body
|
||||
# in 1 MiB chunks straight to a temp file, enforces the size cap as it goes, and
|
||||
# atomically renames into place — constant memory, no base64 inflation.
|
||||
_UPLOAD_CHUNK_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
@app.post("/api/files/upload-stream")
|
||||
async def upload_managed_file_stream(
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
path: str = Form(...),
|
||||
overwrite: bool = Form(True),
|
||||
):
|
||||
policy, target, display_path = _resolve_managed_path(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 overwrite:
|
||||
raise HTTPException(status_code=409, detail="File already exists")
|
||||
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
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 create parent directory: {exc}")
|
||||
|
||||
# Write to a sibling temp file first so a partial/aborted upload never
|
||||
# clobbers an existing file, then atomically rename into place.
|
||||
tmp_fd, tmp_name = tempfile.mkstemp(
|
||||
prefix=f".{target.name}.", suffix=".upload", dir=str(target.parent)
|
||||
)
|
||||
tmp_path = Path(tmp_name)
|
||||
total = 0
|
||||
try:
|
||||
with os.fdopen(tmp_fd, "wb") as out:
|
||||
while True:
|
||||
chunk = await file.read(_UPLOAD_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > _MANAGED_FILE_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail="File is too large")
|
||||
out.write(chunk)
|
||||
os.replace(tmp_path, target)
|
||||
except HTTPException:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
except PermissionError:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=403, detail="File is not writable")
|
||||
except OSError as exc:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=500, detail=f"Could not write file: {exc}")
|
||||
finally:
|
||||
await file.close()
|
||||
|
||||
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)
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
|
|
@ -702,7 +702,7 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||
from hermes_cli.config import save_config
|
||||
from hermes_cli.secret_prompt import masked_secret_prompt
|
||||
|
||||
from hermes_cli.memory_setup import _curses_select
|
||||
from hermes_cli.memory_setup import _CANCELLED, _curses_select, _print_cancelled_setup
|
||||
|
||||
print("\n Configuring Hindsight memory:\n")
|
||||
|
||||
|
|
@ -719,7 +719,10 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||
]
|
||||
existing_mode = existing_config.get("mode")
|
||||
mode_default_idx = mode_values.index(existing_mode) if existing_mode in mode_values else 0
|
||||
mode_idx = _curses_select(" Select mode", mode_items, default=mode_default_idx)
|
||||
mode_idx = _curses_select(" Select mode", mode_items, default=mode_default_idx, cancel_returns=_CANCELLED)
|
||||
if mode_idx == _CANCELLED:
|
||||
_print_cancelled_setup()
|
||||
return
|
||||
mode = mode_values[mode_idx]
|
||||
|
||||
provider_config: dict = dict(existing_config)
|
||||
|
|
@ -737,6 +740,27 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||
else:
|
||||
deps_to_install = [cloud_dep]
|
||||
|
||||
llm_provider = ""
|
||||
if mode == "local_embedded":
|
||||
providers_list = list(_PROVIDER_DEFAULT_MODELS.keys())
|
||||
llm_items = [
|
||||
(p, f"default model: {_PROVIDER_DEFAULT_MODELS[p]}")
|
||||
for p in providers_list
|
||||
]
|
||||
existing_llm_provider = provider_config.get("llm_provider")
|
||||
llm_default_idx = providers_list.index(existing_llm_provider) if existing_llm_provider in providers_list else 0
|
||||
llm_idx = _curses_select(
|
||||
" Select LLM provider",
|
||||
llm_items,
|
||||
default=llm_default_idx,
|
||||
cancel_returns=_CANCELLED,
|
||||
)
|
||||
if llm_idx == _CANCELLED:
|
||||
_print_cancelled_setup()
|
||||
return
|
||||
llm_provider = providers_list[llm_idx]
|
||||
provider_config["llm_provider"] = llm_provider
|
||||
|
||||
print("\n Checking dependencies...")
|
||||
uv_path = shutil.which("uv")
|
||||
if not uv_path:
|
||||
|
|
@ -785,18 +809,6 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||
env_writes["HINDSIGHT_API_KEY"] = api_key
|
||||
|
||||
else: # local_embedded
|
||||
providers_list = list(_PROVIDER_DEFAULT_MODELS.keys())
|
||||
llm_items = [
|
||||
(p, f"default model: {_PROVIDER_DEFAULT_MODELS[p]}")
|
||||
for p in providers_list
|
||||
]
|
||||
existing_llm_provider = provider_config.get("llm_provider")
|
||||
llm_default_idx = providers_list.index(existing_llm_provider) if existing_llm_provider in providers_list else 0
|
||||
llm_idx = _curses_select(" Select LLM provider", llm_items, default=llm_default_idx)
|
||||
llm_provider = providers_list[llm_idx]
|
||||
|
||||
provider_config["llm_provider"] = llm_provider
|
||||
|
||||
if llm_provider == "openai_compatible":
|
||||
existing_base_url = provider_config.get("llm_base_url", "")
|
||||
prompt = " LLM endpoint URL (e.g. http://192.168.1.10:8080/v1)"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ Context database by Volcengine (ByteDance) with filesystem-style knowledge hiera
|
|||
hermes memory setup # select "openviking"
|
||||
```
|
||||
|
||||
The setup can link to an existing `~/.openviking/ovcli.conf`, copy its current
|
||||
connection values into Hermes, or create a minimal `ovcli.conf` when one does
|
||||
not exist.
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
hermes config set memory.provider openviking
|
||||
|
|
@ -28,6 +32,9 @@ All config via environment variables in `.env`:
|
|||
|---------|---------|-------------|
|
||||
| `OPENVIKING_ENDPOINT` | `http://127.0.0.1:1933` | Server URL |
|
||||
| `OPENVIKING_API_KEY` | (none) | API key (optional) |
|
||||
| `OPENVIKING_ACCOUNT` | (none) | Tenant account override |
|
||||
| `OPENVIKING_USER` | (none) | Tenant user override |
|
||||
| `OPENVIKING_AGENT` | `hermes` | Tenant agent namespace |
|
||||
|
||||
## Tools
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,7 +3,6 @@ version: 2.0.0
|
|||
description: "OpenViking context database — session-managed memory with automatic extraction, tiered retrieval, and filesystem-style knowledge browsing."
|
||||
pip_dependencies:
|
||||
- httpx
|
||||
requires_env:
|
||||
- OPENVIKING_ENDPOINT
|
||||
requires_env: []
|
||||
hooks:
|
||||
- on_session_end
|
||||
|
|
|
|||
|
|
@ -106,6 +106,11 @@ dependencies = [
|
|||
"pathspec==1.1.1",
|
||||
"fastapi>=0.104.0,<1",
|
||||
"uvicorn[standard]>=0.24.0,<1",
|
||||
# Streaming multipart uploads for the dashboard file manager (NS-501).
|
||||
# FastAPI's UploadFile/Form depend on python-multipart; it is NOT pulled in
|
||||
# by fastapi itself, so the dashboard's multipart upload endpoint would 500
|
||||
# without an explicit dependency here (and in the `web` extra below).
|
||||
"python-multipart>=0.0.9,<1",
|
||||
"ptyprocess>=0.7.0,<1; sys_platform != 'win32'",
|
||||
"pywinpty>=2.0.0,<3; sys_platform == 'win32'",
|
||||
# Image resize recovery for the vision tools. Pillow shrinks oversized images
|
||||
|
|
@ -253,7 +258,7 @@ youtube = [
|
|||
# `hermes dashboard` (localhost SPA + API). Not in core to keep the default install lean.
|
||||
# starlette==1.0.1 pinned for CVE-2026-48710 (BadHost) — fastapi pulls Starlette
|
||||
# transitively and pre-1.0.1 is the vulnerable range. See the mcp extra above.
|
||||
web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.0.1"]
|
||||
web = ["fastapi==0.133.1", "uvicorn[standard]==0.41.0", "starlette==1.0.1", "python-multipart==0.0.20"]
|
||||
all = [
|
||||
# Policy (2026-05-12): `[all]` includes only extras that genuinely
|
||||
# CAN'T be lazy-installed via `tools/lazy_deps.py` — i.e. things every
|
||||
|
|
|
|||
198
tests/hermes_cli/test_memory_setup.py
Normal file
198
tests/hermes_cli/test_memory_setup.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import hermes_cli.memory_setup as memory_setup
|
||||
from hermes_cli.memory_setup import _CANCELLED, _curses_select
|
||||
|
||||
|
||||
def test_curses_select_cancel_defaults_to_selected(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_radiolist(title, items, selected=0, *, cancel_returns=None):
|
||||
captured.update({
|
||||
"title": title,
|
||||
"items": items,
|
||||
"selected": selected,
|
||||
"cancel_returns": cancel_returns,
|
||||
})
|
||||
return cancel_returns
|
||||
|
||||
monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", fake_radiolist)
|
||||
|
||||
result = _curses_select("Pick one", [("first", "desc"), ("second", "")], default=1)
|
||||
|
||||
assert result == 1
|
||||
assert captured == {
|
||||
"title": "Pick one",
|
||||
"items": ["first - desc", "second"],
|
||||
"selected": 1,
|
||||
"cancel_returns": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_curses_select_accepts_explicit_cancel_value(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_radiolist(title, items, selected=0, *, cancel_returns=None):
|
||||
captured["cancel_returns"] = cancel_returns
|
||||
return cancel_returns
|
||||
|
||||
monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", fake_radiolist)
|
||||
|
||||
result = _curses_select("Pick one", [("first", "")], default=0, cancel_returns=_CANCELLED)
|
||||
|
||||
assert result == _CANCELLED
|
||||
assert captured["cancel_returns"] == _CANCELLED
|
||||
|
||||
|
||||
def test_curses_select_clears_after_picker_returns(monkeypatch):
|
||||
events = []
|
||||
|
||||
def fake_radiolist(title, items, selected=0, *, cancel_returns=None):
|
||||
events.append("picker")
|
||||
return selected
|
||||
|
||||
monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", fake_radiolist)
|
||||
monkeypatch.setattr(memory_setup, "_clear_interactive_transition", lambda: events.append("clear"))
|
||||
|
||||
result = _curses_select("Pick one", [("first", "")], default=0)
|
||||
|
||||
assert result == 0
|
||||
assert events == ["picker", "clear"]
|
||||
|
||||
|
||||
def test_cmd_setup_top_level_cancel_writes_nothing(monkeypatch):
|
||||
save_config = MagicMock()
|
||||
load_config = MagicMock(side_effect=AssertionError("cancel should not load config"))
|
||||
|
||||
monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("fake", "local", object())])
|
||||
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: kwargs["cancel_returns"])
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", load_config)
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", save_config)
|
||||
|
||||
memory_setup.cmd_setup(SimpleNamespace())
|
||||
|
||||
load_config.assert_not_called()
|
||||
save_config.assert_not_called()
|
||||
|
||||
|
||||
def test_cmd_setup_builtin_selection_still_saves_builtin(monkeypatch):
|
||||
save_config = MagicMock()
|
||||
config = {"memory": {"provider": "openviking"}}
|
||||
providers = [("fake", "local", object())]
|
||||
|
||||
monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: providers)
|
||||
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: len(providers))
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", save_config)
|
||||
|
||||
memory_setup.cmd_setup(SimpleNamespace())
|
||||
|
||||
assert config["memory"]["provider"] == ""
|
||||
save_config.assert_called_once_with(config)
|
||||
|
||||
|
||||
def test_cmd_setup_clears_interactive_picker_before_provider_post_setup(monkeypatch):
|
||||
events = []
|
||||
|
||||
class PostSetupProvider:
|
||||
def post_setup(self, hermes_home, config):
|
||||
events.append("post_setup")
|
||||
|
||||
monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("openviking", "local", PostSetupProvider())])
|
||||
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: events.append("select") or 0)
|
||||
monkeypatch.setattr(memory_setup, "_clear_interactive_transition", lambda: events.append("clear"), raising=False)
|
||||
monkeypatch.setattr(memory_setup, "_install_dependencies", lambda name: events.append("install"))
|
||||
monkeypatch.setattr(memory_setup, "get_hermes_home", lambda: "/tmp/hermes-test")
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"memory": {}})
|
||||
|
||||
memory_setup.cmd_setup(SimpleNamespace())
|
||||
|
||||
assert events == ["select", "clear", "install", "post_setup"]
|
||||
|
||||
|
||||
def test_cmd_setup_provider_clears_before_provider_post_setup(monkeypatch):
|
||||
events = []
|
||||
|
||||
class PostSetupProvider:
|
||||
def post_setup(self, hermes_home, config):
|
||||
events.append("post_setup")
|
||||
|
||||
monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("openviking", "local", PostSetupProvider())])
|
||||
monkeypatch.setattr(memory_setup, "_clear_interactive_transition", lambda: events.append("clear"), raising=False)
|
||||
monkeypatch.setattr(memory_setup, "_install_dependencies", lambda name: events.append("install"))
|
||||
monkeypatch.setattr(memory_setup, "get_hermes_home", lambda: "/tmp/hermes-test")
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"memory": {}})
|
||||
|
||||
memory_setup.cmd_setup_provider("openviking")
|
||||
|
||||
assert events == ["clear", "install", "post_setup"]
|
||||
|
||||
|
||||
def test_cmd_status_prefers_provider_status_config(monkeypatch, capsys):
|
||||
class StatusProvider:
|
||||
def get_status_config(self, provider_config):
|
||||
assert provider_config["endpoint"] == "http://stale.local"
|
||||
return {
|
||||
"use_ovcli_config": True,
|
||||
"ovcli_config_path": "/tmp/ovcli.conf.VPS_ROOT",
|
||||
"endpoint": "https://vps.example",
|
||||
"account": "acct",
|
||||
"user": "alice",
|
||||
"agent": "hermes",
|
||||
}
|
||||
|
||||
def is_available(self):
|
||||
return True
|
||||
|
||||
config = {
|
||||
"memory": {
|
||||
"provider": "openviking",
|
||||
"openviking": {
|
||||
"use_ovcli_config": True,
|
||||
"ovcli_config_path": "/tmp/ovcli.conf.VPS_ROOT",
|
||||
"endpoint": "http://stale.local",
|
||||
},
|
||||
}
|
||||
}
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
|
||||
monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("openviking", "API key / local", StatusProvider())])
|
||||
|
||||
memory_setup.cmd_status(SimpleNamespace())
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "endpoint: https://vps.example" in output
|
||||
assert "http://stale.local" not in output
|
||||
|
||||
|
||||
def test_cmd_setup_generic_choice_cancel_writes_nothing(tmp_path, monkeypatch):
|
||||
class ChoiceProvider:
|
||||
def __init__(self):
|
||||
self.save_config = MagicMock()
|
||||
|
||||
def get_config_schema(self):
|
||||
return [{
|
||||
"key": "mode",
|
||||
"description": "Mode",
|
||||
"default": "one",
|
||||
"choices": ["one", "two"],
|
||||
}]
|
||||
|
||||
provider = ChoiceProvider()
|
||||
selections = iter([0, _CANCELLED])
|
||||
save_config = MagicMock()
|
||||
install_dependencies = MagicMock()
|
||||
|
||||
monkeypatch.setattr(memory_setup, "_get_available_providers", lambda: [("fake", "local", provider)])
|
||||
monkeypatch.setattr(memory_setup, "_curses_select", lambda *args, **kwargs: next(selections))
|
||||
monkeypatch.setattr(memory_setup, "_install_dependencies", install_dependencies)
|
||||
monkeypatch.setattr(memory_setup, "get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"memory": {}})
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", save_config)
|
||||
|
||||
memory_setup.cmd_setup(SimpleNamespace())
|
||||
|
||||
install_dependencies.assert_called_once_with("fake")
|
||||
save_config.assert_not_called()
|
||||
provider.save_config.assert_not_called()
|
||||
assert not (tmp_path / ".env").exists()
|
||||
|
|
@ -25,7 +25,7 @@ def test_collect_masked_input_shows_feedback_without_echoing_secret():
|
|||
value, output = _run_collect("secret\n")
|
||||
|
||||
assert value == "secret"
|
||||
assert output == "API key: ******\n"
|
||||
assert output == "API key: ******\r\n"
|
||||
assert "secret" not in output
|
||||
|
||||
|
||||
|
|
@ -33,7 +33,7 @@ def test_collect_masked_input_handles_backspace():
|
|||
value, output = _run_collect("sec\x7fret\r")
|
||||
|
||||
assert value == "seret"
|
||||
assert output == "API key: ***\b \b***\n"
|
||||
assert output == "API key: ***\b \b***\r\n"
|
||||
assert "secret" not in output
|
||||
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ def test_collect_masked_input_raises_keyboard_interrupt():
|
|||
"API key: ",
|
||||
)
|
||||
|
||||
assert "".join(output) == "API key: \n"
|
||||
assert "".join(output) == "API key: \r\n"
|
||||
|
||||
|
||||
def test_masked_secret_prompt_falls_back_to_getpass_for_non_tty(monkeypatch):
|
||||
|
|
|
|||
|
|
@ -331,3 +331,108 @@ def test_hosted_policy_locks_to_opt_data(monkeypatch):
|
|||
|
||||
assert str(policy.locked_root) == "/opt/data"
|
||||
assert policy.can_change_path is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streaming multipart upload (/api/files/upload-stream) — NS-501
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_stream_upload_roundtrip(forced_files_client):
|
||||
"""The multipart endpoint writes raw bytes to disk and reports the entry."""
|
||||
client, root = forced_files_client
|
||||
file_path = root / "out" / "backup.zip"
|
||||
payload = b"PK\x03\x04 not really a zip but binary enough \x00\x01\x02"
|
||||
|
||||
created = client.post(
|
||||
"/api/files/upload-stream",
|
||||
data={"path": str(file_path), "overwrite": "true"},
|
||||
files={"file": ("backup.zip", payload, "application/zip")},
|
||||
)
|
||||
assert created.status_code == 200, created.text
|
||||
assert created.json()["entry"]["path"] == str(file_path)
|
||||
assert created.json()["locked_root"] == str(root)
|
||||
# Bytes land verbatim — no base64 round-trip, no corruption.
|
||||
assert file_path.read_bytes() == payload
|
||||
|
||||
|
||||
def test_stream_upload_rejects_oversized_without_clobbering(forced_files_client, monkeypatch):
|
||||
"""Over-limit uploads return 413 and never overwrite an existing file.
|
||||
|
||||
The size cap is enforced while streaming (not after buffering), and the
|
||||
temp-file + atomic-rename design means a rejected upload leaves any
|
||||
pre-existing file at the target path untouched.
|
||||
"""
|
||||
client, root = forced_files_client
|
||||
file_path = root / "out" / "big.bin"
|
||||
|
||||
# Seed an existing file at the target path.
|
||||
seeded = client.post(
|
||||
"/api/files/upload-stream",
|
||||
data={"path": str(file_path), "overwrite": "true"},
|
||||
files={"file": ("big.bin", b"original-contents", "application/octet-stream")},
|
||||
)
|
||||
assert seeded.status_code == 200
|
||||
assert file_path.read_bytes() == b"original-contents"
|
||||
|
||||
# Shrink the cap so a small payload trips it deterministically.
|
||||
monkeypatch.setattr(web_server, "_MANAGED_FILE_MAX_BYTES", 8)
|
||||
rejected = client.post(
|
||||
"/api/files/upload-stream",
|
||||
data={"path": str(file_path), "overwrite": "true"},
|
||||
files={"file": ("big.bin", b"way too many bytes for the cap", "application/octet-stream")},
|
||||
)
|
||||
assert rejected.status_code == 413
|
||||
# The original file must survive a rejected overwrite.
|
||||
assert file_path.read_bytes() == b"original-contents"
|
||||
# No stray temp files left behind in the directory.
|
||||
leftovers = [p.name for p in file_path.parent.iterdir() if ".upload" in p.name]
|
||||
assert leftovers == [], f"temp upload files leaked: {leftovers}"
|
||||
|
||||
|
||||
def test_stream_upload_respects_overwrite_false(forced_files_client):
|
||||
client, root = forced_files_client
|
||||
file_path = root / "keep.txt"
|
||||
|
||||
first = client.post(
|
||||
"/api/files/upload-stream",
|
||||
data={"path": str(file_path), "overwrite": "true"},
|
||||
files={"file": ("keep.txt", b"first", "text/plain")},
|
||||
)
|
||||
assert first.status_code == 200
|
||||
|
||||
conflict = client.post(
|
||||
"/api/files/upload-stream",
|
||||
data={"path": str(file_path), "overwrite": "false"},
|
||||
files={"file": ("keep.txt", b"second", "text/plain")},
|
||||
)
|
||||
assert conflict.status_code == 409
|
||||
assert file_path.read_bytes() == b"first"
|
||||
|
||||
|
||||
def test_stream_upload_stays_under_forced_root(forced_files_client):
|
||||
"""A relative path with traversal can't escape the locked root."""
|
||||
client, root = forced_files_client
|
||||
escaped = client.post(
|
||||
"/api/files/upload-stream",
|
||||
data={"path": "../../etc/evil.txt", "overwrite": "true"},
|
||||
files={"file": ("evil.txt", b"nope", "text/plain")},
|
||||
)
|
||||
assert escaped.status_code in (400, 403)
|
||||
|
||||
|
||||
def test_stream_upload_large_file_under_cap_succeeds(forced_files_client, monkeypatch):
|
||||
"""A multi-chunk payload (larger than the 1 MiB chunk) streams correctly."""
|
||||
client, root = forced_files_client
|
||||
file_path = root / "multi-chunk.bin"
|
||||
# 2.5 MiB exercises the chunked read loop across multiple iterations.
|
||||
payload = b"x" * (2 * 1024 * 1024 + 512 * 1024)
|
||||
|
||||
created = client.post(
|
||||
"/api/files/upload-stream",
|
||||
data={"path": str(file_path), "overwrite": "true"},
|
||||
files={"file": ("multi-chunk.bin", payload, "application/octet-stream")},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
assert file_path.stat().st_size == len(payload)
|
||||
assert file_path.read_bytes() == payload
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from unittest.mock import AsyncMock, MagicMock
|
|||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.memory_setup import _CANCELLED
|
||||
from plugins.memory.hindsight import (
|
||||
HindsightMemoryProvider,
|
||||
RECALL_SCHEMA,
|
||||
|
|
@ -376,6 +377,61 @@ class TestConfig:
|
|||
|
||||
|
||||
class TestPostSetup:
|
||||
def test_setup_cancel_at_mode_picker_writes_nothing(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes-home"
|
||||
user_home = tmp_path / "user-home"
|
||||
user_home.mkdir()
|
||||
monkeypatch.setenv("HOME", str(user_home))
|
||||
monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: hermes_home)
|
||||
|
||||
save_config = MagicMock()
|
||||
which = MagicMock(return_value="/usr/bin/uv")
|
||||
run = MagicMock()
|
||||
monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: _CANCELLED)
|
||||
monkeypatch.setattr("shutil.which", which)
|
||||
monkeypatch.setattr("subprocess.run", run)
|
||||
monkeypatch.setattr("builtins.input", MagicMock(side_effect=AssertionError("prompt should not run")))
|
||||
monkeypatch.setattr("getpass.getpass", MagicMock(side_effect=AssertionError("prompt should not run")))
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", save_config)
|
||||
|
||||
provider = HindsightMemoryProvider()
|
||||
provider.post_setup(str(hermes_home), {"memory": {"provider": "builtin"}})
|
||||
|
||||
save_config.assert_not_called()
|
||||
which.assert_not_called()
|
||||
run.assert_not_called()
|
||||
assert not (hermes_home / ".env").exists()
|
||||
assert not (hermes_home / "hindsight" / "config.json").exists()
|
||||
assert not (user_home / ".hindsight" / "profiles" / "hermes.env").exists()
|
||||
|
||||
def test_local_embedded_setup_cancel_at_llm_picker_writes_nothing(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes-home"
|
||||
user_home = tmp_path / "user-home"
|
||||
user_home.mkdir()
|
||||
monkeypatch.setenv("HOME", str(user_home))
|
||||
monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: hermes_home)
|
||||
|
||||
selections = iter([1, _CANCELLED]) # local_embedded, then cancel LLM picker
|
||||
save_config = MagicMock()
|
||||
which = MagicMock(return_value="/usr/bin/uv")
|
||||
run = MagicMock()
|
||||
monkeypatch.setattr("hermes_cli.memory_setup._curses_select", lambda *args, **kwargs: next(selections))
|
||||
monkeypatch.setattr("shutil.which", which)
|
||||
monkeypatch.setattr("subprocess.run", run)
|
||||
monkeypatch.setattr("builtins.input", MagicMock(side_effect=AssertionError("prompt should not run")))
|
||||
monkeypatch.setattr("getpass.getpass", MagicMock(side_effect=AssertionError("prompt should not run")))
|
||||
monkeypatch.setattr("hermes_cli.config.save_config", save_config)
|
||||
|
||||
provider = HindsightMemoryProvider()
|
||||
provider.post_setup(str(hermes_home), {"memory": {"provider": "builtin"}})
|
||||
|
||||
save_config.assert_not_called()
|
||||
which.assert_not_called()
|
||||
run.assert_not_called()
|
||||
assert not (hermes_home / ".env").exists()
|
||||
assert not (hermes_home / "hindsight" / "config.json").exists()
|
||||
assert not (user_home / ".hindsight" / "profiles" / "hermes.env").exists()
|
||||
|
||||
def test_local_embedded_setup_materializes_profile_env(self, tmp_path, monkeypatch):
|
||||
hermes_home = tmp_path / "hermes-home"
|
||||
user_home = tmp_path / "user-home"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -90,6 +90,8 @@ def test_aiagent_forwards_user_id_alt_to_memory_provider():
|
|||
assert provider.init_kwargs["user_id"] == "open-id"
|
||||
assert provider.init_kwargs["user_id_alt"] == "union-id"
|
||||
assert provider.init_kwargs["platform"] == "feishu"
|
||||
assert "warning_callback" not in provider.init_kwargs
|
||||
assert "status_callback" not in provider.init_kwargs
|
||||
|
||||
|
||||
class CoreShadowProvider:
|
||||
|
|
@ -132,3 +134,34 @@ def test_core_tool_names_rejected_from_memory_routing_table():
|
|||
assert "clarify" not in schema_names
|
||||
assert "delegate_task" not in schema_names
|
||||
assert "honcho_search" in schema_names
|
||||
|
||||
|
||||
def test_aiagent_forwards_warning_callback_to_cli_memory_provider():
|
||||
provider = RecordingMemoryProvider()
|
||||
cfg = {"memory": {"provider": "recording"}, "agent": {}}
|
||||
|
||||
with (
|
||||
patch("hermes_cli.config.load_config", return_value=cfg),
|
||||
patch("plugins.memory.load_memory_provider", return_value=provider),
|
||||
patch("agent.model_metadata.get_model_context_length", return_value=204_800),
|
||||
patch("run_agent.get_tool_definitions", return_value=[]),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("run_agent.OpenAI"),
|
||||
):
|
||||
from run_agent import AIAgent
|
||||
|
||||
agent = AIAgent(
|
||||
api_key="test-key-1234567890",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=False,
|
||||
session_id="sess-cli",
|
||||
platform="cli",
|
||||
)
|
||||
|
||||
assert agent._memory_manager is not None
|
||||
assert provider.init_session_id == "sess-cli"
|
||||
assert provider.init_kwargs["platform"] == "cli"
|
||||
assert provider.init_kwargs["warning_callback"] == agent._emit_warning
|
||||
assert provider.init_kwargs["status_callback"] == agent._emit_status
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
|
|||
"fastapi==0.133.1",
|
||||
"uvicorn[standard]==0.41.0",
|
||||
"starlette==1.0.1", # CVE-2026-48710 (BadHost) — keep lazy-install in sync with pyproject [web]
|
||||
"python-multipart==0.0.20", # FastAPI UploadFile/Form for streaming uploads (NS-501)
|
||||
),
|
||||
# Vision image-resize recovery (Pillow). Pillow is now a CORE dependency
|
||||
# (pyproject `dependencies`), so this entry is a belt-and-suspenders fallback
|
||||
|
|
|
|||
12
uv.lock
generated
12
uv.lock
generated
|
|
@ -1445,6 +1445,7 @@ dependencies = [
|
|||
{ name = "pydantic" },
|
||||
{ name = "pyjwt", extra = ["crypto"] },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
|
|
@ -1469,6 +1470,7 @@ all = [
|
|||
{ name = "google-auth-httplib2" },
|
||||
{ name = "google-auth-oauthlib" },
|
||||
{ name = "mcp" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "simple-term-menu" },
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
|
|
@ -1598,6 +1600,7 @@ termux-all = [
|
|||
{ name = "google-auth-oauthlib" },
|
||||
{ name = "honcho-ai" },
|
||||
{ name = "mcp" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "python-telegram-bot", extra = ["webhooks"] },
|
||||
{ name = "simple-term-menu" },
|
||||
{ name = "starlette" },
|
||||
|
|
@ -1613,6 +1616,7 @@ voice = [
|
|||
]
|
||||
web = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "python-multipart" },
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
|
@ -1708,6 +1712,8 @@ requires-dist = [
|
|||
{ name = "pytest", marker = "extra == 'dev'", specifier = "==9.0.2" },
|
||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" },
|
||||
{ name = "python-dotenv", specifier = "==1.2.2" },
|
||||
{ name = "python-multipart", specifier = ">=0.0.9,<1" },
|
||||
{ name = "python-multipart", marker = "extra == 'web'", specifier = "==0.0.20" },
|
||||
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'messaging'", specifier = "==22.6" },
|
||||
{ name = "python-telegram-bot", extras = ["webhooks"], marker = "extra == 'termux'", specifier = "==22.6" },
|
||||
{ name = "pywinpty", marker = "sys_platform == 'win32'", specifier = ">=2.0.0,<3" },
|
||||
|
|
@ -3311,11 +3317,11 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.27"
|
||||
version = "0.0.20"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -411,12 +411,21 @@ export const api = {
|
|||
fetchJSON<ManagedFileReadResponse>(
|
||||
`/api/files/read?path=${encodeURIComponent(path)}`,
|
||||
),
|
||||
uploadFile: (path: string, dataUrl: string, overwrite = true) =>
|
||||
fetchJSON<ManagedFileWriteResponse>("/api/files/upload", {
|
||||
uploadFile: (path: string, file: File, overwrite = true) => {
|
||||
// Stream the raw bytes as multipart/form-data. Do NOT set Content-Type —
|
||||
// the browser adds the multipart boundary automatically. Sending the file
|
||||
// as base64 JSON (the old path) inflated the body ~33%, buffered the whole
|
||||
// file in memory, and 502'd on large backup archives behind the proxy
|
||||
// (NS-501).
|
||||
const form = new FormData();
|
||||
form.append("path", path);
|
||||
form.append("overwrite", String(overwrite));
|
||||
form.append("file", file, file.name);
|
||||
return fetchJSON<ManagedFileWriteResponse>("/api/files/upload-stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, data_url: dataUrl, overwrite }),
|
||||
}),
|
||||
body: form,
|
||||
});
|
||||
},
|
||||
createDirectory: (path: string) =>
|
||||
fetchJSON<ManagedFileWriteResponse>("/api/files/mkdir", {
|
||||
method: "POST",
|
||||
|
|
|
|||
|
|
@ -58,18 +58,6 @@ function formatBytes(size: number | null): string {
|
|||
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;
|
||||
|
|
@ -205,8 +193,7 @@ export default function FilesPage() {
|
|||
setUploading(true);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const dataUrl = await readAsDataUrl(file);
|
||||
await api.uploadFile(joinPath(activePath, file.name), dataUrl, true);
|
||||
await api.uploadFile(joinPath(activePath, file.name), file, true);
|
||||
}
|
||||
showToast(`${files.length} file${files.length === 1 ? "" : "s"} uploaded`, "success");
|
||||
await load();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue