From ef588d99d8bf6c200496199609a96192c522d70a Mon Sep 17 00:00:00 2001 From: zukki259 Date: Wed, 19 Aug 2026 01:22:08 +0900 Subject: [PATCH 1/2] Add session export/import as JSON Sessions can now be downloaded as a JSON file containing the session metadata and every message with its tags, and re-imported as a new session from that same format. GET /chat/sessions/{id}/export streams the session as a file download. POST /chat/sessions/import validates the roles, then recreates the session under a fresh UUID so importing never collides with an existing session. The sidebar gets a per-row download button and an import button that feeds a hidden file input. Co-Authored-By: Claude Opus 5 --- backend/app/api/chat.py | 90 ++++++++++++++++++++++++- frontend/src/api/client.ts | 33 +++++++++ frontend/src/components/SessionList.vue | 44 +++++++++++- frontend/src/stores/chat.ts | 30 ++++++++- 4 files changed, 192 insertions(+), 5 deletions(-) diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 0b6902c..8caaa83 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -2,12 +2,12 @@ import asyncio import uuid from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException -from fastapi.responses import StreamingResponse +from fastapi.responses import StreamingResponse, JSONResponse from pydantic import BaseModel from sqlalchemy.orm import Session as DBSession from app.db.database import get_db -from app.db.models import Session, Message +from app.db.models import Session, Message, MessageTag from app.core.memory_kb import memory as mem from app.core import llm from app.core.memory_kb import knowledge @@ -47,6 +47,25 @@ class MessageResponse(BaseModel): from_attributes = True +class ImportMessage(BaseModel): + role: str + content: str + created_at: datetime | None = None + tags: list[str] = [] + + +class ImportSessionMeta(BaseModel): + title: str | None = None + model: str | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +class ImportRequest(BaseModel): + session: ImportSessionMeta = ImportSessionMeta() + messages: list[ImportMessage] + + @router.get("/sessions", response_model=list[SessionResponse]) def list_sessions(db: DBSession = Depends(get_db)): return db.query(Session).order_by(Session.updated_at.desc()).all() @@ -69,6 +88,73 @@ def get_messages(session_id: str, db: DBSession = Depends(get_db)): ] +@router.get("/sessions/{session_id}/export") +def export_session(session_id: str, db: DBSession = Depends(get_db)): + """セッションのメタデータと全メッセージをJSONファイルとしてダウンロードする。""" + session = db.get(Session, session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + data = { + "session": { + "id": session.id, + "title": session.title, + "model": session.model, + "created_at": session.created_at.isoformat(), + "updated_at": session.updated_at.isoformat(), + }, + "messages": [ + { + "id": m.id, + "role": m.role, + "content": m.content, + "created_at": m.created_at.isoformat(), + "tags": [t.tag for t in m.tags], + } + for m in session.messages + ], + } + return JSONResponse( + content=data, + headers={"Content-Disposition": f'attachment; filename="session_{session.id}.json"'}, + ) + + +@router.post("/sessions/import", response_model=SessionResponse, status_code=201) +def import_session(req: ImportRequest, db: DBSession = Depends(get_db)): + """export で出力したJSON(またはその形式に沿ったJSON)から新規セッションを作成する。""" + if not req.messages: + raise HTTPException(status_code=400, detail="messages is empty") + for m in req.messages: + if m.role not in ("user", "assistant", "system"): + raise HTTPException(status_code=400, detail=f"invalid role: {m.role}") + + title = req.session.title or req.messages[0].content[:40] + session = Session( + id=str(uuid.uuid4()), + title=title, + model=req.session.model or DEFAULT_MODEL, + ) + if req.session.created_at: + session.created_at = req.session.created_at + session.updated_at = req.session.updated_at or datetime.now(timezone.utc) + db.add(session) + db.flush() + + for m in req.messages: + msg = Message(session_id=session.id, role=m.role, content=m.content) + if m.created_at: + msg.created_at = m.created_at + db.add(msg) + db.flush() + for tag in m.tags: + db.add(MessageTag(message_id=msg.id, tag=tag)) + + db.commit() + db.refresh(session) + return session + + @router.delete("/sessions/{session_id}", status_code=204) def delete_session(session_id: str, db: DBSession = Depends(get_db)): session = db.get(Session, session_id) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 64d8457..6b44260 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -63,6 +63,39 @@ export async function deleteSession(sessionId: string): Promise { await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' }) } +export function exportSession(sessionId: string): void { + const a = document.createElement('a') + a.href = `${BASE}/chat/sessions/${sessionId}/export` + a.download = `session_${sessionId}.json` + document.body.appendChild(a) + a.click() + a.remove() +} + +export interface ImportPayload { + session?: { + title?: string | null + model?: string | null + created_at?: string | null + updated_at?: string | null + } + messages: { + role: string + content: string + created_at?: string | null + tags?: string[] + }[] +} + +export async function importSession(payload: ImportPayload): Promise { + const res = await fetch(`${BASE}/chat/sessions/import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + return parseJson(res, 'インポート') +} + export async function fetchLogs(params: { session_id?: string status?: 'ok' | 'error' diff --git a/frontend/src/components/SessionList.vue b/frontend/src/components/SessionList.vue index e2f50e2..34ccdf8 100644 --- a/frontend/src/components/SessionList.vue +++ b/frontend/src/components/SessionList.vue @@ -4,6 +4,7 @@ import { useChatStore } from '../stores/chat' const store = useChatStore() const error = ref(null) +const importInput = ref(null) async function load() { error.value = null @@ -14,6 +15,18 @@ async function load() { } } +function triggerImport() { + importInput.value?.click() +} + +async function onImportFileChange(e: Event) { + const input = e.target as HTMLInputElement + const file = input.files?.[0] + input.value = '' + if (!file) return + await store.importSessionFromFile(file) +} + onMounted(load) @@ -21,7 +34,17 @@ onMounted(load)