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
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse, JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session as DBSession
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
|
||||||
from app.db.database import get_db
|
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.memory_kb import memory as mem
|
||||||
from app.core import llm
|
from app.core import llm
|
||||||
from app.core.memory_kb import knowledge
|
from app.core.memory_kb import knowledge
|
||||||
@@ -47,6 +47,25 @@ class MessageResponse(BaseModel):
|
|||||||
from_attributes = True
|
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])
|
@router.get("/sessions", response_model=list[SessionResponse])
|
||||||
def list_sessions(db: DBSession = Depends(get_db)):
|
def list_sessions(db: DBSession = Depends(get_db)):
|
||||||
return db.query(Session).order_by(Session.updated_at.desc()).all()
|
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)
|
@router.delete("/sessions/{session_id}", status_code=204)
|
||||||
def delete_session(session_id: str, db: DBSession = Depends(get_db)):
|
def delete_session(session_id: str, db: DBSession = Depends(get_db)):
|
||||||
session = db.get(Session, session_id)
|
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' })
|
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: {
|
export async function fetchLogs(params: {
|
||||||
session_id?: string
|
session_id?: string
|
||||||
status?: 'ok' | 'error'
|
status?: 'ok' | 'error'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useChatStore } from '../stores/chat'
|
|||||||
|
|
||||||
const store = useChatStore()
|
const store = useChatStore()
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
const importInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
error.value = null
|
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)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -21,8 +34,18 @@ onMounted(load)
|
|||||||
<aside class="session-list">
|
<aside class="session-list">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<span>チャット履歴</span>
|
<span>チャット履歴</span>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="import-btn" title="JSONからインポート" @click="triggerImport">⭱</button>
|
||||||
<button class="new-btn" @click="store.selectSession(null)">+ 新規</button>
|
<button class="new-btn" @click="store.selectSession(null)">+ 新規</button>
|
||||||
</div>
|
</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>
|
<div v-if="error" class="api-error" :title="error">⚠ 接続エラー</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li
|
<li
|
||||||
@@ -32,6 +55,11 @@ onMounted(load)
|
|||||||
@click="store.selectSession(s.id)"
|
@click="store.selectSession(s.id)"
|
||||||
>
|
>
|
||||||
<span class="title">{{ s.title }}</span>
|
<span class="title">{{ s.title }}</span>
|
||||||
|
<button
|
||||||
|
class="export-btn"
|
||||||
|
@click.stop="store.downloadSessionLog(s.id)"
|
||||||
|
title="JSONでエクスポート"
|
||||||
|
>⭳</button>
|
||||||
<button
|
<button
|
||||||
class="del-btn"
|
class="del-btn"
|
||||||
@click.stop="store.removeSession(s.id)"
|
@click.stop="store.removeSession(s.id)"
|
||||||
@@ -69,7 +97,13 @@ onMounted(load)
|
|||||||
border-bottom: 1px solid #7f1d1d;
|
border-bottom: 1px solid #7f1d1d;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.new-btn {
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.new-btn,
|
||||||
|
.import-btn {
|
||||||
background: #2a2a2a;
|
background: #2a2a2a;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
border: 1px solid #444;
|
border: 1px solid #444;
|
||||||
@@ -78,6 +112,9 @@ onMounted(load)
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.hidden-file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
ul {
|
ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -104,6 +141,7 @@ li.active { background: #333; }
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #ddd;
|
color: #ddd;
|
||||||
}
|
}
|
||||||
|
.export-btn,
|
||||||
.del-btn {
|
.del-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -114,5 +152,7 @@ li.active { background: #333; }
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
.export-btn { font-size: 13px; }
|
||||||
|
li:hover .export-btn,
|
||||||
li:hover .del-btn { opacity: 1; }
|
li:hover .del-btn { opacity: 1; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
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 {
|
export interface Message {
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
@@ -69,6 +69,32 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
await loadSessions()
|
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 {
|
function sendMessage(text: string): void {
|
||||||
if (isStreaming.value || !text.trim()) return
|
if (isStreaming.value || !text.trim()) return
|
||||||
|
|
||||||
@@ -137,6 +163,8 @@ export const useChatStore = defineStore('chat', () => {
|
|||||||
loadSessions,
|
loadSessions,
|
||||||
selectSession,
|
selectSession,
|
||||||
removeSession,
|
removeSession,
|
||||||
|
downloadSessionLog,
|
||||||
|
importSessionFromFile,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
retryMessage,
|
retryMessage,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user