import { defineStore } from 'pinia' import { useNotesStore } from './notes' import { ref, computed } from 'vue' import { fetchSessions, fetchMessages, deleteSession, exportSession, importSession, streamChat, type SessionInfo, type Source, type ImportPayload } from '../api/client' export interface Message { role: 'user' | 'assistant' content: string pending?: boolean error?: boolean archived?: boolean tags?: string[] sources?: Source[] } export interface Toast { id: number message: string } let toastSeq = 0 export const useChatStore = defineStore('chat', () => { const sessions = ref([]) const currentSessionId = ref(null) const messages = ref([]) const isStreaming = ref(false) const selectedModel = ref('bedrock/jp.anthropic.claude-sonnet-4-6') const toasts = ref([]) const currentSession = computed(() => sessions.value.find((s) => s.id === currentSessionId.value) ?? null, ) function pushToast(message: string, durationMs = 5000) { const id = ++toastSeq toasts.value.push({ id, message }) setTimeout(() => { toasts.value = toasts.value.filter((t) => t.id !== id) }, durationMs) } function dismissToast(id: number) { toasts.value = toasts.value.filter((t) => t.id !== id) } async function loadSessions() { sessions.value = await fetchSessions() } async function selectSession(sessionId: string | null) { currentSessionId.value = sessionId messages.value = [] if (!sessionId) return try { const records = await fetchMessages(sessionId) messages.value = records .filter((r) => r.role === 'user' || r.role === 'assistant') .map((r) => ({ role: r.role as 'user' | 'assistant', content: r.content, tags: r.tags, archived: r.archived_at !== null, })) } catch (e) { pushToast(`メッセージ取得失敗: ${e instanceof Error ? e.message : e}`) } } async function removeSession(sessionId: string) { await deleteSession(sessionId) if (currentSessionId.value === sessionId) { currentSessionId.value = null messages.value = [] } await loadSessions() } function downloadSessionLog(sessionId: string): void { exportSession(sessionId) } async function importSessionFromFile(file: File): Promise { 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 messages.value.push({ role: 'user', content: text }) messages.value.push({ role: 'assistant', content: '', pending: true }) const msgIdx = messages.value.length - 1 // reactive proxy 経由で更新するためインデックスを保持 isStreaming.value = true streamChat( text, currentSessionId.value, selectedModel.value, (chunk) => { if (chunk.type === 'session_id' && chunk.value) { currentSessionId.value = chunk.value as string loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`)) } else if (chunk.type === 'chunk' && chunk.value) { messages.value[msgIdx].content += chunk.value as string } else if (chunk.type === 'sources' && chunk.value) { messages.value[msgIdx].sources = chunk.value as Source[] } else if (chunk.type === 'notes_updated') { // AIが共有ノートを書き換えたので、開いているノートパネルに反映する useNotesStore().load().catch((e) => pushToast(`ノート更新失敗: ${e.message}`)) } else if (chunk.type === 'done') { messages.value[msgIdx].pending = false isStreaming.value = false loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`)) } else if (chunk.type === 'error') { messages.value[msgIdx].content = (chunk.value as string) ?? 'バックエンドでエラーが発生しました。' messages.value[msgIdx].pending = false messages.value[msgIdx].error = true isStreaming.value = false } }, (err) => { // 途中まで届いていた内容は残しつつ、エラーメッセージを付記する const partial = messages.value[msgIdx].content messages.value[msgIdx].content = partial ? `${partial}\n\n⚠️ ${err.message}` : err.message messages.value[msgIdx].pending = false messages.value[msgIdx].error = true isStreaming.value = false }, ) } /** エラーになったアシスタント応答のひとつ前にあるユーザーメッセージを再送信する。 */ function retryMessage(assistantIdx: number): void { if (isStreaming.value) return const userIdx = assistantIdx - 1 const userMsg = messages.value[userIdx] if (!userMsg || userMsg.role !== 'user') return const text = userMsg.content // 失敗したユーザーメッセージとエラー応答を取り除いてから送信し直す messages.value.splice(userIdx, 2) sendMessage(text) } return { sessions, currentSessionId, messages, isStreaming, selectedModel, toasts, currentSession, pushToast, dismissToast, loadSessions, selectSession, removeSession, downloadSessionLog, importSessionFromFile, sendMessage, retryMessage, } })