Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/server/desktop_gateway_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,8 @@ def __init__(self, websocket: WebSocket, state: DesktopServeState) -> None:
"session.list": self.session_active_list,
"session.interrupt": self.session_interrupt,
"session.clear": self.session_clear,
"session.title": self.session_title,
"session.usage": self.session_usage,
"prompt.submit": self.prompt_submit,
"approval.respond": self.approval_respond,
"permission.cycle": self.permission_cycle,
Expand Down Expand Up @@ -519,6 +521,26 @@ async def session_clear(self, params: dict[str, Any]) -> dict[str, Any]:
result = await self._session(params).control_query("clear", {})
return {"ok": (result or {}).get("ok", True) is not False}

async def session_title(self, params: dict[str, Any]) -> dict[str, Any]:
title = str(params.get("title") or params.get("name") or "").strip()
result = await self._session(params).control_query("rename", {"name": title})
name = (result or {}).get("name") if isinstance(result, dict) else None
# Also stamp the saved-session file so the sidebar row updates without a
# live turn (rename control persists the runtime session; the sidebar
# reads the file).
try:
from src.server.desktop_sessions import update_session_meta

update_session_meta(self.state.saved_sessions_dir(),
str(params.get("session_id") or ""), name=name or None)
except Exception: # noqa: BLE001 — best-effort file sync
pass
return {"title": name or title, "ok": True}

async def session_usage(self, params: dict[str, Any]) -> dict[str, Any]:
result = await self._session(params).control_query("get_context_usage", {})
return result if isinstance(result, dict) else {}

async def prompt_submit(self, params: dict[str, Any]) -> dict[str, Any]:
session = self._session(params)
text = str(params.get("text") or "")
Expand Down
42 changes: 40 additions & 2 deletions src/server/desktop_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -210,16 +210,52 @@ async def elevenlabs_voices(request: Request) -> Response:
async def session_detail(request: Request) -> Response:
if not _token_ok(state, _rest_token(request)):
return JSONResponse({"error": "unauthorized"}, status_code=401)
sid = request.path_params["session_id"]

if request.method == "DELETE":
from src.server.desktop_sessions import delete_session

ok = delete_session(state.saved_sessions_dir(), sid)
return JSONResponse({"ok": ok})

if request.method == "PATCH":
from src.server.desktop_sessions import update_session_meta

try:
body = await request.json()
except Exception: # noqa: BLE001
body = {}
fields: dict[str, Any] = {}
# The renderer sends whichever it's changing: name / archived / pinned.
if "name" in body:
fields["name"] = body["name"]
if "title" in body:
fields["name"] = body["title"]
if "archived" in body:
fields["archived"] = bool(body["archived"])
if "pinned" in body:
fields["pinned"] = bool(body["pinned"])
ok = update_session_meta(state.saved_sessions_dir(), sid, **fields) if fields else False
return JSONResponse({"ok": ok})

from src.server.desktop_sessions import load_session_messages

sid = request.path_params["session_id"]
found = load_session_messages(state.saved_sessions_dir(), sid)
if found is None:
return JSONResponse({"error": "session not found"}, status_code=404)
return JSONResponse({"session_id": found["session_id"],
"messages": found["messages"],
"message_count": found["message_count"]})

async def sessions_search(request: Request) -> Response:
if not _token_ok(state, _rest_token(request)):
return JSONResponse({"error": "unauthorized"}, status_code=401)
from src.server.desktop_sessions import search_sessions

return JSONResponse(
search_sessions(state.saved_sessions_dir(), request.query_params.get("q", ""))
)

def _sessions_slice(request: Request, *, profile_tag: str = "default") -> dict[str, Any]:
"""One filtered slice of the saved-session list (shared by the
profile-scoped route and the batched sidebar)."""
Expand Down Expand Up @@ -393,7 +429,9 @@ async def not_found(request: Request) -> Response:
Route("/api/config/schema", config_schema),
Route("/api/cron/jobs", cron_jobs),
Route("/api/audio/elevenlabs/voices", elevenlabs_voices),
Route("/api/sessions/{session_id}", session_detail),
Route("/api/sessions/search", sessions_search),
Route("/api/sessions/{session_id}", session_detail,
methods=["GET", "PATCH", "DELETE"]),
Route("/api/audio/transcribe", audio_transcribe, methods=["POST"]),
Route("/", index),
WebSocketRoute("/api/ws", gateway_ws),
Expand Down
70 changes: 69 additions & 1 deletion src/server/desktop_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,4 +133,72 @@ def load_session_messages(sessions_dir: Path, session_id: str) -> dict[str, Any]
}


__all__ = ["list_session_rows", "load_session_messages"]
def _write_session_file(path: Path, data: dict[str, Any]) -> bool:
tmp = path.with_name(path.name + ".tmp")
try:
tmp.write_text(json.dumps(data), encoding="utf-8")
tmp.replace(path)
return True
except OSError:
logger.warning("desktop: could not write %s", path, exc_info=True)
return False


