e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
147 lines
5.3 KiB
Python
147 lines
5.3 KiB
Python
"""
|
|
Import chat histories from external coding CLIs (Claude Code, Codex) into the
|
|
user's learning space as normal, re-openable sessions.
|
|
|
|
Reading the user's local ``~/.claude`` / ``~/.codex`` happens in the browser
|
|
(File System Access API) — those files live on the user's machine, not the
|
|
server. The browser normalizes each conversation to the small JSON shape below
|
|
and POSTs it here; this router only validates and persists. Imported sessions
|
|
share the session tables with native chats (so the chat loop can re-open and
|
|
continue them) but carry an ``imported_`` id prefix that keeps them in their
|
|
own Space category. Re-importing the same folder is idempotent (dedup by id).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
from deeptutor.services.session import (
|
|
get_sqlite_session_store,
|
|
make_imported_session_id,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter()
|
|
|
|
# Browser adapters only emit these; reject anything else so a malformed payload
|
|
# can never seed an unsupported provider category.
|
|
_ALLOWED_SOURCES = {"claude_code", "codex"}
|
|
|
|
# Defensive ceilings — a single import request should never grow unbounded.
|
|
_MAX_SESSIONS_PER_REQUEST = 1000
|
|
_MAX_MESSAGES_PER_SESSION = 10000
|
|
|
|
|
|
class ImportedMessage(BaseModel):
|
|
role: str = Field(..., pattern="^(user|assistant)$")
|
|
content: str = ""
|
|
created_at: float | None = None
|
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class ImportedSession(BaseModel):
|
|
external_id: str = Field(..., min_length=1, max_length=256)
|
|
title: str = ""
|
|
source_cwd: str = ""
|
|
created_at: float
|
|
updated_at: float
|
|
messages: list[ImportedMessage] = Field(default_factory=list)
|
|
|
|
|
|
class ChatHistoryImportRequest(BaseModel):
|
|
source: str = Field(..., min_length=1)
|
|
# All sessions in one request belong to one agent (a named, scoped slice of
|
|
# a source folder). Empty when the client hasn't adopted the agent model yet
|
|
# — the backend stays backwards-compatible by simply omitting attribution.
|
|
agent_id: str = Field(default="", max_length=256)
|
|
agent_name: str = Field(default="", max_length=256)
|
|
sessions: list[ImportedSession] = Field(default_factory=list)
|
|
|
|
@field_validator("source")
|
|
@classmethod
|
|
def _normalize_source(cls, value: str) -> str:
|
|
normalized = (value or "").strip().lower()
|
|
if normalized not in _ALLOWED_SOURCES:
|
|
raise ValueError(f"Unsupported import source: {value!r}")
|
|
return normalized
|
|
|
|
|
|
@router.post("/chat-history")
|
|
async def import_chat_history(payload: ChatHistoryImportRequest) -> dict[str, Any]:
|
|
if not payload.sessions:
|
|
raise HTTPException(status_code=400, detail="No sessions to import")
|
|
if len(payload.sessions) > _MAX_SESSIONS_PER_REQUEST:
|
|
raise HTTPException(
|
|
status_code=413,
|
|
detail=f"Too many sessions in one request (max {_MAX_SESSIONS_PER_REQUEST})",
|
|
)
|
|
|
|
store = get_sqlite_session_store()
|
|
imported = 0
|
|
skipped = 0
|
|
results: list[dict[str, Any]] = []
|
|
|
|
for incoming in payload.sessions:
|
|
# Drop content-less rows (e.g. tool-only turns the adapter could not
|
|
# reduce to text) so the transcript stays a clean human conversation.
|
|
messages = [m for m in incoming.messages if (m.content or "").strip()]
|
|
if not messages:
|
|
skipped += 1
|
|
results.append(
|
|
{"external_id": incoming.external_id, "imported": False, "reason": "empty"}
|
|
)
|
|
continue
|
|
if len(messages) > _MAX_MESSAGES_PER_SESSION:
|
|
messages = messages[:_MAX_MESSAGES_PER_SESSION]
|
|
|
|
session_id = make_imported_session_id(payload.source, incoming.external_id)
|
|
import_meta: dict[str, Any] = {
|
|
"source": payload.source,
|
|
"source_cwd": incoming.source_cwd,
|
|
"external_id": incoming.external_id,
|
|
}
|
|
if payload.agent_id:
|
|
import_meta["agent_id"] = payload.agent_id
|
|
if payload.agent_name:
|
|
import_meta["agent_name"] = payload.agent_name
|
|
preferences = {"import": import_meta}
|
|
try:
|
|
result = await store.import_session(
|
|
session_id,
|
|
incoming.title,
|
|
incoming.created_at,
|
|
incoming.updated_at,
|
|
preferences,
|
|
[m.model_dump() for m in messages],
|
|
)
|
|
except Exception:
|
|
logger.exception("failed to import session %s", incoming.external_id)
|
|
skipped += 1
|
|
results.append(
|
|
{"external_id": incoming.external_id, "imported": False, "reason": "error"}
|
|
)
|
|
continue
|
|
|
|
if result.get("imported"):
|
|
imported += 1
|
|
else:
|
|
skipped += 1
|
|
results.append({"external_id": incoming.external_id, **result})
|
|
|
|
return {"imported": imported, "skipped": skipped, "sessions": results}
|
|
|
|
|
|
@router.get("/chat-history")
|
|
async def list_imported_chat_history(
|
|
limit: int = Query(default=50, ge=1, le=200),
|
|
offset: int = Query(default=0, ge=0),
|
|
) -> dict[str, Any]:
|
|
store = get_sqlite_session_store()
|
|
sessions = await store.list_imported_sessions(limit=limit, offset=offset)
|
|
return {"sessions": sessions}
|