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>
217 lines
7.8 KiB
TypeScript
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,
|
|
}
|
|
})
|