def update_session_meta(sessions_dir: Path, session_id: str, **fields: Any) -> bool:
"""Patch metadata (name/archived/pinned) on a saved session file.

Only the given keys are touched; ``name=None`` clears a custom title.
Unknown session id or unreadable file → False.
"""
safe = _safe_id(session_id)
if safe is None:
return False
path = sessions_dir / f"{safe}.json"
data = _read_session_file(path)
if data is None:
return False
for key, value in fields.items():
if value is None:
data.pop(key, None)
else:
data[key] = value
return _write_session_file(path, data)


def delete_session(sessions_dir: Path, session_id: str) -> bool:
safe = _safe_id(session_id)
if safe is None:
return False
path = sessions_dir / f"{safe}.json"
try:
path.unlink()
return True
except FileNotFoundError:
return False
except OSError:
logger.warning("desktop: could not delete %s", path, exc_info=True)
return False


def search_sessions(sessions_dir: Path, query: str, *, limit: int = 40) -> dict[str, Any]:
"""Substring search over saved sessions' title/preview, newest-first."""
needle = (query or "").strip().lower()
listing = list_session_rows(sessions_dir, limit=1000)
if not needle:
return {"sessions": listing["sessions"][:limit], "query": query}
hits = [
row for row in listing["sessions"]
if needle in str(row.get("title", "")).lower()
or needle in str(row.get("preview", "")).lower()
or needle in str(row.get("id", "")).lower()
]
return {"sessions": hits[:limit], "query": query}


__all__ = [
"delete_session",
"list_session_rows",
"load_session_messages",
"search_sessions",
"update_session_meta",
]
56 changes: 56 additions & 0 deletions tests/server/test_desktop_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,62 @@ def test_session_detail_route(rest: TestClient, tmp_path: Path) -> None:
assert rest.get("/api/sessions/missing", headers=AUTH).status_code == 404


def test_session_rename_archive_delete(rest: TestClient, tmp_path: Path) -> None:
d = tmp_path / "sessions"
_write_session(d, "chat-y", preview="original", count=2)

# Rename via PATCH → the sidebar row's title updates.
assert rest.patch("/api/sessions/chat-y", headers=AUTH,
json={"name": "Renamed"}).json() == {"ok": True}
rows = rest.get("/api/sessions", headers=AUTH).json()["sessions"]
assert next(r for r in rows if r["id"] == "chat-y")["title"] == "Renamed"

# Archive flag persists on the file.
assert rest.patch("/api/sessions/chat-y", headers=AUTH,
json={"archived": True}).json() == {"ok": True}
saved = json.loads((d / "chat-y.json").read_text())
assert saved["archived"] is True
assert saved["name"] == "Renamed"

# Delete removes the file.
assert rest.request("DELETE", "/api/sessions/chat-y", headers=AUTH).json() == {"ok": True}
assert not (d / "chat-y.json").exists()
assert rest.request("DELETE", "/api/sessions/chat-y", headers=AUTH).json() == {"ok": False}


def test_session_search_route(rest: TestClient, tmp_path: Path) -> None:
d = tmp_path / "sessions"
_write_session(d, "find-me", preview="a unique needle here", count=1)
_write_session(d, "other", preview="nothing matching", count=1)

hits = rest.get("/api/sessions/search?q=unique+needle", headers=AUTH).json()
assert [s["id"] for s in hits["sessions"]] == ["find-me"]
assert hits["query"] == "unique needle"
# Empty query returns all, doesn't error.
assert len(rest.get("/api/sessions/search?q=", headers=AUTH).json()["sessions"]) == 2
# Search route wins over the {id} route (literal precedence).
assert rest.get("/api/sessions/search").status_code == 401


def test_session_mutations_refuse_traversal(rest: TestClient, tmp_path: Path) -> None:
# A traversal id is refused (either the router 404s the malformed path, or
# the handler's id-charset guard returns ok:false) — never a write.
sentinel = tmp_path / "etc"
for method in ("PATCH", "DELETE"):
res = rest.request(method, "/api/sessions/..%2Fetc", headers=AUTH,
json={"name": "x"})
assert res.status_code == 404 or res.json() == {"ok": False}
assert not sentinel.exists()


def test_session_meta_helper_refuses_bad_id(tmp_path: Path) -> None:
from src.server.desktop_sessions import delete_session, update_session_meta

assert update_session_meta(tmp_path, "../etc", name="x") is False
assert update_session_meta(tmp_path, "has/slash", name="x") is False
assert delete_session(tmp_path, "../etc") is False


def test_profile_sessions_slice_filters_sources(
rest: TestClient, tmp_path: Path
) -> None:
Expand Down
Loading