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:
2026-08-19 01:22:08 +09:00
co-authored by Claude Opus 5
parent 1ad8b130c7
commit ef588d99d8
4 changed files with 192 additions and 5 deletions
+33
View File
@@ -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'
+42 -2
View File
@@ -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>
+29 -1
View File
@@ -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,
}