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 <noreply@anthropic.com>
This commit is contained in:
+88
-2
@@ -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)
|
||||
|
||||
@@ -63,6 +63,39 @@ export async function deleteSession(sessionId: string): Promise<void> {
|
||||
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<SessionInfo> {
|
||||
const res = await fetch(`${BASE}/chat/sessions/import`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
return parseJson<SessionInfo>(res, 'インポート')
|
||||
}
|
||||
|
||||
export async function fetchLogs(params: {
|
||||
session_id?: string
|
||||
status?: 'ok' | 'error'
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useChatStore } from '../stores/chat'
|
||||
|
||||
const store = useChatStore()
|
||||
const error = ref<string | null>(null)
|
||||
const importInput = ref<HTMLInputElement | null>(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)
|
||||
</script>
|
||||
|
||||
@@ -21,7 +34,17 @@ onMounted(load)
|
||||
<aside class="session-list">
|
||||
<div class="header">
|
||||
<span>チャット履歴</span>
|
||||
<button class="new-btn" @click="store.selectSession(null)">+ 新規</button>
|
||||
<div class="header-actions">
|
||||
<button class="import-btn" title="JSONからインポート" @click="triggerImport">⭱</button>
|
||||
<button class="new-btn" @click="store.selectSession(null)">+ 新規</button>
|
||||
</div>
|
||||
<input
|
||||
ref="importInput"
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
class="hidden-file-input"
|
||||
@change="onImportFileChange"
|
||||
>
|
||||
</div>
|
||||
<div v-if="error" class="api-error" :title="error">⚠ 接続エラー</div>
|
||||
<ul>
|
||||
@@ -32,6 +55,11 @@ onMounted(load)
|
||||
@click="store.selectSession(s.id)"
|
||||
>
|
||||
<span class="title">{{ s.title }}</span>
|
||||
<button
|
||||
class="export-btn"
|
||||
@click.stop="store.downloadSessionLog(s.id)"
|
||||
title="JSONでエクスポート"
|
||||
>⭳</button>
|
||||
<button
|
||||
class="del-btn"
|
||||
@click.stop="store.removeSession(s.id)"
|
||||
@@ -69,7 +97,13 @@ onMounted(load)
|
||||
border-bottom: 1px solid #7f1d1d;
|
||||
cursor: default;
|
||||
}
|
||||
.new-btn {
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.new-btn,
|
||||
.import-btn {
|
||||
background: #2a2a2a;
|
||||
color: #ccc;
|
||||
border: 1px solid #444;
|
||||
@@ -78,6 +112,9 @@ onMounted(load)
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.hidden-file-input {
|
||||
display: none;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
@@ -104,6 +141,7 @@ li.active { background: #333; }
|
||||
font-size: 13px;
|
||||
color: #ddd;
|
||||
}
|
||||
.export-btn,
|
||||
.del-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -114,5 +152,7 @@ li.active { background: #333; }
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.export-btn { font-size: 13px; }
|
||||
li:hover .export-btn,
|
||||
li:hover .del-btn { opacity: 1; }
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { fetchSessions, fetchMessages, deleteSession, streamChat, type SessionInfo, type Source } from '../api/client'
|
||||
import { fetchSessions, fetchMessages, deleteSession, exportSession, importSession, streamChat, type SessionInfo, type Source, type ImportPayload } from '../api/client'
|
||||
|
||||
export interface Message {
|
||||
role: 'user' | 'assistant'
|
||||
@@ -69,6 +69,32 @@ export const useChatStore = defineStore('chat', () => {
|
||||
await loadSessions()
|
||||
}
|
||||
|
||||
function downloadSessionLog(sessionId: string): void {
|
||||
exportSession(sessionId)
|
||||
}
|
||||
|
||||
async function importSessionFromFile(file: File): Promise<void> {
|
||||
let payload: ImportPayload
|
||||
try {
|
||||
const parsed = JSON.parse(await file.text())
|
||||
if (!Array.isArray(parsed?.messages)) {
|
||||
throw new Error('messages 配列が見つかりません')
|
||||
}
|
||||
payload = parsed as ImportPayload
|
||||
} catch (e) {
|
||||
pushToast(`インポート失敗: JSONの読み込みに失敗しました (${e instanceof Error ? e.message : e})`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const session = await importSession(payload)
|
||||
await loadSessions()
|
||||
await selectSession(session.id)
|
||||
pushToast(`「${session.title}」をインポートしました`)
|
||||
} catch (e) {
|
||||
pushToast(`インポート失敗: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
}
|
||||
|
||||
function sendMessage(text: string): void {
|
||||
if (isStreaming.value || !text.trim()) return
|
||||
|
||||
@@ -137,6 +163,8 @@ export const useChatStore = defineStore('chat', () => {
|
||||
loadSessions,
|
||||
selectSession,
|
||||
removeSession,
|
||||
downloadSessionLog,
|
||||
importSessionFromFile,
|
||||
sendMessage,
|
||||
retryMessage,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user