Files
playchat/frontend/src/stores/chat.ts
T
colcaroliandClaude Opus 5 0e58a07af4 Pin notes to a chat, and import notes from files
Two additions to the shared notes, both about getting content in and out of
the prompt.

Per-chat pinning. Notes normally reach the model as a title list only, and
the body arrives just if the model decides to call read_note. A 📌 on the tab
now puts a page's full body in the system prompt for the chat that is open,
every turn, no tool round trip. The selection is per session (new table
session_note_pages, unique on session_id + page_id), so different
conversations carry different pages. The title list marks pinned pages so the
model does not read them twice, and bodies are capped per page by
SESSION_NOTES_CONTENT_LIMIT.

A new chat has no session row until its first message, so the front end
cannot PUT the selection yet. POST /chat/stream therefore accepts
note_page_ids and applies it before building the context, which makes the
pins count from the very first turn.

File import. Dropping .md/.txt on the editor - or picking them with the new
⬆ button - creates pages, and a .zip is unpacked into one page per text
entry with the first path component as the section. That is exactly the
shape the export writes, so a downloaded zip round-trips back in. Titles that
collide get a " (2)" suffix rather than overwriting, since restoring a backup
into a live workspace should not destroy anything. UploadFile needs
python-multipart, added to the lockfile.

Toasts grew a kind: the import success message was rendering in the red error
toast, as was the existing session-import message. Both are green now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 16:04:41 +09:00

217 lines
7.8 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 ToolActivity, type ImportPayload } from '../api/client'
export interface Message {
role: 'user' | 'assistant'
content: string
pending?: boolean
error?: boolean
archived?: boolean
tags?: string[]
sources?: Source[]
/** この応答を作る際に実行したツール(Web検索・ノート読み書きなど)。表示専用で永続化はしない。 */
tools?: ToolActivity[]
}
export interface Toast {
id: number
message: string
kind: 'error' | 'success'
}
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, kind: Toast['kind'] = 'error', durationMs = 5000) {
const id = ++toastSeq
toasts.value.push({ id, message, kind })
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 = []
// プロンプトに載せるノートはセッションごとの設定なので、切り替えのたびに読み直す
useNotesStore().loadAttached(sessionId).catch(() => {})
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}」をインポートしました`, 'success')
} catch (e) {
pushToast(`インポート失敗: ${e instanceof Error ? e.message : e}`)
}
}
/** tool イベントを同じ id の行にマージする(開始時に追加し、完了時に上書きする)。 */
function mergeTool(msgIdx: number, activity: ToolActivity) {
const msg = messages.value[msgIdx]
if (!msg) return
const tools = msg.tools ?? (msg.tools = [])
const existing = tools.find((t) => t.id === activity.id)
if (existing) Object.assign(existing, activity)
else tools.push({ ...activity })
}
/** ストリームが終わった時点で running のまま残っているツールを中断扱いにする。 */
function settleTools(msgIdx: number) {
for (const tool of messages.value[msgIdx]?.tools ?? []) {
if (tool.status === 'running') {
tool.status = 'error'
tool.result = '中断されました'
}
}
}
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
const notesStore = useNotesStore()
streamChat(
text,
currentSessionId.value,
selectedModel.value,
[...notesStore.attachedIds],
(chunk) => {
if (chunk.type === 'session_id' && chunk.value) {
currentSessionId.value = chunk.value as string
// 新規セッションはこの時点で作られる。送信時に渡したノートの紐付けが
// サーバー側に保存されているので、その保存先を控え直す
notesStore.loadAttached(chunk.value as string).catch(() => {})
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 === 'tool' && chunk.value) {
mergeTool(msgIdx, chunk.value as ToolActivity)
} else if (chunk.type === 'notes_updated') {
// AIが共有ノートを書き換えたので、開いているノートパネルに反映する
useNotesStore().load().catch((e) => pushToast(`ノート更新失敗: ${e.message}`))
} else if (chunk.type === 'done') {
settleTools(msgIdx)
messages.value[msgIdx].pending = false
isStreaming.value = false
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
} else if (chunk.type === 'error') {
settleTools(msgIdx)
messages.value[msgIdx].content = (chunk.value as string) ?? 'バックエンドでエラーが発生しました。'
messages.value[msgIdx].pending = false
messages.value[msgIdx].error = true
isStreaming.value = false
}
},
(err) => {
settleTools(msgIdx)
// 途中まで届いていた内容は残しつつ、エラーメッセージを付記する
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,
}
})