Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

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}