Feature/shared notes #1

Merged
colcaroli merged 2 commits from feature/shared-notes into main 2026-08-18 16:29:57 +00:00
4 changed files with 192 additions and 5 deletions
Showing only changes of commit ef588d99d8 - Show all commits
+88 -2
View File
@@ -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)
+33
View File
@@ -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'
+41 -1
View File
@@ -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>
+29 -1
View File
@@ -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,
} }