mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
feat(dashboard): add session import flow
This commit is contained in:
parent
f813c7ddad
commit
b51d365ef0
9 changed files with 622 additions and 22 deletions
|
|
@ -433,7 +433,7 @@ TIPS = [
|
|||
"hermes chat --source tool tags programmatic chats so they don't clutter hermes sessions list.",
|
||||
'hermes dump --show-keys includes redacted API key fingerprints for deeper support debugging.',
|
||||
'hermes sessions rename <ID> "new title" renames any past session; hermes sessions delete <ID> removes one.',
|
||||
'hermes import restores a session export or profile archive produced by sessions export or profile export.',
|
||||
'hermes import restores a full Hermes backup zip; session JSON/JSONL exports import from the dashboard Sessions page.',
|
||||
'hermes fallback manages the fallback_model chain interactively — no hand-editing config.yaml.',
|
||||
'hermes pairing rotates the DM pairing token — the first messager after rotation claims access to the bot.',
|
||||
'hermes setup walks first-time users through provider, keys, and platform wiring in one interactive flow.',
|
||||
|
|
|
|||
|
|
@ -9569,6 +9569,11 @@ class BulkDeleteSessions(BaseModel):
|
|||
profile: Optional[str] = None
|
||||
|
||||
|
||||
class SessionImport(BaseModel):
|
||||
sessions: List[Dict[str, Any]]
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/api/sessions/bulk-delete")
|
||||
async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
|
||||
"""Delete every session in ``body.ids`` in a single DB transaction.
|
||||
|
|
@ -9619,6 +9624,27 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
|
|||
db.close()
|
||||
|
||||
|
||||
@app.post("/api/sessions/import")
|
||||
async def import_sessions_endpoint(body: SessionImport):
|
||||
"""Import one or more sessions exported from the dashboard or CLI.
|
||||
|
||||
This is intentionally separate from ``/api/ops/import``: that endpoint
|
||||
restores a whole Hermes backup archive, while this endpoint is scoped to
|
||||
session rows/messages and is safe to use from the Sessions page.
|
||||
"""
|
||||
db = _open_session_db_for_profile(body.profile)
|
||||
try:
|
||||
result = db.import_sessions(body.sessions)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not result.get("ok", False):
|
||||
raise HTTPException(status_code=400, detail=result)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/sessions/empty/count")
|
||||
async def count_empty_sessions_endpoint(profile: Optional[str] = None):
|
||||
"""Return the number of empty, ended, non-archived sessions.
|
||||
|
|
|
|||
235
hermes_state.py
235
hermes_state.py
|
|
@ -5352,6 +5352,241 @@ class SessionDB:
|
|||
results.append({**session, "messages": messages})
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _json_text_or_none(value: Any) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
try:
|
||||
return json.dumps(value)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
|
||||
@staticmethod
|
||||
def _float_or_none(value: Any) -> Optional[float]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _int_or_default(value: Any, default: int = 0) -> int:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _reasoning_json_value(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return value
|
||||
|
||||
def import_sessions(self, sessions: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
"""Import sessions exported by :meth:`export_session` or ``export_all``.
|
||||
|
||||
Existing session IDs are skipped. Imported child sessions keep their
|
||||
parent only when that parent already exists or is included in the same
|
||||
import payload; otherwise the child is detached so partial imports don't
|
||||
fail foreign-key validation. Gateway routing, handoff, rewind, and other
|
||||
live runtime state are intentionally reset: this restores conversation
|
||||
history, not ownership of a live channel or process.
|
||||
"""
|
||||
if not isinstance(sessions, list):
|
||||
raise ValueError("sessions must be a list")
|
||||
if len(sessions) > 500:
|
||||
raise ValueError("sessions must contain at most 500 entries")
|
||||
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
errors: List[Dict[str, Any]] = []
|
||||
seen_ids: set[str] = set()
|
||||
|
||||
for index, raw in enumerate(sessions):
|
||||
if not isinstance(raw, dict):
|
||||
errors.append({"index": index, "error": "session must be an object"})
|
||||
continue
|
||||
session_id = str(raw.get("id") or "").strip()
|
||||
if not session_id:
|
||||
errors.append({"index": index, "error": "session id is required"})
|
||||
continue
|
||||
if session_id in seen_ids:
|
||||
errors.append(
|
||||
{"index": index, "session_id": session_id, "error": "duplicate session id"}
|
||||
)
|
||||
continue
|
||||
messages = raw.get("messages") or []
|
||||
if not isinstance(messages, list):
|
||||
errors.append(
|
||||
{"index": index, "session_id": session_id, "error": "messages must be a list"}
|
||||
)
|
||||
continue
|
||||
if any(not isinstance(msg, dict) for msg in messages):
|
||||
errors.append(
|
||||
{
|
||||
"index": index,
|
||||
"session_id": session_id,
|
||||
"error": "messages must contain only objects",
|
||||
}
|
||||
)
|
||||
continue
|
||||
seen_ids.add(session_id)
|
||||
normalized.append({"index": index, "session": raw, "messages": messages})
|
||||
|
||||
if errors:
|
||||
return {
|
||||
"ok": False,
|
||||
"imported": 0,
|
||||
"skipped": 0,
|
||||
"detached": 0,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
def _do(conn):
|
||||
imported_ids: List[str] = []
|
||||
skipped_ids: List[str] = []
|
||||
parent_updates: List[tuple[str, str]] = []
|
||||
detached = 0
|
||||
|
||||
for item in normalized:
|
||||
raw = item["session"]
|
||||
messages = item["messages"]
|
||||
session_id = str(raw.get("id") or "").strip()
|
||||
exists = conn.execute(
|
||||
"SELECT 1 FROM sessions WHERE id = ? LIMIT 1",
|
||||
(session_id,),
|
||||
).fetchone()
|
||||
if exists:
|
||||
skipped_ids.append(session_id)
|
||||
continue
|
||||
|
||||
started_at = self._float_or_none(raw.get("started_at"))
|
||||
if started_at is None:
|
||||
started_at = time.time()
|
||||
archived = 1 if raw.get("archived") else 0
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO sessions (
|
||||
id, source, user_id, model, model_config, system_prompt,
|
||||
parent_session_id, started_at, ended_at, end_reason,
|
||||
message_count, tool_call_count, input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_write_tokens, reasoning_tokens,
|
||||
cwd, git_branch, git_repo_root,
|
||||
billing_provider, billing_base_url, billing_mode,
|
||||
estimated_cost_usd, actual_cost_usd, cost_status, cost_source,
|
||||
pricing_version, title, api_call_count, archived
|
||||
)
|
||||
VALUES (
|
||||
:id, :source, :user_id, :model, :model_config,
|
||||
:system_prompt, NULL, :started_at, :ended_at,
|
||||
:end_reason, 0, 0, :input_tokens, :output_tokens,
|
||||
:cache_read_tokens, :cache_write_tokens,
|
||||
:reasoning_tokens, :cwd, :git_branch, :git_repo_root,
|
||||
:billing_provider, :billing_base_url, :billing_mode,
|
||||
:estimated_cost_usd, :actual_cost_usd, :cost_status,
|
||||
:cost_source, :pricing_version, :title,
|
||||
:api_call_count, :archived
|
||||
)""",
|
||||
{
|
||||
"id": session_id,
|
||||
"source": str(raw.get("source") or "import"),
|
||||
"user_id": raw.get("user_id"),
|
||||
"model": raw.get("model"),
|
||||
"model_config": self._json_text_or_none(raw.get("model_config")),
|
||||
"system_prompt": raw.get("system_prompt"),
|
||||
"started_at": started_at,
|
||||
"ended_at": self._float_or_none(raw.get("ended_at")),
|
||||
"end_reason": raw.get("end_reason"),
|
||||
"input_tokens": self._int_or_default(raw.get("input_tokens")),
|
||||
"output_tokens": self._int_or_default(raw.get("output_tokens")),
|
||||
"cache_read_tokens": self._int_or_default(
|
||||
raw.get("cache_read_tokens")
|
||||
),
|
||||
"cache_write_tokens": self._int_or_default(
|
||||
raw.get("cache_write_tokens")
|
||||
),
|
||||
"reasoning_tokens": self._int_or_default(
|
||||
raw.get("reasoning_tokens")
|
||||
),
|
||||
"cwd": raw.get("cwd"),
|
||||
"git_branch": raw.get("git_branch"),
|
||||
"git_repo_root": raw.get("git_repo_root"),
|
||||
"billing_provider": raw.get("billing_provider"),
|
||||
"billing_base_url": raw.get("billing_base_url"),
|
||||
"billing_mode": raw.get("billing_mode"),
|
||||
"estimated_cost_usd": self._float_or_none(
|
||||
raw.get("estimated_cost_usd")
|
||||
),
|
||||
"actual_cost_usd": self._float_or_none(
|
||||
raw.get("actual_cost_usd")
|
||||
),
|
||||
"cost_status": raw.get("cost_status"),
|
||||
"cost_source": raw.get("cost_source"),
|
||||
"pricing_version": raw.get("pricing_version"),
|
||||
"title": raw.get("title"),
|
||||
"api_call_count": self._int_or_default(raw.get("api_call_count")),
|
||||
"archived": archived,
|
||||
},
|
||||
)
|
||||
|
||||
sanitized_messages: List[Dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
clean = dict(msg)
|
||||
for key in (
|
||||
"reasoning_details",
|
||||
"codex_reasoning_items",
|
||||
"codex_message_items",
|
||||
):
|
||||
clean[key] = self._reasoning_json_value(clean.get(key))
|
||||
sanitized_messages.append(clean)
|
||||
|
||||
total_messages, total_tool_calls = self._insert_message_rows(
|
||||
conn,
|
||||
session_id,
|
||||
sanitized_messages,
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE sessions SET message_count = ?, tool_call_count = ? WHERE id = ?",
|
||||
(total_messages, total_tool_calls, session_id),
|
||||
)
|
||||
|
||||
parent_id = str(raw.get("parent_session_id") or "").strip()
|
||||
if parent_id and parent_id != session_id:
|
||||
parent_updates.append((session_id, parent_id))
|
||||
imported_ids.append(session_id)
|
||||
|
||||
for session_id, parent_id in parent_updates:
|
||||
parent_exists = conn.execute(
|
||||
"SELECT 1 FROM sessions WHERE id = ? LIMIT 1",
|
||||
(parent_id,),
|
||||
).fetchone()
|
||||
if parent_exists:
|
||||
conn.execute(
|
||||
"UPDATE sessions SET parent_session_id = ? WHERE id = ?",
|
||||
(parent_id, session_id),
|
||||
)
|
||||
else:
|
||||
detached += 1
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"imported": len(imported_ids),
|
||||
"skipped": len(skipped_ids),
|
||||
"detached": detached,
|
||||
"imported_ids": imported_ids,
|
||||
"skipped_ids": skipped_ids,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
return self._execute_write(_do)
|
||||
|
||||
def clear_messages(self, session_id: str) -> None:
|
||||
"""Delete all messages for a session and reset its counters."""
|
||||
def _do(conn):
|
||||
|
|
|
|||
|
|
@ -1189,6 +1189,53 @@ class TestWebServerEndpoints:
|
|||
resp = self.client.patch("/api/sessions/does-not-exist", json={"title": "x"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_import_sessions_endpoint_imports_exported_json(self):
|
||||
from hermes_state import SessionDB
|
||||
|
||||
payload = {
|
||||
"id": "imported-web-session",
|
||||
"source": "cli",
|
||||
"title": "Imported from dashboard",
|
||||
"started_at": 100.0,
|
||||
"ended_at": 110.0,
|
||||
"end_reason": "complete",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hello", "timestamp": 101.0},
|
||||
{"role": "assistant", "content": "hi", "timestamp": 102.0},
|
||||
],
|
||||
}
|
||||
|
||||
resp = self.client.post("/api/sessions/import", json={"sessions": [payload]})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["imported"] == 1
|
||||
assert data["skipped"] == 0
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
session = db.get_session("imported-web-session")
|
||||
assert session["title"] == "Imported from dashboard"
|
||||
assert session["message_count"] == 2
|
||||
assert [m["content"] for m in db.get_messages("imported-web-session")] == [
|
||||
"hello",
|
||||
"hi",
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
duplicate = self.client.post("/api/sessions/import", json={"sessions": [payload]})
|
||||
assert duplicate.status_code == 200
|
||||
assert duplicate.json()["skipped_ids"] == ["imported-web-session"]
|
||||
|
||||
invalid = self.client.post(
|
||||
"/api/sessions/import",
|
||||
json={"sessions": [{"source": "cli", "messages": []}]},
|
||||
)
|
||||
assert invalid.status_code == 400
|
||||
assert invalid.json()["detail"]["errors"] == [
|
||||
{"index": 0, "error": "session id is required"}
|
||||
]
|
||||
|
||||
def test_archive_session_via_patch(self):
|
||||
"""PATCH archived=true soft-hides a session; archived=false restores it."""
|
||||
from hermes_state import SessionDB
|
||||
|
|
|
|||
|
|
@ -2168,6 +2168,111 @@ class TestDeleteAndExport:
|
|||
assert len(exports) == 1
|
||||
assert exports[0]["source"] == "cli"
|
||||
|
||||
def test_import_exported_session_round_trips(self, db, tmp_path):
|
||||
db.create_session(
|
||||
session_id="s1",
|
||||
source="cli",
|
||||
model="test-model",
|
||||
model_config={"temperature": 0.2},
|
||||
user_id="user-1",
|
||||
cwd="/workspace",
|
||||
)
|
||||
db.set_session_title("s1", "Imported session")
|
||||
db.update_session_cwd(
|
||||
"s1",
|
||||
"/workspace/project",
|
||||
git_branch="feature/import",
|
||||
git_repo_root="/workspace/project",
|
||||
)
|
||||
db.append_message("s1", role="user", content="Hello", timestamp=10)
|
||||
db.append_message(
|
||||
"s1",
|
||||
role="assistant",
|
||||
content="Hi",
|
||||
timestamp=11,
|
||||
tool_calls=[{"id": "call-1", "function": {"name": "noop"}}],
|
||||
reasoning_details=[{"type": "summary", "text": "short"}],
|
||||
)
|
||||
db.end_session("s1", "complete")
|
||||
|
||||
exported = db.export_session("s1")
|
||||
exported["handoff_state"] = "active"
|
||||
exported["handoff_platform"] = "telegram"
|
||||
exported["handoff_error"] = "stale runtime state"
|
||||
exported["rewind_count"] = 3
|
||||
target = SessionDB(db_path=tmp_path / "target_state.db")
|
||||
try:
|
||||
result = target.import_sessions([exported])
|
||||
assert result["ok"] is True
|
||||
assert result["imported"] == 1
|
||||
assert result["skipped"] == 0
|
||||
|
||||
imported = target.get_session("s1")
|
||||
assert imported["title"] == "Imported session"
|
||||
assert imported["source"] == "cli"
|
||||
assert imported["model"] == "test-model"
|
||||
assert imported["cwd"] == "/workspace/project"
|
||||
assert imported["git_branch"] == "feature/import"
|
||||
assert imported["git_repo_root"] == "/workspace/project"
|
||||
assert imported["message_count"] == 2
|
||||
assert imported["tool_call_count"] == 1
|
||||
assert imported["handoff_state"] is None
|
||||
assert imported["handoff_platform"] is None
|
||||
assert imported["handoff_error"] is None
|
||||
assert imported["rewind_count"] == 0
|
||||
|
||||
messages = target.get_messages("s1")
|
||||
assert [m["role"] for m in messages] == ["user", "assistant"]
|
||||
assert messages[0]["content"] == "Hello"
|
||||
assert messages[1]["tool_calls"][0]["id"] == "call-1"
|
||||
|
||||
duplicate = target.import_sessions([exported])
|
||||
assert duplicate["imported"] == 0
|
||||
assert duplicate["skipped"] == 1
|
||||
assert duplicate["skipped_ids"] == ["s1"]
|
||||
finally:
|
||||
target.close()
|
||||
|
||||
def test_import_sessions_restores_valid_parents_and_detaches_missing(self, db):
|
||||
result = db.import_sessions(
|
||||
[
|
||||
{
|
||||
"id": "child",
|
||||
"source": "cli",
|
||||
"parent_session_id": "parent",
|
||||
"messages": [],
|
||||
},
|
||||
{"id": "parent", "source": "cli", "messages": []},
|
||||
{
|
||||
"id": "orphan",
|
||||
"source": "cli",
|
||||
"parent_session_id": "missing",
|
||||
"messages": [],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert result["imported"] == 3
|
||||
assert result["detached"] == 1
|
||||
assert db.get_session("child")["parent_session_id"] == "parent"
|
||||
assert db.get_session("orphan")["parent_session_id"] is None
|
||||
|
||||
def test_import_sessions_rejects_invalid_batch_atomically(self, db):
|
||||
result = db.import_sessions(
|
||||
[
|
||||
{"id": "valid", "source": "cli", "messages": []},
|
||||
{"source": "cli", "messages": []},
|
||||
]
|
||||
)
|
||||
|
||||
assert result["ok"] is False
|
||||
assert result["imported"] == 0
|
||||
assert result["errors"] == [
|
||||
{"index": 1, "error": "session id is required"}
|
||||
]
|
||||
assert db.get_session("valid") is None
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Prune
|
||||
|
|
|
|||
|
|
@ -403,6 +403,15 @@ export const api = {
|
|||
fetchJSON<SessionStoreStats>(appendProfileParam("/api/sessions/stats", profile)),
|
||||
exportSessionUrl: (id: string, profile = getManagementProfile()) =>
|
||||
appendProfileParam(`/api/sessions/${encodeURIComponent(id)}/export`, profile),
|
||||
importSessions: (
|
||||
sessions: Array<Record<string, unknown>>,
|
||||
profile = getManagementProfile(),
|
||||
) =>
|
||||
fetchJSON<SessionImportResponse>("/api/sessions/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessions, profile: profile || undefined }),
|
||||
}),
|
||||
pruneSessions: (
|
||||
older_than_days: number,
|
||||
source?: string,
|
||||
|
|
@ -1298,6 +1307,16 @@ export interface SessionStoreStats {
|
|||
by_source: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface SessionImportResponse {
|
||||
ok: boolean;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
detached: number;
|
||||
imported_ids: string[];
|
||||
skipped_ids: string[];
|
||||
errors: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface SkillHubResult {
|
||||
name: string;
|
||||
description: string;
|
||||
|
|
|
|||
47
web/src/lib/session-import.test.ts
Normal file
47
web/src/lib/session-import.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { importSummary, parseImportSessions } from "./session-import";
|
||||
|
||||
describe("parseImportSessions", () => {
|
||||
it("accepts a single exported session", () => {
|
||||
expect(parseImportSessions('{"id":"session-1","messages":[]}')).toEqual([
|
||||
{ id: "session-1", messages: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accepts arrays and wrapped session exports", () => {
|
||||
const sessions = [{ id: "one" }, { id: "two" }];
|
||||
expect(parseImportSessions(JSON.stringify(sessions))).toEqual(sessions);
|
||||
expect(parseImportSessions(JSON.stringify({ sessions }))).toEqual(sessions);
|
||||
});
|
||||
|
||||
it("accepts JSONL session exports", () => {
|
||||
expect(parseImportSessions('{"id":"one"}\n\n{"id":"two"}\n')).toEqual([
|
||||
{ id: "one" },
|
||||
{ id: "two" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects empty files and non-object entries", () => {
|
||||
expect(() => parseImportSessions(" \n")).toThrow("File is empty");
|
||||
expect(() => parseImportSessions('[{"id":"one"},42]')).toThrow(
|
||||
"Expected exported session JSON or JSONL",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("importSummary", () => {
|
||||
it("includes skipped and detached counts only when present", () => {
|
||||
expect(
|
||||
importSummary({
|
||||
ok: true,
|
||||
imported: 2,
|
||||
skipped: 1,
|
||||
detached: 1,
|
||||
imported_ids: ["one", "two"],
|
||||
skipped_ids: ["existing"],
|
||||
errors: [],
|
||||
}),
|
||||
).toBe("2 imported; 1 skipped; 1 detached from missing parents");
|
||||
});
|
||||
});
|
||||
46
web/src/lib/session-import.ts
Normal file
46
web/src/lib/session-import.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import type { SessionImportResponse } from "@/lib/api";
|
||||
|
||||
export type ImportableSession = Record<string, unknown>;
|
||||
|
||||
function normalizeImportSessions(value: unknown): ImportableSession[] {
|
||||
const candidate =
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Array.isArray((value as { sessions?: unknown }).sessions)
|
||||
? (value as { sessions: unknown[] }).sessions
|
||||
: Array.isArray(value)
|
||||
? value
|
||||
: [value];
|
||||
|
||||
const sessions = candidate.filter(
|
||||
(item): item is ImportableSession =>
|
||||
!!item && typeof item === "object" && !Array.isArray(item),
|
||||
);
|
||||
if (sessions.length !== candidate.length) {
|
||||
throw new Error("Expected exported session JSON or JSONL");
|
||||
}
|
||||
return sessions;
|
||||
}
|
||||
|
||||
export function parseImportSessions(text: string): ImportableSession[] {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) throw new Error("File is empty");
|
||||
|
||||
try {
|
||||
return normalizeImportSessions(JSON.parse(trimmed));
|
||||
} catch (jsonError) {
|
||||
const lines = trimmed.split(/\r?\n/).filter((line) => line.trim());
|
||||
if (lines.length <= 1) throw jsonError;
|
||||
return normalizeImportSessions(lines.map((line) => JSON.parse(line)));
|
||||
}
|
||||
}
|
||||
|
||||
export function importSummary(result: SessionImportResponse): string {
|
||||
const parts = [`${result.imported} imported`];
|
||||
if (result.skipped > 0) parts.push(`${result.skipped} skipped`);
|
||||
if (result.detached > 0) {
|
||||
parts.push(`${result.detached} detached from missing parents`);
|
||||
}
|
||||
return parts.join("; ");
|
||||
}
|
||||
|
|
@ -25,12 +25,17 @@ import {
|
|||
Play,
|
||||
Eraser,
|
||||
Download,
|
||||
Upload,
|
||||
Pencil,
|
||||
Check,
|
||||
Archive,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { shouldRefreshSessions } from "@/lib/session-refresh";
|
||||
import {
|
||||
importSummary,
|
||||
parseImportSessions,
|
||||
} from "@/lib/session-import";
|
||||
import type {
|
||||
SessionInfo,
|
||||
SessionMessage,
|
||||
|
|
@ -387,7 +392,6 @@ function SessionRow({
|
|||
resumeInChatEnabled,
|
||||
}: SessionRowProps) {
|
||||
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState(session.title ?? "");
|
||||
|
|
@ -396,15 +400,20 @@ function SessionRow({
|
|||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && messages === null && !loading) {
|
||||
setLoading(true);
|
||||
api
|
||||
.getSessionMessages(session.id)
|
||||
.then((resp) => setMessages(resp.messages))
|
||||
.catch((err) => setError(String(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
}, [isExpanded, session.id, messages, loading]);
|
||||
if (!isExpanded || messages !== null) return;
|
||||
let cancelled = false;
|
||||
api
|
||||
.getSessionMessages(session.id)
|
||||
.then((resp) => {
|
||||
if (!cancelled) setMessages(resp.messages);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(String(err));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isExpanded, session.id, messages]);
|
||||
|
||||
const sourceInfo = (session.source
|
||||
? SOURCE_CONFIG[session.source]
|
||||
|
|
@ -639,7 +648,7 @@ function SessionRow({
|
|||
|
||||
{isExpanded && (
|
||||
<div className="min-w-0 border-t border-border bg-background/50 p-4">
|
||||
{loading && (
|
||||
{messages === null && !error && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="text-xl text-primary" />
|
||||
</div>
|
||||
|
|
@ -725,6 +734,7 @@ export default function SessionsPage() {
|
|||
>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const logScrollRef = useRef<HTMLPreElement | null>(null);
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null);
|
||||
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
|
||||
|
|
@ -757,6 +767,7 @@ export default function SessionsPage() {
|
|||
const [pruneOpen, setPruneOpen] = useState(false);
|
||||
const [pruneDays, setPruneDays] = useState("90");
|
||||
const [pruning, setPruning] = useState(false);
|
||||
const [importingSessions, setImportingSessions] = useState(false);
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { setAfterTitle, setEnd } = usePageHeader();
|
||||
|
|
@ -831,6 +842,37 @@ export default function SessionsPage() {
|
|||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleImportSessions = useCallback(
|
||||
async (files: FileList | null) => {
|
||||
const file = files?.[0];
|
||||
if (!file) return;
|
||||
setImportingSessions(true);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const importedSessions = parseImportSessions(text);
|
||||
const result = await api.importSessions(importedSessions);
|
||||
showToast(`Import complete: ${importSummary(result)}`, "success");
|
||||
clearSelection();
|
||||
loadSessions(page, true);
|
||||
loadStats();
|
||||
refreshEmptyCount();
|
||||
} catch (error) {
|
||||
showToast(`Import failed: ${error}`, "error");
|
||||
} finally {
|
||||
setImportingSessions(false);
|
||||
if (importInputRef.current) importInputRef.current.value = "";
|
||||
}
|
||||
},
|
||||
[
|
||||
clearSelection,
|
||||
loadSessions,
|
||||
loadStats,
|
||||
page,
|
||||
refreshEmptyCount,
|
||||
showToast,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, [loadStats]);
|
||||
|
|
@ -842,11 +884,21 @@ export default function SessionsPage() {
|
|||
// baseline without triggering a redundant reload (mount already loads).
|
||||
const newestSeenRef = useRef<string | null>(null);
|
||||
const pageRef = useRef(page);
|
||||
pageRef.current = page;
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions(page);
|
||||
refreshEmptyCount();
|
||||
pageRef.current = page;
|
||||
}, [page]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
loadSessions(page);
|
||||
refreshEmptyCount();
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadSessions, page, refreshEmptyCount]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -902,6 +954,7 @@ export default function SessionsPage() {
|
|||
const updateSearch = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
if (value.trim()) setView("list");
|
||||
clearSelection();
|
||||
},
|
||||
[clearSelection],
|
||||
|
|
@ -919,13 +972,15 @@ export default function SessionsPage() {
|
|||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
if (!search.trim()) {
|
||||
setSearchResults(null);
|
||||
setSearching(false);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setSearchResults(null);
|
||||
setSearching(false);
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setSearching(true);
|
||||
api
|
||||
.searchSessions(search.trim())
|
||||
.then((resp) => setSearchResults(resp.results))
|
||||
|
|
@ -1210,10 +1265,6 @@ export default function SessionsPage() {
|
|||
const showList = view === "list" || isSearching || !showOverviewTab;
|
||||
const showPagination = showList && !searchResults && total > PAGE_SIZE;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSearching) setView("list");
|
||||
}, [isSearching]);
|
||||
|
||||
const alerts: { message: string; detail?: string }[] = [];
|
||||
if (status) {
|
||||
if (status.gateway_state === "startup_failed") {
|
||||
|
|
@ -1249,6 +1300,13 @@ export default function SessionsPage() {
|
|||
<div className="flex min-w-0 w-full max-w-full flex-col gap-4">
|
||||
<PluginSlot name="sessions:top" />
|
||||
<Toast toast={toast} />
|
||||
<input
|
||||
ref={importInputRef}
|
||||
type="file"
|
||||
accept=".json,.jsonl,application/json,application/x-ndjson"
|
||||
className="hidden"
|
||||
onChange={(event) => void handleImportSessions(event.currentTarget.files)}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={sessionDelete.isOpen}
|
||||
|
|
@ -1527,6 +1585,23 @@ export default function SessionsPage() {
|
|||
</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isSearching && (
|
||||
<Button
|
||||
outlined
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
disabled={importingSessions}
|
||||
onClick={() => importInputRef.current?.click()}
|
||||
aria-label="Import exported sessions"
|
||||
title="Import exported session JSON or JSONL"
|
||||
prefix={importingSessions ? <Spinner /> : <Upload />}
|
||||
>
|
||||
<span className="font-mondwest normal-case text-xs">
|
||||
Import sessions
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPagination && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue