mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-14 16:19:17 +03:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f8534fe80 | ||
|
|
20aca92311 | ||
|
|
2f64aaf21f | ||
|
|
410e5e5121 | ||
|
|
335808e525 | ||
|
|
afad96af5f | ||
|
|
fd7eb8e046 |
+2
-1
@@ -133,7 +133,8 @@ or a result you must retain.
|
|||||||
|
|
||||||
Use the workspace picker before starting project-specific work. This gives the
|
Use the workspace picker before starting project-specific work. This gives the
|
||||||
agent the right project context for file paths, shell commands, and session
|
agent the right project context for file paths, shell commands, and session
|
||||||
metadata.
|
metadata. A locally hosted WebUI opens the operating system's folder chooser
|
||||||
|
when one is available; remote deployments keep the manual absolute path entry.
|
||||||
|
|
||||||
Selecting a project does not replace the configured agent workspace. The two
|
Selecting a project does not replace the configured agent workspace. The two
|
||||||
paths have different responsibilities:
|
paths have different responsibilities:
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Shared isolation for WebSocket tests that persist runtime state."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def isolate_websocket_runtime_data(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Keep transcripts and other runtime files out of the active user data directory."""
|
||||||
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
@@ -31,6 +31,11 @@ from .ws_test_client import http_get as _http_get
|
|||||||
_PORT = 29900
|
_PORT = 29900
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_runtime_data(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
|
|
||||||
|
|
||||||
class _MatrixChannel(BaseChannel):
|
class _MatrixChannel(BaseChannel):
|
||||||
name = "matrix"
|
name = "matrix"
|
||||||
display_name = "Matrix"
|
display_name = "Matrix"
|
||||||
@@ -283,6 +288,53 @@ async def test_sessions_list_requires_bearer_token(
|
|||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sessions_list_and_thread_restore_transcript_without_canonical_file(
|
||||||
|
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
|
sm = SessionManager(tmp_path / "workspace")
|
||||||
|
from nanobot.webui.transcript import append_transcript_object
|
||||||
|
|
||||||
|
key = "websocket:restored-history"
|
||||||
|
append_transcript_object(
|
||||||
|
key,
|
||||||
|
{"event": "user", "chat_id": "restored-history", "text": "original question"},
|
||||||
|
)
|
||||||
|
append_transcript_object(
|
||||||
|
key,
|
||||||
|
{"event": "message", "chat_id": "restored-history", "text": "original answer"},
|
||||||
|
)
|
||||||
|
assert not sm._get_session_path(key).exists()
|
||||||
|
|
||||||
|
port = _free_port()
|
||||||
|
channel = _ch(bus, session_manager=sm, port=port)
|
||||||
|
server_task = asyncio.create_task(channel.start())
|
||||||
|
try:
|
||||||
|
token = channel.gateway.tokens.issue_api_token(300)
|
||||||
|
auth = {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
listing = await _http_get(f"http://127.0.0.1:{port}/api/sessions", headers=auth)
|
||||||
|
thread = await _http_get(
|
||||||
|
f"http://127.0.0.1:{port}/api/sessions/"
|
||||||
|
"websocket%3Arestored-history/webui-thread",
|
||||||
|
headers=auth,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert listing.status_code == 200
|
||||||
|
assert [row["key"] for row in listing.json()["sessions"]] == [key]
|
||||||
|
assert listing.json()["sessions"][0]["preview"] == "original question"
|
||||||
|
assert thread.status_code == 200
|
||||||
|
assert [message["content"] for message in thread.json()["messages"]] == [
|
||||||
|
"original question",
|
||||||
|
"original answer",
|
||||||
|
]
|
||||||
|
assert not sm._get_session_path(key).exists()
|
||||||
|
finally:
|
||||||
|
await channel.stop()
|
||||||
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_legacy_session_messages_route_is_not_exposed(
|
async def test_legacy_session_messages_route_is_not_exposed(
|
||||||
bus: MagicMock, tmp_path: Path
|
bus: MagicMock, tmp_path: Path
|
||||||
@@ -2267,6 +2319,40 @@ async def test_session_delete_removes_file(
|
|||||||
await server_task
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_session_delete_removes_transcript_without_canonical_file(
|
||||||
|
bus: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
|
sm = SessionManager(tmp_path / "workspace")
|
||||||
|
from nanobot.webui.transcript import append_transcript_object
|
||||||
|
|
||||||
|
key = "websocket:transcript-only"
|
||||||
|
append_transcript_object(
|
||||||
|
key,
|
||||||
|
{"event": "user", "chat_id": "transcript-only", "text": "recover me"},
|
||||||
|
)
|
||||||
|
assert not sm._get_session_path(key).exists()
|
||||||
|
webui_path = tmp_path / "webui" / f"{SessionManager.safe_key(key)}.jsonl"
|
||||||
|
assert webui_path.is_file()
|
||||||
|
|
||||||
|
channel = _ch(bus, session_manager=sm, port=_free_port())
|
||||||
|
server_task = asyncio.create_task(channel.start())
|
||||||
|
try:
|
||||||
|
response = await _webui_mutate(
|
||||||
|
channel,
|
||||||
|
"session.delete",
|
||||||
|
{"key": key},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["deleted"] is True
|
||||||
|
assert not webui_path.exists()
|
||||||
|
finally:
|
||||||
|
await channel.stop()
|
||||||
|
await server_task
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
|
async def test_webui_automations_route_lists_all_jobs_and_allows_user_actions(
|
||||||
bus: MagicMock, tmp_path: Path
|
bus: MagicMock, tmp_path: Path
|
||||||
@@ -3180,6 +3266,85 @@ async def _webui_mutate(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_workspace_folder_picker_is_local_authenticated_mutation(
|
||||||
|
bus: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
selected = tmp_path / "project"
|
||||||
|
selected.mkdir()
|
||||||
|
pick_folder = AsyncMock(return_value=str(selected))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.ws_http.native_folder_picker_available",
|
||||||
|
lambda: True,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
|
||||||
|
channel = _ch(bus)
|
||||||
|
|
||||||
|
response = await _webui_mutate(channel, "workspace.pick_folder")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"path": str(selected)}
|
||||||
|
pick_folder.assert_awaited_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_workspace_folder_picker_rejects_direct_http(
|
||||||
|
bus: MagicMock,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
pick_folder = AsyncMock(return_value="/tmp")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.ws_http.native_folder_picker_available",
|
||||||
|
lambda: True,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
|
||||||
|
channel = _ch(bus)
|
||||||
|
|
||||||
|
response = await channel.gateway.http.dispatch(
|
||||||
|
_LOCAL,
|
||||||
|
_FakeReq(
|
||||||
|
{"Host": "127.0.0.1:8765"},
|
||||||
|
path="/api/workspaces/pick-folder",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert response.status_code == 405
|
||||||
|
assert b"authenticated WebSocket" in response.body
|
||||||
|
pick_folder.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("connection", "host"),
|
||||||
|
[(_REMOTE, "127.0.0.1"), (_LOCAL, "0.0.0.0")],
|
||||||
|
)
|
||||||
|
async def test_workspace_folder_picker_rejects_nonlocal_surfaces(
|
||||||
|
bus: MagicMock,
|
||||||
|
monkeypatch,
|
||||||
|
connection: _FakeConn,
|
||||||
|
host: str,
|
||||||
|
) -> None:
|
||||||
|
pick_folder = AsyncMock(return_value="/tmp")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nanobot.webui.ws_http.native_folder_picker_available",
|
||||||
|
lambda: True,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("nanobot.webui.ws_http.pick_native_folder", pick_folder)
|
||||||
|
channel = _ch(bus, host=host, token="test-token" if host == "0.0.0.0" else "")
|
||||||
|
|
||||||
|
response = await _webui_mutate(
|
||||||
|
channel,
|
||||||
|
"workspace.pick_folder",
|
||||||
|
connection=connection,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
pick_folder.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None:
|
def test_local_browser_request_requires_loopback_host_and_forwarded_origin() -> None:
|
||||||
from nanobot.webui.http_utils import is_local_browser_request
|
from nanobot.webui.http_utils import is_local_browser_request
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Native directory picker used by a locally hosted WebUI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_PICKER_TIMEOUT_SECONDS = 300
|
||||||
|
_COMMON_ENV_KEYS = (
|
||||||
|
"HOME",
|
||||||
|
"LANG",
|
||||||
|
"LANGUAGE",
|
||||||
|
"LC_ALL",
|
||||||
|
"LC_CTYPE",
|
||||||
|
"LC_MESSAGES",
|
||||||
|
"LOGNAME",
|
||||||
|
"PATH",
|
||||||
|
"SHELL",
|
||||||
|
"TMPDIR",
|
||||||
|
"USER",
|
||||||
|
)
|
||||||
|
_LINUX_GUI_ENV_KEYS = (
|
||||||
|
"DBUS_SESSION_BUS_ADDRESS",
|
||||||
|
"DESKTOP_SESSION",
|
||||||
|
"DISPLAY",
|
||||||
|
"WAYLAND_DISPLAY",
|
||||||
|
"XAUTHORITY",
|
||||||
|
"XDG_CURRENT_DESKTOP",
|
||||||
|
"XDG_RUNTIME_DIR",
|
||||||
|
)
|
||||||
|
_MACOS_GUI_ENV_KEYS = ("SECURITYSESSIONID", "__CF_USER_TEXT_ENCODING")
|
||||||
|
_WINDOWS_GUI_ENV_KEYS = (
|
||||||
|
"APPDATA",
|
||||||
|
"COMSPEC",
|
||||||
|
"HOMEDRIVE",
|
||||||
|
"HOMEPATH",
|
||||||
|
"LOCALAPPDATA",
|
||||||
|
"PATHEXT",
|
||||||
|
"ProgramData",
|
||||||
|
"ProgramFiles",
|
||||||
|
"ProgramFiles(x86)",
|
||||||
|
"ProgramW6432",
|
||||||
|
"SESSIONNAME",
|
||||||
|
"SYSTEMROOT",
|
||||||
|
"TEMP",
|
||||||
|
"TMP",
|
||||||
|
"USERDOMAIN",
|
||||||
|
"USERNAME",
|
||||||
|
"USERPROFILE",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NativeFolderPickerError(RuntimeError):
|
||||||
|
"""Raised when an available native folder picker cannot complete."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _PickerCommand:
|
||||||
|
argv: tuple[str, ...]
|
||||||
|
cancel_codes: frozenset[int]
|
||||||
|
cancel_markers: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
def _picker_command() -> _PickerCommand | None:
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
executable = shutil.which("osascript")
|
||||||
|
if executable is None:
|
||||||
|
return None
|
||||||
|
return _PickerCommand(
|
||||||
|
argv=(
|
||||||
|
executable,
|
||||||
|
"-e",
|
||||||
|
'set selectedFolder to choose folder with prompt "Select Workspace Directory"',
|
||||||
|
"-e",
|
||||||
|
"POSIX path of selectedFolder",
|
||||||
|
),
|
||||||
|
cancel_codes=frozenset({1}),
|
||||||
|
cancel_markers=("user canceled", "(-128)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
executable = shutil.which("powershell.exe") or shutil.which("powershell")
|
||||||
|
if executable is None:
|
||||||
|
return None
|
||||||
|
script = (
|
||||||
|
"Add-Type -AssemblyName System.Windows.Forms;"
|
||||||
|
"$dialog=New-Object System.Windows.Forms.FolderBrowserDialog;"
|
||||||
|
"$dialog.Description='Select Workspace Directory';"
|
||||||
|
"$dialog.ShowNewFolderButton=$true;"
|
||||||
|
"if($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK){"
|
||||||
|
"[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new();"
|
||||||
|
"[Console]::Out.Write($dialog.SelectedPath)}"
|
||||||
|
)
|
||||||
|
return _PickerCommand(
|
||||||
|
argv=(
|
||||||
|
executable,
|
||||||
|
"-NoProfile",
|
||||||
|
"-NonInteractive",
|
||||||
|
"-STA",
|
||||||
|
"-Command",
|
||||||
|
script,
|
||||||
|
),
|
||||||
|
cancel_codes=frozenset(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
|
||||||
|
return None
|
||||||
|
zenity = shutil.which("zenity")
|
||||||
|
if zenity is not None:
|
||||||
|
return _PickerCommand(
|
||||||
|
argv=(
|
||||||
|
zenity,
|
||||||
|
"--file-selection",
|
||||||
|
"--directory",
|
||||||
|
"--title=Select Workspace Directory",
|
||||||
|
),
|
||||||
|
cancel_codes=frozenset({1}),
|
||||||
|
)
|
||||||
|
kdialog = shutil.which("kdialog")
|
||||||
|
if kdialog is not None:
|
||||||
|
return _PickerCommand(
|
||||||
|
argv=(kdialog, "--getexistingdirectory", str(Path.home())),
|
||||||
|
cancel_codes=frozenset({1}),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def native_folder_picker_available() -> bool:
|
||||||
|
"""Return whether this host can display a native directory picker."""
|
||||||
|
return _picker_command() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _picker_environment() -> dict[str, str]:
|
||||||
|
"""Pass only host UI/runtime variables, never provider or gateway secrets."""
|
||||||
|
keys: list[str] = list(_COMMON_ENV_KEYS)
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
keys.extend(_MACOS_GUI_ENV_KEYS)
|
||||||
|
elif sys.platform == "win32":
|
||||||
|
keys.extend(_WINDOWS_GUI_ENV_KEYS)
|
||||||
|
elif sys.platform.startswith("linux"):
|
||||||
|
keys.extend(_LINUX_GUI_ENV_KEYS)
|
||||||
|
return {
|
||||||
|
key: value
|
||||||
|
for key in keys
|
||||||
|
if (value := os.environ.get(key)) is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _stop_process(process: asyncio.subprocess.Process) -> None:
|
||||||
|
if process.returncode is not None:
|
||||||
|
return
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(process.wait(), timeout=2)
|
||||||
|
except TimeoutError:
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
process.kill()
|
||||||
|
await process.wait()
|
||||||
|
|
||||||
|
|
||||||
|
async def pick_native_folder() -> str | None:
|
||||||
|
"""Open the platform directory picker and return an existing absolute path."""
|
||||||
|
command = _picker_command()
|
||||||
|
if command is None:
|
||||||
|
raise NativeFolderPickerError("native folder picker is unavailable on this host")
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = await asyncio.create_subprocess_exec(
|
||||||
|
*command.argv,
|
||||||
|
env=_picker_environment(),
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE,
|
||||||
|
)
|
||||||
|
except OSError as exc:
|
||||||
|
raise NativeFolderPickerError("native folder picker failed to start") from exc
|
||||||
|
try:
|
||||||
|
stdout, stderr = await asyncio.wait_for(
|
||||||
|
process.communicate(),
|
||||||
|
timeout=_PICKER_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await _stop_process(process)
|
||||||
|
raise
|
||||||
|
except TimeoutError as exc:
|
||||||
|
await _stop_process(process)
|
||||||
|
raise NativeFolderPickerError("native folder picker timed out") from exc
|
||||||
|
|
||||||
|
error_text = stderr.decode("utf-8", errors="replace").strip()
|
||||||
|
normalized_error = error_text.lower()
|
||||||
|
if process.returncode != 0:
|
||||||
|
if process.returncode in command.cancel_codes and (
|
||||||
|
not command.cancel_markers
|
||||||
|
or any(marker in normalized_error for marker in command.cancel_markers)
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
raise NativeFolderPickerError("native folder picker failed")
|
||||||
|
|
||||||
|
selected = stdout.decode("utf-8", errors="replace").strip()
|
||||||
|
if not selected:
|
||||||
|
return None
|
||||||
|
path = Path(selected).expanduser()
|
||||||
|
if not path.is_absolute() or not path.is_dir():
|
||||||
|
raise NativeFolderPickerError("native folder picker returned an invalid directory")
|
||||||
|
return str(path)
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
"""Cache-only WebUI session list index.
|
"""Cache-only WebUI session list index.
|
||||||
|
|
||||||
The core ``SessionManager`` owns durable conversation history. This module owns
|
The core ``SessionManager`` owns model context while the WebUI transcript owns
|
||||||
the WebUI sidebar optimization so core session writes stay independent from UI
|
durable display history. The sidebar discovers both without reconstructing one
|
||||||
presentation caches.
|
store from the other, so core session writes stay independent from UI state.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
@@ -30,9 +31,12 @@ from nanobot.session.manager import (
|
|||||||
)
|
)
|
||||||
from nanobot.session.model_selection import model_preset_from_metadata
|
from nanobot.session.model_selection import model_preset_from_metadata
|
||||||
|
|
||||||
_INDEX_VERSION = 6
|
_INDEX_VERSION = 7
|
||||||
_INDEX_FILENAME = ".webui_session_index.json"
|
_INDEX_FILENAME = ".webui_session_index.json"
|
||||||
_MODEL_PRESET_FIELD = "model_preset"
|
_MODEL_PRESET_FIELD = "model_preset"
|
||||||
|
_ROW_SOURCE_FIELD = "_source"
|
||||||
|
_SESSION_SOURCE = "session"
|
||||||
|
_TRANSCRIPT_SOURCE = "webui_transcript"
|
||||||
_WORKSPACE_SCOPE_PRESENT_FIELD = "_workspace_scope_present"
|
_WORKSPACE_SCOPE_PRESENT_FIELD = "_workspace_scope_present"
|
||||||
_WORKSPACE_SCOPE_VALUE_FIELD = "_workspace_scope_value"
|
_WORKSPACE_SCOPE_VALUE_FIELD = "_workspace_scope_value"
|
||||||
WEBUI_SESSION_INDEX_INTERNAL_FIELDS = frozenset(
|
WEBUI_SESSION_INDEX_INTERNAL_FIELDS = frozenset(
|
||||||
@@ -42,7 +46,12 @@ _INDEXED_WORKSPACE_SCOPE_KEYS = ("project_path", "path", "access_mode")
|
|||||||
_MAX_INDEXED_WORKSPACE_SCOPE_BYTES = 4096
|
_MAX_INDEXED_WORKSPACE_SCOPE_BYTES = 4096
|
||||||
_WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns"
|
_WEBUI_ACTIVITY_MTIME_NS = "webui_activity_mtime_ns"
|
||||||
_WEBUI_ACTIVITY_SIZE = "webui_activity_size"
|
_WEBUI_ACTIVITY_SIZE = "webui_activity_size"
|
||||||
|
_WEBUI_ACTIVITY_FILES = "webui_activity_files"
|
||||||
_VISIBLE_TRANSCRIPT_ROLES = {"user", "assistant"}
|
_VISIBLE_TRANSCRIPT_ROLES = {"user", "assistant"}
|
||||||
|
_WEBUI_SESSION_STEM_PREFIX = SessionManager.safe_key("websocket:")
|
||||||
|
_WEBUI_CHAT_ID_RE = re.compile(r"^[A-Za-z0-9_:-]{1,64}$")
|
||||||
|
_TRANSCRIPT_SEGMENTS_SUFFIX = ".segments"
|
||||||
|
_TRANSCRIPT_NON_ANSWER_KINDS = {"progress", "reasoning", "tool_hint"}
|
||||||
|
|
||||||
|
|
||||||
def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]:
|
def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]:
|
||||||
@@ -53,41 +62,79 @@ def list_webui_sessions(session_manager: SessionManager) -> list[dict[str, Any]]
|
|||||||
_write_index_rows(session_manager.sessions_dir, rows)
|
_write_index_rows(session_manager.sessions_dir, rows)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Failed to write WebUI session list index: {}", e)
|
logger.debug("Failed to write WebUI session list index: {}", e)
|
||||||
sessions = [_public_row(session_manager.sessions_dir, row) for row in rows]
|
sessions = [
|
||||||
|
_public_row(session_manager.sessions_dir, get_webui_dir(), row)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
return sorted(sessions, key=lambda row: row.get("updated_at", ""), reverse=True)
|
return sorted(sessions, key=lambda row: row.get("updated_at", ""), reverse=True)
|
||||||
|
|
||||||
|
|
||||||
def _reconcile_index(session_manager: SessionManager) -> tuple[list[dict[str, Any]], bool]:
|
def _reconcile_index(session_manager: SessionManager) -> tuple[list[dict[str, Any]], bool]:
|
||||||
existing_rows = _read_index_rows(session_manager.sessions_dir)
|
existing_rows = _read_index_rows(session_manager.sessions_dir)
|
||||||
existing_by_file = {
|
existing_by_source = {
|
||||||
row.get("file"): row
|
(row.get(_ROW_SOURCE_FIELD), row.get("file")): row
|
||||||
for row in existing_rows or []
|
for row in existing_rows or []
|
||||||
if isinstance(row.get("file"), str)
|
if isinstance(row.get(_ROW_SOURCE_FIELD), str)
|
||||||
|
and isinstance(row.get("file"), str)
|
||||||
}
|
}
|
||||||
paths = sorted(
|
|
||||||
path
|
|
||||||
for path in session_manager.sessions_dir.glob("*.jsonl")
|
|
||||||
if SessionManager._session_key_from_path(path) is not None # pyright: ignore[reportPrivateUsage]
|
|
||||||
)
|
|
||||||
if not paths:
|
|
||||||
return [], existing_rows != []
|
|
||||||
|
|
||||||
webui_dir = get_webui_dir()
|
webui_dir = get_webui_dir()
|
||||||
|
session_paths: dict[str, Path] = {}
|
||||||
|
for path in sorted(session_manager.sessions_dir.glob("*.jsonl")):
|
||||||
|
key = SessionManager._session_key_from_path(path) # pyright: ignore[reportPrivateUsage]
|
||||||
|
if key is not None:
|
||||||
|
session_paths[key] = path
|
||||||
|
|
||||||
|
session_keys_by_stem = {
|
||||||
|
SessionManager.safe_key(key): key
|
||||||
|
for key in session_paths
|
||||||
|
if key.startswith("websocket:")
|
||||||
|
}
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
changed = existing_rows is None
|
changed = existing_rows is None
|
||||||
|
expected_sources: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
for path in paths:
|
for key, path in sorted(session_paths.items()):
|
||||||
row = existing_by_file.get(path.name)
|
identity = (_SESSION_SOURCE, path.name)
|
||||||
|
row = existing_by_source.get(identity)
|
||||||
if row is not None and _indexed_row_matches_file(row, path, webui_dir):
|
if row is not None and _indexed_row_matches_file(row, path, webui_dir):
|
||||||
rows.append(row)
|
rows.append(row)
|
||||||
|
expected_sources.add(identity)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
changed = True
|
changed = True
|
||||||
scanned = _scan_session_row(session_manager, path, webui_dir)
|
scanned = _scan_session_row(session_manager, path, webui_dir)
|
||||||
if scanned is not None:
|
if scanned is not None:
|
||||||
rows.append(scanned)
|
rows.append(scanned)
|
||||||
|
expected_sources.add(identity)
|
||||||
|
|
||||||
if set(existing_by_file) != {path.name for path in paths}:
|
for stem, paths in _webui_transcript_sources(webui_dir).items():
|
||||||
|
if stem in session_keys_by_stem:
|
||||||
|
continue
|
||||||
|
identity = (_TRANSCRIPT_SOURCE, stem)
|
||||||
|
row = existing_by_source.get(identity)
|
||||||
|
cached_key = row.get("key") if row is not None else None
|
||||||
|
key = (
|
||||||
|
cached_key
|
||||||
|
if isinstance(cached_key, str) and _valid_transcript_session_key(cached_key, stem)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if key is not None and row is not None and _indexed_transcript_row_matches(
|
||||||
|
row,
|
||||||
|
key,
|
||||||
|
webui_dir,
|
||||||
|
):
|
||||||
|
rows.append(row)
|
||||||
|
expected_sources.add(identity)
|
||||||
|
continue
|
||||||
|
|
||||||
|
changed = True
|
||||||
|
scanned = _scan_transcript_row(key, stem, paths, webui_dir)
|
||||||
|
scanned_key = scanned.get("key") if scanned is not None else None
|
||||||
|
if scanned is not None and scanned_key not in session_paths:
|
||||||
|
rows.append(scanned)
|
||||||
|
expected_sources.add(identity)
|
||||||
|
|
||||||
|
if set(existing_by_source) != expected_sources:
|
||||||
changed = True
|
changed = True
|
||||||
if existing_rows is not None and rows != existing_rows:
|
if existing_rows is not None and rows != existing_rows:
|
||||||
changed = True
|
changed = True
|
||||||
@@ -144,7 +191,7 @@ def _indexed_row_matches_file(row: dict[str, Any], path: Path, webui_dir: Path)
|
|||||||
return False
|
return False
|
||||||
if not isinstance(row.get(_WORKSPACE_SCOPE_PRESENT_FIELD), bool):
|
if not isinstance(row.get(_WORKSPACE_SCOPE_PRESENT_FIELD), bool):
|
||||||
return False
|
return False
|
||||||
if row.get("file") != path.name:
|
if row.get(_ROW_SOURCE_FIELD) != _SESSION_SOURCE or row.get("file") != path.name:
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
signature = _file_signature(path)
|
signature = _file_signature(path)
|
||||||
@@ -156,10 +203,39 @@ def _indexed_row_matches_file(row: dict[str, Any], path: Path, webui_dir: Path)
|
|||||||
and row.get("size") == signature["size"]
|
and row.get("size") == signature["size"]
|
||||||
and row.get(_WEBUI_ACTIVITY_MTIME_NS) == activity_signature[_WEBUI_ACTIVITY_MTIME_NS]
|
and row.get(_WEBUI_ACTIVITY_MTIME_NS) == activity_signature[_WEBUI_ACTIVITY_MTIME_NS]
|
||||||
and row.get(_WEBUI_ACTIVITY_SIZE) == activity_signature[_WEBUI_ACTIVITY_SIZE]
|
and row.get(_WEBUI_ACTIVITY_SIZE) == activity_signature[_WEBUI_ACTIVITY_SIZE]
|
||||||
|
and row.get(_WEBUI_ACTIVITY_FILES) == activity_signature[_WEBUI_ACTIVITY_FILES]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _public_row(sessions_dir: Path, row: dict[str, Any]) -> dict[str, Any]:
|
def _indexed_transcript_row_matches(
|
||||||
|
row: dict[str, Any],
|
||||||
|
session_key: str,
|
||||||
|
webui_dir: Path,
|
||||||
|
) -> bool:
|
||||||
|
if not all(isinstance(row.get(key), str) for key in ("key", "created_at", "updated_at")):
|
||||||
|
return False
|
||||||
|
if row.get(_ROW_SOURCE_FIELD) != _TRANSCRIPT_SOURCE:
|
||||||
|
return False
|
||||||
|
if row.get("key") != session_key or row.get("file") != SessionManager.safe_key(session_key):
|
||||||
|
return False
|
||||||
|
if not isinstance(row.get("title", ""), str) or not isinstance(row.get("preview", ""), str):
|
||||||
|
return False
|
||||||
|
if not isinstance(row.get(_WORKSPACE_SCOPE_PRESENT_FIELD), bool):
|
||||||
|
return False
|
||||||
|
signature = _webui_activity_signature(session_key, webui_dir)
|
||||||
|
return (
|
||||||
|
row.get(_WEBUI_ACTIVITY_MTIME_NS) == signature[_WEBUI_ACTIVITY_MTIME_NS]
|
||||||
|
and row.get(_WEBUI_ACTIVITY_SIZE) == signature[_WEBUI_ACTIVITY_SIZE]
|
||||||
|
and row.get(_WEBUI_ACTIVITY_FILES) == signature[_WEBUI_ACTIVITY_FILES]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _public_row(sessions_dir: Path, webui_dir: Path, row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
file = str(row.get("file", ""))
|
||||||
|
if row.get(_ROW_SOURCE_FIELD) == _TRANSCRIPT_SOURCE:
|
||||||
|
path = webui_dir / f"{file}.jsonl"
|
||||||
|
else:
|
||||||
|
path = sessions_dir / file
|
||||||
return {
|
return {
|
||||||
"key": row.get("key"),
|
"key": row.get("key"),
|
||||||
"created_at": row.get("created_at"),
|
"created_at": row.get("created_at"),
|
||||||
@@ -169,7 +245,7 @@ def _public_row(sessions_dir: Path, row: dict[str, Any]) -> dict[str, Any]:
|
|||||||
_MODEL_PRESET_FIELD: row.get(_MODEL_PRESET_FIELD),
|
_MODEL_PRESET_FIELD: row.get(_MODEL_PRESET_FIELD),
|
||||||
_WORKSPACE_SCOPE_PRESENT_FIELD: row.get(_WORKSPACE_SCOPE_PRESENT_FIELD, False),
|
_WORKSPACE_SCOPE_PRESENT_FIELD: row.get(_WORKSPACE_SCOPE_PRESENT_FIELD, False),
|
||||||
_WORKSPACE_SCOPE_VALUE_FIELD: row.get(_WORKSPACE_SCOPE_VALUE_FIELD),
|
_WORKSPACE_SCOPE_VALUE_FIELD: row.get(_WORKSPACE_SCOPE_VALUE_FIELD),
|
||||||
"path": str(sessions_dir / str(row.get("file", ""))),
|
"path": str(path),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -242,17 +318,90 @@ def _preview_from_messages(messages: list[dict[str, Any]]) -> str:
|
|||||||
return fallback_preview
|
return fallback_preview
|
||||||
|
|
||||||
|
|
||||||
|
def _webui_transcript_record_paths(stem: str, webui_dir: Path) -> tuple[Path, ...]:
|
||||||
|
paths: list[Path] = []
|
||||||
|
segments_dir = webui_dir / f"{stem}{_TRANSCRIPT_SEGMENTS_SUFFIX}"
|
||||||
|
if segments_dir.is_dir() and not segments_dir.is_symlink():
|
||||||
|
try:
|
||||||
|
paths.extend(
|
||||||
|
sorted(
|
||||||
|
path
|
||||||
|
for path in segments_dir.glob("*.jsonl")
|
||||||
|
if path.is_file() and not path.is_symlink()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
active = webui_dir / f"{stem}.jsonl"
|
||||||
|
if active.is_file() and not active.is_symlink():
|
||||||
|
paths.append(active)
|
||||||
|
return tuple(paths)
|
||||||
|
|
||||||
|
|
||||||
|
def _webui_transcript_sources(webui_dir: Path) -> dict[str, tuple[Path, ...]]:
|
||||||
|
stems: set[str] = set()
|
||||||
|
try:
|
||||||
|
entries = tuple(webui_dir.iterdir())
|
||||||
|
except OSError:
|
||||||
|
return {}
|
||||||
|
for path in entries:
|
||||||
|
if path.is_symlink():
|
||||||
|
continue
|
||||||
|
if path.is_file() and path.suffix == ".jsonl":
|
||||||
|
stem = path.stem
|
||||||
|
elif path.is_dir() and path.name.endswith(_TRANSCRIPT_SEGMENTS_SUFFIX):
|
||||||
|
stem = path.name.removesuffix(_TRANSCRIPT_SEGMENTS_SUFFIX)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if stem.startswith(_WEBUI_SESSION_STEM_PREFIX):
|
||||||
|
stems.add(stem)
|
||||||
|
return {
|
||||||
|
stem: paths
|
||||||
|
for stem in sorted(stems)
|
||||||
|
if (paths := _webui_transcript_record_paths(stem, webui_dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _transcript_record(line: str) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
value: object = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return cast(dict[str, Any], value) if isinstance(value, dict) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_transcript_session_key(key: str, stem: str) -> bool:
|
||||||
|
if not key.startswith("websocket:"):
|
||||||
|
return False
|
||||||
|
chat_id = key.split(":", 1)[1]
|
||||||
|
return _WEBUI_CHAT_ID_RE.fullmatch(chat_id) is not None and SessionManager.safe_key(key) == stem
|
||||||
|
|
||||||
|
|
||||||
def _webui_activity_paths(session_key: str, webui_dir: Path) -> list[Path]:
|
def _webui_activity_paths(session_key: str, webui_dir: Path) -> list[Path]:
|
||||||
stem = SessionManager.safe_key(session_key)
|
stem = SessionManager.safe_key(session_key)
|
||||||
return [
|
paths = [
|
||||||
webui_dir / f"{stem}.jsonl",
|
webui_dir / f"{stem}.jsonl",
|
||||||
webui_dir / f"{stem}.json",
|
webui_dir / f"{stem}.json",
|
||||||
]
|
]
|
||||||
|
segments_dir = webui_dir / f"{stem}{_TRANSCRIPT_SEGMENTS_SUFFIX}"
|
||||||
|
if segments_dir.is_dir() and not segments_dir.is_symlink():
|
||||||
|
try:
|
||||||
|
paths.extend(
|
||||||
|
sorted(
|
||||||
|
path
|
||||||
|
for path in segments_dir.iterdir()
|
||||||
|
if path.is_file() and not path.is_symlink()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return paths
|
||||||
|
|
||||||
|
|
||||||
def _webui_activity_signature(session_key: str, webui_dir: Path) -> dict[str, int]:
|
def _webui_activity_signature(session_key: str, webui_dir: Path) -> dict[str, int]:
|
||||||
latest_mtime_ns = 0
|
latest_mtime_ns = 0
|
||||||
total_size = 0
|
total_size = 0
|
||||||
|
file_count = 0
|
||||||
for path in _webui_activity_paths(session_key, webui_dir):
|
for path in _webui_activity_paths(session_key, webui_dir):
|
||||||
try:
|
try:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
@@ -260,11 +409,13 @@ def _webui_activity_signature(session_key: str, webui_dir: Path) -> dict[str, in
|
|||||||
continue
|
continue
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
continue
|
continue
|
||||||
|
file_count += 1
|
||||||
latest_mtime_ns = max(latest_mtime_ns, stat.st_mtime_ns)
|
latest_mtime_ns = max(latest_mtime_ns, stat.st_mtime_ns)
|
||||||
total_size += stat.st_size
|
total_size += stat.st_size
|
||||||
return {
|
return {
|
||||||
_WEBUI_ACTIVITY_MTIME_NS: latest_mtime_ns,
|
_WEBUI_ACTIVITY_MTIME_NS: latest_mtime_ns,
|
||||||
_WEBUI_ACTIVITY_SIZE: total_size,
|
_WEBUI_ACTIVITY_SIZE: total_size,
|
||||||
|
_WEBUI_ACTIVITY_FILES: file_count,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -333,6 +484,7 @@ def _indexed_row_for_session(session: Session, path: Path, webui_dir: Path) -> d
|
|||||||
"preview": _preview_from_messages(session.messages),
|
"preview": _preview_from_messages(session.messages),
|
||||||
_MODEL_PRESET_FIELD: model_preset_from_metadata(session.metadata),
|
_MODEL_PRESET_FIELD: model_preset_from_metadata(session.metadata),
|
||||||
**_indexed_workspace_scope_fields(session.metadata),
|
**_indexed_workspace_scope_fields(session.metadata),
|
||||||
|
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
|
||||||
"file": path.name,
|
"file": path.name,
|
||||||
"mtime_ns": signature["mtime_ns"],
|
"mtime_ns": signature["mtime_ns"],
|
||||||
"size": signature["size"],
|
"size": signature["size"],
|
||||||
@@ -340,6 +492,122 @@ def _indexed_row_for_session(session: Session, path: Path, webui_dir: Path) -> d
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _transcript_preview(record: dict[str, Any]) -> tuple[str, str]:
|
||||||
|
text = record.get("text")
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
return "", ""
|
||||||
|
preview = _message_preview_text({"content": text})
|
||||||
|
if not preview:
|
||||||
|
return "", ""
|
||||||
|
event = record.get("event")
|
||||||
|
if event == "user" or record.get("role") == "user":
|
||||||
|
return preview, ""
|
||||||
|
if (
|
||||||
|
event == "message"
|
||||||
|
and record.get("kind") not in _TRANSCRIPT_NON_ANSWER_KINDS
|
||||||
|
) or record.get("role") == "assistant":
|
||||||
|
return "", preview
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
|
||||||
|
def _transcript_created_at(record: dict[str, Any]) -> str | None:
|
||||||
|
value = record.get("created_at_ms")
|
||||||
|
if (
|
||||||
|
not isinstance(value, int | float)
|
||||||
|
or isinstance(value, bool)
|
||||||
|
or value < 0
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(value / 1000).isoformat()
|
||||||
|
except (OSError, OverflowError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _scan_transcript_row(
|
||||||
|
session_key: str | None,
|
||||||
|
stem: str,
|
||||||
|
paths: tuple[Path, ...],
|
||||||
|
webui_dir: Path,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
path_key = session_key or f"websocket:{stem.removeprefix(_WEBUI_SESSION_STEM_PREFIX)}"
|
||||||
|
signature = _webui_activity_signature(path_key, webui_dir)
|
||||||
|
activity_updated_at = _webui_activity_updated_at(signature)
|
||||||
|
if activity_updated_at is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
preview = ""
|
||||||
|
fallback_preview = ""
|
||||||
|
created_at: str | None = None
|
||||||
|
saw_record = False
|
||||||
|
scanned_records = 0
|
||||||
|
scanned_chars = 0
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as handle:
|
||||||
|
for line in handle:
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
scanned_records += 1
|
||||||
|
scanned_chars += len(line)
|
||||||
|
record = _transcript_record(line)
|
||||||
|
if record is not None:
|
||||||
|
saw_record = True
|
||||||
|
chat_id = record.get("chat_id")
|
||||||
|
if isinstance(chat_id, str) and chat_id.strip():
|
||||||
|
candidate = f"websocket:{chat_id.strip()}"
|
||||||
|
if _valid_transcript_session_key(candidate, stem):
|
||||||
|
session_key = candidate
|
||||||
|
if created_at is None:
|
||||||
|
created_at = _transcript_created_at(record)
|
||||||
|
user_preview, assistant_preview = _transcript_preview(record)
|
||||||
|
if user_preview:
|
||||||
|
preview = user_preview
|
||||||
|
break
|
||||||
|
if not fallback_preview and assistant_preview:
|
||||||
|
fallback_preview = assistant_preview
|
||||||
|
if (
|
||||||
|
scanned_records >= _SESSION_LIST_PREVIEW_MAX_RECORDS
|
||||||
|
or scanned_chars >= _SESSION_LIST_PREVIEW_MAX_CHARS
|
||||||
|
):
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if preview or (
|
||||||
|
scanned_records >= _SESSION_LIST_PREVIEW_MAX_RECORDS
|
||||||
|
or scanned_chars >= _SESSION_LIST_PREVIEW_MAX_CHARS
|
||||||
|
):
|
||||||
|
break
|
||||||
|
if not saw_record:
|
||||||
|
return None
|
||||||
|
if session_key is None:
|
||||||
|
fallback = f"websocket:{stem.removeprefix(_WEBUI_SESSION_STEM_PREFIX)}"
|
||||||
|
if not _valid_transcript_session_key(fallback, stem):
|
||||||
|
return None
|
||||||
|
session_key = fallback
|
||||||
|
|
||||||
|
if created_at is None:
|
||||||
|
try:
|
||||||
|
earliest_mtime = min(path.stat().st_mtime for path in paths)
|
||||||
|
created_at = datetime.fromtimestamp(earliest_mtime).isoformat()
|
||||||
|
except (OSError, OverflowError, ValueError):
|
||||||
|
created_at = activity_updated_at
|
||||||
|
return {
|
||||||
|
"key": session_key,
|
||||||
|
"created_at": created_at,
|
||||||
|
"updated_at": activity_updated_at,
|
||||||
|
"title": "",
|
||||||
|
"preview": preview or fallback_preview,
|
||||||
|
_MODEL_PRESET_FIELD: None,
|
||||||
|
**_indexed_workspace_scope_fields({}),
|
||||||
|
_ROW_SOURCE_FIELD: _TRANSCRIPT_SOURCE,
|
||||||
|
"file": stem,
|
||||||
|
"mtime_ns": signature[_WEBUI_ACTIVITY_MTIME_NS],
|
||||||
|
"size": signature[_WEBUI_ACTIVITY_SIZE],
|
||||||
|
**signature,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _scan_session_row(
|
def _scan_session_row(
|
||||||
session_manager: SessionManager,
|
session_manager: SessionManager,
|
||||||
path: Path,
|
path: Path,
|
||||||
@@ -418,6 +686,7 @@ def _scan_session_row(
|
|||||||
"preview": preview or fallback_preview,
|
"preview": preview or fallback_preview,
|
||||||
_MODEL_PRESET_FIELD: model_preset_from_metadata(metadata),
|
_MODEL_PRESET_FIELD: model_preset_from_metadata(metadata),
|
||||||
**_indexed_workspace_scope_fields(metadata),
|
**_indexed_workspace_scope_fields(metadata),
|
||||||
|
_ROW_SOURCE_FIELD: _SESSION_SOURCE,
|
||||||
"file": path.name,
|
"file": path.name,
|
||||||
"mtime_ns": signature["mtime_ns"],
|
"mtime_ns": signature["mtime_ns"],
|
||||||
"size": signature["size"],
|
"size": signature["size"],
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ def workspaces_payload(
|
|||||||
default_workspace: Path,
|
default_workspace: Path,
|
||||||
default_restrict_to_workspace: bool,
|
default_restrict_to_workspace: bool,
|
||||||
controls_available: bool,
|
controls_available: bool,
|
||||||
|
folder_picker_available: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
default_access_mode = read_webui_default_access_mode()
|
default_access_mode = read_webui_default_access_mode()
|
||||||
default_scope = (
|
default_scope = (
|
||||||
@@ -167,6 +168,7 @@ def workspaces_payload(
|
|||||||
"controls": {
|
"controls": {
|
||||||
"can_change_project": controls_available,
|
"can_change_project": controls_available,
|
||||||
"can_use_full_access": controls_available,
|
"can_use_full_access": controls_available,
|
||||||
|
"can_pick_folder": folder_picker_available,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,11 +243,17 @@ class WebUIWorkspaceController:
|
|||||||
cast(object, metadata_data.get(WORKSPACE_SCOPE_METADATA_KEY))
|
cast(object, metadata_data.get(WORKSPACE_SCOPE_METADATA_KEY))
|
||||||
)
|
)
|
||||||
|
|
||||||
def payload(self, *, controls_available: bool) -> dict[str, Any]:
|
def payload(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
controls_available: bool,
|
||||||
|
folder_picker_available: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
return workspaces_payload(
|
return workspaces_payload(
|
||||||
default_workspace=self._default_workspace,
|
default_workspace=self._default_workspace,
|
||||||
default_restrict_to_workspace=self._default_restrict_to_workspace,
|
default_restrict_to_workspace=self._default_restrict_to_workspace,
|
||||||
controls_available=controls_available,
|
controls_available=controls_available,
|
||||||
|
folder_picker_available=folder_picker_available,
|
||||||
)
|
)
|
||||||
|
|
||||||
def scope_from_envelope(
|
def scope_from_envelope(
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ from nanobot.webui.http_utils import (
|
|||||||
from nanobot.webui.http_utils import (
|
from nanobot.webui.http_utils import (
|
||||||
is_localhost as _is_localhost,
|
is_localhost as _is_localhost,
|
||||||
)
|
)
|
||||||
|
from nanobot.webui.http_utils import is_loopback_host as _is_loopback_host
|
||||||
from nanobot.webui.http_utils import (
|
from nanobot.webui.http_utils import (
|
||||||
is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request,
|
is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request,
|
||||||
)
|
)
|
||||||
@@ -85,6 +86,11 @@ from nanobot.webui.http_utils import (
|
|||||||
)
|
)
|
||||||
from nanobot.webui.ingress_policy import WebUIIngressPolicy
|
from nanobot.webui.ingress_policy import WebUIIngressPolicy
|
||||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||||
|
from nanobot.webui.native_folder_picker import (
|
||||||
|
NativeFolderPickerError,
|
||||||
|
native_folder_picker_available,
|
||||||
|
pick_native_folder,
|
||||||
|
)
|
||||||
from nanobot.webui.session_automations import (
|
from nanobot.webui.session_automations import (
|
||||||
all_automations_payload,
|
all_automations_payload,
|
||||||
serialize_automation_jobs,
|
serialize_automation_jobs,
|
||||||
@@ -133,6 +139,7 @@ _WEBUI_MUTATION_PATHS = {
|
|||||||
"skill.update": "/api/webui/skills/update",
|
"skill.update": "/api/webui/skills/update",
|
||||||
"skill.delete": "/api/webui/skills/delete",
|
"skill.delete": "/api/webui/skills/delete",
|
||||||
"sidebar.update": "/api/webui/sidebar-state/update",
|
"sidebar.update": "/api/webui/sidebar-state/update",
|
||||||
|
"workspace.pick_folder": "/api/workspaces/pick-folder",
|
||||||
"settings.agent.update": "/api/settings/update",
|
"settings.agent.update": "/api/settings/update",
|
||||||
"settings.model_configuration.create": "/api/settings/model-configurations/create",
|
"settings.model_configuration.create": "/api/settings/model-configurations/create",
|
||||||
"settings.model_configuration.update": "/api/settings/model-configurations/update",
|
"settings.model_configuration.update": "/api/settings/model-configurations/update",
|
||||||
@@ -329,6 +336,7 @@ class GatewayHTTPHandler:
|
|||||||
)
|
)
|
||||||
self.skill_state_action = skill_state_action
|
self.skill_state_action = skill_state_action
|
||||||
self._skill_install_lock = asyncio.Lock()
|
self._skill_install_lock = asyncio.Lock()
|
||||||
|
self._folder_picker_lock = asyncio.Lock()
|
||||||
self.cron_service = cron_service
|
self.cron_service = cron_service
|
||||||
self.local_trigger_store = local_trigger_store
|
self.local_trigger_store = local_trigger_store
|
||||||
self.cron_pending_job_ids = cron_pending_job_ids
|
self.cron_pending_job_ids = cron_pending_job_ids
|
||||||
@@ -360,6 +368,17 @@ class GatewayHTTPHandler:
|
|||||||
def workspace_controls_available(self, connection: Any) -> bool:
|
def workspace_controls_available(self, connection: Any) -> bool:
|
||||||
return self._runtime_surface == "native" or _is_localhost(connection)
|
return self._runtime_surface == "native" or _is_localhost(connection)
|
||||||
|
|
||||||
|
def workspace_folder_picker_available(
|
||||||
|
self,
|
||||||
|
connection: Any,
|
||||||
|
request: WsRequest,
|
||||||
|
) -> bool:
|
||||||
|
return (
|
||||||
|
_is_loopback_host(self.config.host)
|
||||||
|
and _is_local_browser_request(connection, request.headers)
|
||||||
|
and native_folder_picker_available()
|
||||||
|
)
|
||||||
|
|
||||||
# -- Token management ---------------------------------------------------
|
# -- Token management ---------------------------------------------------
|
||||||
|
|
||||||
def check_api_token(self, request: WsRequest) -> bool:
|
def check_api_token(self, request: WsRequest) -> bool:
|
||||||
@@ -435,6 +454,7 @@ class GatewayHTTPHandler:
|
|||||||
"/api/webui/skills/update",
|
"/api/webui/skills/update",
|
||||||
"/api/webui/skills/delete",
|
"/api/webui/skills/delete",
|
||||||
"/api/webui/sidebar-state/update",
|
"/api/webui/sidebar-state/update",
|
||||||
|
"/api/workspaces/pick-folder",
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -855,9 +875,9 @@ class GatewayHTTPHandler:
|
|||||||
self.local_trigger_store.delete(job.id)
|
self.local_trigger_store.delete(job.id)
|
||||||
elif self.cron_service is not None:
|
elif self.cron_service is not None:
|
||||||
self.cron_service.remove_job(job.id)
|
self.cron_service.remove_job(job.id)
|
||||||
deleted = self.session_manager.delete_session(decoded_key)
|
session_deleted = self.session_manager.delete_session(decoded_key)
|
||||||
delete_webui_thread(decoded_key)
|
transcript_deleted = delete_webui_thread(decoded_key)
|
||||||
return _http_json_response({"deleted": bool(deleted)})
|
return _http_json_response({"deleted": bool(session_deleted or transcript_deleted)})
|
||||||
|
|
||||||
# -- Automation routes --------------------------------------------------
|
# -- Automation routes --------------------------------------------------
|
||||||
|
|
||||||
@@ -1054,6 +1074,8 @@ class GatewayHTTPHandler:
|
|||||||
return await self._handle_sessions_list(request)
|
return await self._handle_sessions_list(request)
|
||||||
if got == "/api/commands":
|
if got == "/api/commands":
|
||||||
return self._handle_commands(request)
|
return self._handle_commands(request)
|
||||||
|
if got == "/api/workspaces/pick-folder":
|
||||||
|
return await self._handle_workspace_folder_picker(connection, request)
|
||||||
if got == "/api/workspaces":
|
if got == "/api/workspaces":
|
||||||
return self._handle_workspaces(connection, request)
|
return self._handle_workspaces(connection, request)
|
||||||
if got == "/api/webui/skills/search":
|
if got == "/api/webui/skills/search":
|
||||||
@@ -1089,10 +1111,32 @@ class GatewayHTTPHandler:
|
|||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
return _http_json_response(
|
return _http_json_response(
|
||||||
self.workspaces.payload(
|
self.workspaces.payload(
|
||||||
controls_available=self.workspace_controls_available(connection)
|
controls_available=self.workspace_controls_available(connection),
|
||||||
|
folder_picker_available=self.workspace_folder_picker_available(
|
||||||
|
connection,
|
||||||
|
request,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _handle_workspace_folder_picker(
|
||||||
|
self,
|
||||||
|
connection: Any,
|
||||||
|
request: WsRequest,
|
||||||
|
) -> Response:
|
||||||
|
if not self.check_api_token(request):
|
||||||
|
return _http_error(401, "Unauthorized")
|
||||||
|
if not self.workspace_folder_picker_available(connection, request):
|
||||||
|
return _http_error(403, "native folder picker is unavailable for this connection")
|
||||||
|
if self._folder_picker_lock.locked():
|
||||||
|
return _http_error(409, "native folder picker is already open")
|
||||||
|
try:
|
||||||
|
async with self._folder_picker_lock:
|
||||||
|
path = await pick_native_folder()
|
||||||
|
except NativeFolderPickerError as exc:
|
||||||
|
return _http_error(503, str(exc))
|
||||||
|
return _http_json_response({"path": path})
|
||||||
|
|
||||||
def _handle_webui_skills(self, request: WsRequest) -> Response:
|
def _handle_webui_skills(self, request: WsRequest) -> Response:
|
||||||
if not self.check_api_token(request):
|
if not self.check_api_token(request):
|
||||||
return _http_error(401, "Unauthorized")
|
return _http_error(401, "Unauthorized")
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.webui import native_folder_picker as picker
|
||||||
|
|
||||||
|
|
||||||
|
def _picker_command(tmp_path: Path, body: str) -> picker._PickerCommand:
|
||||||
|
script = tmp_path / "picker.py"
|
||||||
|
script.write_text(f"{body}\n", encoding="utf-8")
|
||||||
|
return picker._PickerCommand((sys.executable, str(script)), frozenset({1}))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pick_native_folder_returns_selected_directory(tmp_path, monkeypatch) -> None:
|
||||||
|
selected = tmp_path / "project"
|
||||||
|
selected.mkdir()
|
||||||
|
command = _picker_command(tmp_path, f"print({str(selected)!r}, end='')")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
picker,
|
||||||
|
"_picker_command",
|
||||||
|
lambda: command,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await picker.pick_native_folder() == str(selected)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pick_native_folder_uses_secret_free_environment(tmp_path, monkeypatch) -> None:
|
||||||
|
selected = tmp_path / "project"
|
||||||
|
selected.mkdir()
|
||||||
|
command = _picker_command(tmp_path, f"print({str(selected)!r}, end='')")
|
||||||
|
monkeypatch.setattr(picker, "_picker_command", lambda: command)
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
monkeypatch.setenv("OPENAI_API_KEY", "must-not-reach-picker")
|
||||||
|
original_spawn = picker.asyncio.create_subprocess_exec
|
||||||
|
captured_env: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def capture_spawn(*args, **kwargs):
|
||||||
|
captured_env.update(kwargs["env"])
|
||||||
|
return await original_spawn(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(picker.asyncio, "create_subprocess_exec", capture_spawn)
|
||||||
|
|
||||||
|
assert await picker.pick_native_folder() == str(selected)
|
||||||
|
assert captured_env["HOME"] == str(tmp_path)
|
||||||
|
assert "OPENAI_API_KEY" not in captured_env
|
||||||
|
|
||||||
|
|
||||||
|
def test_picker_environment_preserves_linux_display_context(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(picker.sys, "platform", "linux")
|
||||||
|
monkeypatch.setenv("DISPLAY", ":42")
|
||||||
|
monkeypatch.setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/run/user/test/bus")
|
||||||
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "must-not-reach-picker")
|
||||||
|
|
||||||
|
environment = picker._picker_environment()
|
||||||
|
|
||||||
|
assert environment["DISPLAY"] == ":42"
|
||||||
|
assert environment["DBUS_SESSION_BUS_ADDRESS"] == "unix:path=/run/user/test/bus"
|
||||||
|
assert "ANTHROPIC_API_KEY" not in environment
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pick_native_folder_maps_dialog_cancel_to_none(tmp_path, monkeypatch) -> None:
|
||||||
|
command = _picker_command(tmp_path, "raise SystemExit(1)")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
picker,
|
||||||
|
"_picker_command",
|
||||||
|
lambda: command,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert await picker.pick_native_folder() is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pick_native_folder_rejects_non_directory_result(tmp_path, monkeypatch) -> None:
|
||||||
|
missing = tmp_path / "missing"
|
||||||
|
command = _picker_command(tmp_path, f"print({str(missing)!r}, end='')")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
picker,
|
||||||
|
"_picker_command",
|
||||||
|
lambda: command,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(picker.NativeFolderPickerError, match="invalid directory"):
|
||||||
|
await picker.pick_native_folder()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pick_native_folder_reports_unavailable(monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(picker, "_picker_command", lambda: None)
|
||||||
|
|
||||||
|
assert picker.native_folder_picker_available() is False
|
||||||
|
with pytest.raises(picker.NativeFolderPickerError, match="unavailable"):
|
||||||
|
await picker.pick_native_folder()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pick_native_folder_wraps_process_start_failure(tmp_path, monkeypatch) -> None:
|
||||||
|
command = _picker_command(tmp_path, "raise AssertionError('not started')")
|
||||||
|
monkeypatch.setattr(picker, "_picker_command", lambda: command)
|
||||||
|
|
||||||
|
async def fail_spawn(*args, **kwargs):
|
||||||
|
raise OSError("executable disappeared")
|
||||||
|
|
||||||
|
monkeypatch.setattr(picker.asyncio, "create_subprocess_exec", fail_spawn)
|
||||||
|
|
||||||
|
with pytest.raises(picker.NativeFolderPickerError, match="failed to start"):
|
||||||
|
await picker.pick_native_folder()
|
||||||
@@ -72,6 +72,7 @@ def test_workspace_payload_is_config_data_dir_scoped(tmp_path, monkeypatch) -> N
|
|||||||
assert payload["default_scope"]["access_mode"] == "full"
|
assert payload["default_scope"]["access_mode"] == "full"
|
||||||
assert payload["default_access_mode"] == "default"
|
assert payload["default_access_mode"] == "default"
|
||||||
assert payload["controls"]["can_change_project"] is True
|
assert payload["controls"]["can_change_project"] is True
|
||||||
|
assert payload["controls"]["can_pick_folder"] is False
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
|
def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
|
||||||
@@ -91,6 +92,22 @@ def test_workspace_payload_hides_mutable_state_when_controls_unavailable(
|
|||||||
assert payload["default_scope"]["project_path"] == str(default.resolve())
|
assert payload["default_scope"]["project_path"] == str(default.resolve())
|
||||||
assert payload["controls"]["can_change_project"] is False
|
assert payload["controls"]["can_change_project"] is False
|
||||||
assert payload["controls"]["can_use_full_access"] is False
|
assert payload["controls"]["can_use_full_access"] is False
|
||||||
|
assert payload["controls"]["can_pick_folder"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_workspace_payload_advertises_native_folder_picker(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr("nanobot.webui.workspaces.get_webui_dir", lambda: tmp_path / "webui")
|
||||||
|
default = tmp_path / "default"
|
||||||
|
default.mkdir()
|
||||||
|
|
||||||
|
payload = workspaces_payload(
|
||||||
|
default_workspace=default,
|
||||||
|
default_restrict_to_workspace=False,
|
||||||
|
controls_available=True,
|
||||||
|
folder_picker_available=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["controls"]["can_pick_folder"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_payload_uses_webui_default_access_mode(tmp_path, monkeypatch) -> None:
|
def test_workspace_payload_uses_webui_default_access_mode(tmp_path, monkeypatch) -> None:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -17,6 +18,13 @@ from nanobot.session.manager import SessionManager
|
|||||||
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
from nanobot.session.model_selection import SESSION_MODEL_PRESET_METADATA_KEY
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _isolate_webui_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
webui_dir = tmp_path / "webui"
|
||||||
|
webui_dir.mkdir(exist_ok=True)
|
||||||
|
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_reuses_valid_index_without_scanning_files(
|
def test_webui_session_list_reuses_valid_index_without_scanning_files(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
@@ -208,6 +216,269 @@ def test_webui_session_list_drops_deleted_index_rows(tmp_path: Path) -> None:
|
|||||||
assert list_webui_sessions(manager) == []
|
assert list_webui_sessions(manager) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_session_list_recovers_transcript_without_canonical_session(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
webui_dir = tmp_path / "webui"
|
||||||
|
webui_dir.mkdir(exist_ok=True)
|
||||||
|
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
||||||
|
key = "websocket:restored"
|
||||||
|
transcript = webui_dir / f"{SessionManager.safe_key(key)}.jsonl"
|
||||||
|
transcript.write_text(
|
||||||
|
'{"event":"user","chat_id":"restored","text":"original question",'
|
||||||
|
'"created_at_ms":1785502800000}\n'
|
||||||
|
'{"event":"message","chat_id":"restored","text":"original answer",'
|
||||||
|
'"created_at_ms":1785502801000}\n'
|
||||||
|
'{"event":"turn_end","chat_id":"restored","created_at_ms":1785502802000}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
manager = SessionManager(tmp_path / "workspace")
|
||||||
|
|
||||||
|
[row] = list_webui_sessions(manager)
|
||||||
|
|
||||||
|
assert row["key"] == key
|
||||||
|
assert row["preview"] == "original question"
|
||||||
|
assert row["created_at"] == datetime.fromtimestamp(1785502800).isoformat()
|
||||||
|
assert not manager._get_session_path(key).exists()
|
||||||
|
assert manager.list_sessions() == []
|
||||||
|
|
||||||
|
reloaded = SessionManager(tmp_path / "workspace")
|
||||||
|
assert [row["key"] for row in list_webui_sessions(reloaded)] == [key]
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_session_list_recovers_colon_chat_id_from_transcript(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
webui_dir = tmp_path / "webui"
|
||||||
|
webui_dir.mkdir(exist_ok=True)
|
||||||
|
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
||||||
|
key = "websocket:scope:child"
|
||||||
|
transcript = webui_dir / f"{SessionManager.safe_key(key)}.jsonl"
|
||||||
|
transcript.write_text(
|
||||||
|
'{"event":"user","chat_id":"scope:child","text":"scoped history"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
[row] = list_webui_sessions(SessionManager(tmp_path / "workspace"))
|
||||||
|
|
||||||
|
assert row["key"] == key
|
||||||
|
assert row["preview"] == "scoped history"
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_session_list_normalizes_transcript_preview(tmp_path: Path) -> None:
|
||||||
|
key = "websocket:long-preview"
|
||||||
|
transcript = tmp_path / "webui" / f"{SessionManager.safe_key(key)}.jsonl"
|
||||||
|
transcript.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"event": "user",
|
||||||
|
"chat_id": "long-preview",
|
||||||
|
"text": "first\n\n" + "word " * 100,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
+ "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
[row] = list_webui_sessions(SessionManager(tmp_path / "workspace"))
|
||||||
|
|
||||||
|
assert row["preview"].startswith("first word")
|
||||||
|
assert "\n" not in row["preview"]
|
||||||
|
assert row["preview"].endswith("…")
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_session_list_tolerates_invalid_transcript_timestamp(
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
key = "websocket:bad-time"
|
||||||
|
transcript = tmp_path / "webui" / f"{SessionManager.safe_key(key)}.jsonl"
|
||||||
|
transcript.write_text(
|
||||||
|
'{"event":"user","chat_id":"bad-time","text":"still visible",'
|
||||||
|
'"created_at_ms":1e100}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
[row] = list_webui_sessions(SessionManager(tmp_path / "workspace"))
|
||||||
|
|
||||||
|
assert row["preview"] == "still visible"
|
||||||
|
datetime.fromisoformat(row["created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_session_list_ignores_invalid_transcript_chat_id(tmp_path: Path) -> None:
|
||||||
|
transcript = tmp_path / "webui" / "websocket_.._outside.jsonl"
|
||||||
|
transcript.write_text(
|
||||||
|
'{"event":"user","chat_id":"../outside","text":"do not expose"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert list_webui_sessions(SessionManager(tmp_path / "workspace")) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_session_list_recovers_segment_only_transcript(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
webui_dir = tmp_path / "webui"
|
||||||
|
webui_dir.mkdir(exist_ok=True)
|
||||||
|
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
||||||
|
key = "websocket:segmented"
|
||||||
|
segments = webui_dir / f"{SessionManager.safe_key(key)}.segments"
|
||||||
|
segments.mkdir()
|
||||||
|
(segments / "000001.jsonl").write_text(
|
||||||
|
'{"event":"user","chat_id":"segmented","text":"older segment"}\n'
|
||||||
|
'{"event":"turn_end","chat_id":"segmented"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
[row] = list_webui_sessions(SessionManager(tmp_path / "workspace"))
|
||||||
|
|
||||||
|
assert row["key"] == key
|
||||||
|
assert row["preview"] == "older segment"
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_session_list_prefers_canonical_metadata_without_duplicate(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
webui_dir = tmp_path / "webui"
|
||||||
|
webui_dir.mkdir(exist_ok=True)
|
||||||
|
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
||||||
|
key = "websocket:canonical"
|
||||||
|
(webui_dir / f"{SessionManager.safe_key(key)}.jsonl").write_text(
|
||||||
|
'{"event":"user","chat_id":"canonical","text":"display copy"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
manager = SessionManager(tmp_path / "workspace")
|
||||||
|
session = manager.get_or_create(key)
|
||||||
|
session.metadata["title"] = "Canonical title"
|
||||||
|
session.add_message("user", "canonical preview")
|
||||||
|
manager.save(session)
|
||||||
|
|
||||||
|
rows = list_webui_sessions(manager)
|
||||||
|
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0]["key"] == key
|
||||||
|
assert rows[0]["preview"] == "canonical preview"
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_session_list_reuses_unchanged_transcript_index(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
webui_dir = tmp_path / "webui"
|
||||||
|
webui_dir.mkdir(exist_ok=True)
|
||||||
|
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
||||||
|
key = "websocket:cached-transcript"
|
||||||
|
(webui_dir / f"{SessionManager.safe_key(key)}.jsonl").write_text(
|
||||||
|
'{"event":"user","chat_id":"cached-transcript","text":"cached"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
manager = SessionManager(tmp_path / "workspace")
|
||||||
|
assert list_webui_sessions(manager)[0]["preview"] == "cached"
|
||||||
|
|
||||||
|
def fail_scan(*args, **kwargs):
|
||||||
|
raise AssertionError("unchanged transcript should reuse its index row")
|
||||||
|
|
||||||
|
monkeypatch.setattr(session_list_index, "_scan_transcript_row", fail_scan)
|
||||||
|
|
||||||
|
assert list_webui_sessions(manager)[0]["preview"] == "cached"
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_session_list_does_not_cache_changed_transcript_with_old_signature(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
webui_dir = tmp_path / "webui"
|
||||||
|
key = "websocket:transcript-race"
|
||||||
|
transcript = webui_dir / f"{SessionManager.safe_key(key)}.jsonl"
|
||||||
|
transcript.write_text(
|
||||||
|
'{"event":"user","chat_id":"transcript-race","text":"initial"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
manager = SessionManager(tmp_path / "workspace")
|
||||||
|
assert list_webui_sessions(manager)[0]["preview"] == "initial"
|
||||||
|
transcript.write_text(
|
||||||
|
'{"event":"user","chat_id":"transcript-race","text":"first scan"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
original_open = open
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
class RacingReader(io.StringIO):
|
||||||
|
def __next__(self) -> str:
|
||||||
|
nonlocal changed
|
||||||
|
if not changed:
|
||||||
|
changed = True
|
||||||
|
transcript.write_text(
|
||||||
|
'{"event":"user","chat_id":"transcript-race","text":"second scan"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return super().__next__()
|
||||||
|
|
||||||
|
def racing_open(path, *args, **kwargs):
|
||||||
|
if Path(path) == transcript:
|
||||||
|
with original_open(path, *args, **kwargs) as source:
|
||||||
|
return RacingReader(source.read())
|
||||||
|
return original_open(path, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(session_list_index, "open", racing_open, raising=False)
|
||||||
|
|
||||||
|
first = list_webui_sessions(manager)
|
||||||
|
second = list_webui_sessions(manager)
|
||||||
|
|
||||||
|
assert first[0]["preview"] == "first scan"
|
||||||
|
assert second[0]["preview"] == "second scan"
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_session_list_drops_deleted_transcript_index_row(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
webui_dir = tmp_path / "webui"
|
||||||
|
webui_dir.mkdir(exist_ok=True)
|
||||||
|
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
||||||
|
key = "websocket:deleted-transcript"
|
||||||
|
transcript = webui_dir / f"{SessionManager.safe_key(key)}.jsonl"
|
||||||
|
transcript.write_text(
|
||||||
|
'{"event":"user","chat_id":"deleted-transcript","text":"delete me"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
manager = SessionManager(tmp_path / "workspace")
|
||||||
|
assert list_webui_sessions(manager)[0]["key"] == key
|
||||||
|
|
||||||
|
transcript.unlink()
|
||||||
|
|
||||||
|
assert list_webui_sessions(manager) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_webui_session_list_keeps_runtime_instances_isolated(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
first_dir = tmp_path / "instance-a" / "webui"
|
||||||
|
second_dir = tmp_path / "instance-b" / "webui"
|
||||||
|
first_dir.mkdir(parents=True)
|
||||||
|
second_dir.mkdir(parents=True)
|
||||||
|
(first_dir / "websocket_first.jsonl").write_text(
|
||||||
|
'{"event":"user","chat_id":"first","text":"first instance"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(second_dir / "websocket_second.jsonl").write_text(
|
||||||
|
'{"event":"user","chat_id":"second","text":"second instance"}\n',
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
manager = SessionManager(tmp_path / "workspace")
|
||||||
|
|
||||||
|
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: first_dir)
|
||||||
|
assert [row["key"] for row in list_webui_sessions(manager)] == ["websocket:first"]
|
||||||
|
|
||||||
|
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: second_dir)
|
||||||
|
assert [row["key"] for row in list_webui_sessions(manager)] == ["websocket:second"]
|
||||||
|
|
||||||
|
|
||||||
def test_webui_session_list_ignores_legacy_stem(tmp_path: Path) -> None:
|
def test_webui_session_list_ignores_legacy_stem(tmp_path: Path) -> None:
|
||||||
manager = SessionManager(tmp_path)
|
manager = SessionManager(tmp_path)
|
||||||
legacy_path = manager.sessions_dir / "websocket_legacy.jsonl"
|
legacy_path = manager.sessions_dir / "websocket_legacy.jsonl"
|
||||||
@@ -269,7 +540,7 @@ def test_webui_session_list_uses_webui_transcript_activity_for_sort(
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
webui_dir = tmp_path / "webui"
|
webui_dir = tmp_path / "webui"
|
||||||
webui_dir.mkdir()
|
webui_dir.mkdir(exist_ok=True)
|
||||||
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
||||||
|
|
||||||
manager = SessionManager(tmp_path)
|
manager = SessionManager(tmp_path)
|
||||||
@@ -311,7 +582,7 @@ def test_webui_session_list_rescans_when_transcript_changes(
|
|||||||
monkeypatch,
|
monkeypatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
webui_dir = tmp_path / "webui"
|
webui_dir = tmp_path / "webui"
|
||||||
webui_dir.mkdir()
|
webui_dir.mkdir(exist_ok=True)
|
||||||
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
monkeypatch.setattr(session_list_index, "get_webui_dir", lambda: webui_dir)
|
||||||
|
|
||||||
manager = SessionManager(tmp_path)
|
manager = SessionManager(tmp_path)
|
||||||
@@ -416,4 +687,3 @@ def test_session_manager_list_sessions_fallback_time_when_missing(tmp_path: Path
|
|||||||
assert sessions[0]["updated_at"] is not None
|
assert sessions[0]["updated_at"] is not None
|
||||||
datetime.fromisoformat(sessions[0]["created_at"])
|
datetime.fromisoformat(sessions[0]["created_at"])
|
||||||
datetime.fromisoformat(sessions[0]["updated_at"])
|
datetime.fromisoformat(sessions[0]["updated_at"])
|
||||||
|
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ interface ThreadComposerProps {
|
|||||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||||
workspaceScopeDisabled?: boolean;
|
workspaceScopeDisabled?: boolean;
|
||||||
workspaceError?: string | null;
|
workspaceError?: string | null;
|
||||||
|
onPickWorkspaceFolder?: () => Promise<string | null>;
|
||||||
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
||||||
pendingQueueKey?: string | null;
|
pendingQueueKey?: string | null;
|
||||||
transcriptionProvider?: string | null;
|
transcriptionProvider?: string | null;
|
||||||
@@ -970,6 +971,7 @@ export function ThreadComposer({
|
|||||||
workspaceControls = null,
|
workspaceControls = null,
|
||||||
workspaceScopeDisabled = false,
|
workspaceScopeDisabled = false,
|
||||||
workspaceError = null,
|
workspaceError = null,
|
||||||
|
onPickWorkspaceFolder,
|
||||||
onWorkspaceScopeChange,
|
onWorkspaceScopeChange,
|
||||||
pendingQueueKey = null,
|
pendingQueueKey = null,
|
||||||
transcriptionProvider = null,
|
transcriptionProvider = null,
|
||||||
@@ -2600,6 +2602,7 @@ export function ThreadComposer({
|
|||||||
defaultScope={workspaceDefaultScope}
|
defaultScope={workspaceDefaultScope}
|
||||||
controls={workspaceControls}
|
controls={workspaceControls}
|
||||||
error={workspaceError}
|
error={workspaceError}
|
||||||
|
onPickFolder={onPickWorkspaceFolder}
|
||||||
onChange={onWorkspaceScopeChange}
|
onChange={onWorkspaceScopeChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -661,6 +661,14 @@ export function ThreadShell({
|
|||||||
forkBoundaryMessageCount,
|
forkBoundaryMessageCount,
|
||||||
} = useSessionHistory(historyKey);
|
} = useSessionHistory(historyKey);
|
||||||
const { client, getToken, ingressLimits, modelName, token } = useClient();
|
const { client, getToken, ingressLimits, modelName, token } = useClient();
|
||||||
|
const pickWorkspaceFolder = useCallback(async (): Promise<string | null> => {
|
||||||
|
const response = await client.requestMutation<{ path: unknown }>(
|
||||||
|
"workspace.pick_folder",
|
||||||
|
{},
|
||||||
|
300_000,
|
||||||
|
);
|
||||||
|
return typeof response.path === "string" ? response.path : null;
|
||||||
|
}, [client]);
|
||||||
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
|
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
|
||||||
const [booting, setBooting] = useState(false);
|
const [booting, setBooting] = useState(false);
|
||||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||||
@@ -1458,6 +1466,9 @@ export function ThreadShell({
|
|||||||
workspaceControls={workspaceControls}
|
workspaceControls={workspaceControls}
|
||||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||||
workspaceError={workspaceError}
|
workspaceError={workspaceError}
|
||||||
|
onPickWorkspaceFolder={
|
||||||
|
workspaceControls?.can_pick_folder ? pickWorkspaceFolder : undefined
|
||||||
|
}
|
||||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||||
pendingQueueKey={temporary ? null : chatId}
|
pendingQueueKey={temporary ? null : chatId}
|
||||||
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
||||||
@@ -1503,6 +1514,9 @@ export function ThreadShell({
|
|||||||
workspaceControls={workspaceControls}
|
workspaceControls={workspaceControls}
|
||||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||||
workspaceError={workspaceError}
|
workspaceError={workspaceError}
|
||||||
|
onPickWorkspaceFolder={
|
||||||
|
workspaceControls?.can_pick_folder ? pickWorkspaceFolder : undefined
|
||||||
|
}
|
||||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||||
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
||||||
ingressLimits={ingressLimits}
|
ingressLimits={ingressLimits}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export function WorkspaceProjectPicker({
|
|||||||
defaultScope,
|
defaultScope,
|
||||||
controls,
|
controls,
|
||||||
error,
|
error,
|
||||||
|
onPickFolder,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
isHero: boolean;
|
isHero: boolean;
|
||||||
@@ -61,6 +62,7 @@ export function WorkspaceProjectPicker({
|
|||||||
defaultScope: WorkspaceScopePayload | null;
|
defaultScope: WorkspaceScopePayload | null;
|
||||||
controls: WorkspacesPayload["controls"] | null;
|
controls: WorkspacesPayload["controls"] | null;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
|
onPickFolder?: () => Promise<string | null>;
|
||||||
onChange?: (scope: WorkspaceScopePayload) => void;
|
onChange?: (scope: WorkspaceScopePayload) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -79,7 +81,7 @@ export function WorkspaceProjectPicker({
|
|||||||
&& !!defaultScope
|
&& !!defaultScope
|
||||||
&& !!onChange
|
&& !!onChange
|
||||||
&& controls?.can_change_project !== false;
|
&& controls?.can_change_project !== false;
|
||||||
const pickFolder = getRuntimeHost().pickFolder;
|
const pickFolder = getRuntimeHost().pickFolder ?? onPickFolder;
|
||||||
const nativeProjectPicker = !!pickFolder;
|
const nativeProjectPicker = !!pickFolder;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -363,6 +363,7 @@ export interface WorkspacesPayload {
|
|||||||
controls: {
|
controls: {
|
||||||
can_change_project: boolean;
|
can_change_project: boolean;
|
||||||
can_use_full_access: boolean;
|
can_use_full_access: boolean;
|
||||||
|
can_pick_folder?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1260,6 +1260,45 @@ describe("ThreadComposer", () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses the gateway folder picker for a locally hosted WebUI", async () => {
|
||||||
|
const onWorkspaceScopeChange = vi.fn();
|
||||||
|
const pickFolder = vi.fn().mockResolvedValue("/Users/test/gateway-project");
|
||||||
|
const defaultScope = {
|
||||||
|
project_path: "/Users/test/.nanobot/workspace",
|
||||||
|
project_name: "workspace",
|
||||||
|
access_mode: "full" as const,
|
||||||
|
restrict_to_workspace: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={vi.fn()}
|
||||||
|
placeholder="Ask anything..."
|
||||||
|
variant="hero"
|
||||||
|
workspaceScope={defaultScope}
|
||||||
|
workspaceDefaultScope={defaultScope}
|
||||||
|
workspaceControls={{
|
||||||
|
can_change_project: true,
|
||||||
|
can_use_full_access: true,
|
||||||
|
can_pick_folder: true,
|
||||||
|
}}
|
||||||
|
onPickWorkspaceFolder={pickFolder}
|
||||||
|
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Choose project" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(pickFolder).toHaveBeenCalled());
|
||||||
|
expect(screen.queryByLabelText("Paste path")).not.toBeInTheDocument();
|
||||||
|
expect(onWorkspaceScopeChange).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
project_path: "/Users/test/gateway-project",
|
||||||
|
project_name: "gateway-project",
|
||||||
|
access_mode: "full",
|
||||||
|
restrict_to_workspace: false,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the web path menu when no native host picker is available", async () => {
|
it("uses the web path menu when no native host picker is available", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const defaultScope = {
|
const defaultScope = {
|
||||||
|
|||||||
Reference in New Issue
Block a user