Files
playchat/frontend/src/stores/chat.ts
T
colcaroliandClaude Opus 5 2ee7238afb Archive summarized messages instead of deleting them
要約で長期メモリに移したメッセージをDBから物理削除していたため、
一度要約されると人間も内容を確認できなくなっていた。削除をやめ、
messages に archived_at(要約に取り込まれた時刻)と memory_id
(どの要約に取り込まれたか)を持たせる。

モデルからは従来どおり見えない:
- get_context_messages() がアーカイブ済みを除外する
- maybe_summarize() の閾値判定も現役メッセージだけを数える
  (ここを除外し忘れると件数が減らず再要約が繰り返される)
- backfill_tags() もアーカイブ済みは対象外にする

人間からは確認できる:
- GET /chat/sessions/{id}/messages がアーカイブ済みも archived_at
  付きで返す。export/import もアーカイブ状態を保持する
- チャット画面では半透明で表示し、最後のアーカイブ済みメッセージの
  直後に「ここから先がモデルに渡る」区切り線を出す

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 14:33:18 +09:00

182 lines
6.0 KiB
TypeScript

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<SessionInfo[]>([])
const currentSessionId = ref<string | null>(null)
const messages = ref<Message[]>([])
const isStreaming = ref(false)
const selectedModel = ref('bedrock/jp.anthropic.claude-sonnet-4-6')
const toasts = ref<Toast[]>([])
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<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
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,
}
})