Add shared notes with tabbed pages editable by user and AI

Adds a note_pages table backing a tabbed notes panel where the user and
the model can both read and write. Only page titles (plus a short
preview) are injected into the system prompt on every turn; full page
content is kept out of context and fetched on demand via a read_note
tool, with write_note letting the model append to, replace, or create a
page.

Generalizes llm.resolve_web_search into resolve_tool_calls so the
tool-calling loop handles web_search and the new note tools through one
path. The chat stream emits a notes_updated SSE event when the model
edits a note so the frontend panel can refresh; local edits are tracked
as dirty and never clobbered by that refresh.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 01:27:31 +09:00
co-authored by Claude Opus 5
parent ef588d99d8
commit a08b1785a4
16 changed files with 760 additions and 26 deletions
+4
View File
@@ -1,4 +1,5 @@
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'
@@ -115,6 +116,9 @@ export const useChatStore = defineStore('chat', () => {
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
+123
View File
@@ -0,0 +1,123 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import {
fetchNotePages,
createNotePage,
updateNotePage,
deleteNotePage,
type NotePage,
} from '../api/client'
const SAVE_DEBOUNCE_MS = 800
export const useNotesStore = defineStore('notes', () => {
const pages = ref<NotePage[]>([])
const activeId = ref<number | null>(null)
const error = ref<string | null>(null)
/** 未保存の編集があるページID。AIによる更新で上書きしないために使う。 */
const dirtyIds = ref<Set<number>>(new Set())
let saveTimer: number | null = null
const activePage = computed(() => pages.value.find((p) => p.id === activeId.value) ?? null)
async function load(): Promise<void> {
error.value = null
try {
const fetched = await fetchNotePages()
// 編集中のページはローカルの内容を優先し、サーバーの値で潰さない
pages.value = fetched.map((page) => {
if (!dirtyIds.value.has(page.id)) return page
const local = pages.value.find((p) => p.id === page.id)
return local ? { ...page, title: local.title, content: local.content } : page
})
if (activeId.value === null || !pages.value.some((p) => p.id === activeId.value)) {
activeId.value = pages.value[0]?.id ?? null
}
} catch (e) {
error.value = e instanceof Error ? e.message : 'ノートの取得に失敗しました'
}
}
function selectPage(id: number): void {
flush()
activeId.value = id
}
async function addPage(title = '新しいページ'): Promise<void> {
error.value = null
try {
const page = await createNotePage(title)
pages.value.push(page)
activeId.value = page.id
} catch (e) {
error.value = e instanceof Error ? e.message : 'ページの作成に失敗しました'
}
}
async function removePage(id: number): Promise<void> {
error.value = null
try {
await deleteNotePage(id)
dirtyIds.value.delete(id)
pages.value = pages.value.filter((p) => p.id !== id)
if (activeId.value === id) activeId.value = pages.value[0]?.id ?? null
} catch (e) {
error.value = e instanceof Error ? e.message : 'ページの削除に失敗しました'
}
}
/** ローカルの内容を即座に更新し、少し待ってからサーバーへ保存する。 */
function edit(id: number, patch: { title?: string; content?: string }): void {
const page = pages.value.find((p) => p.id === id)
if (!page) return
Object.assign(page, patch)
dirtyIds.value.add(id)
if (saveTimer !== null) clearTimeout(saveTimer)
saveTimer = window.setTimeout(() => {
saveTimer = null
void save(id)
}, SAVE_DEBOUNCE_MS)
}
async function save(id: number): Promise<void> {
const page = pages.value.find((p) => p.id === id)
if (!page || !dirtyIds.value.has(id)) return
const { title, content } = page
try {
const saved = await updateNotePage(id, { title, content })
// 保存の往復中にさらに編集されていた場合は dirty のままにしておく
if (page.title === title && page.content === content) {
dirtyIds.value.delete(id)
page.updated_at = saved.updated_at
}
error.value = null
} catch (e) {
error.value = e instanceof Error ? e.message : 'ノートの保存に失敗しました'
}
}
/** 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。 */
function flush(): void {
if (saveTimer !== null) {
clearTimeout(saveTimer)
saveTimer = null
}
for (const id of [...dirtyIds.value]) void save(id)
}
return {
pages,
activeId,
activePage,
error,
dirtyIds,
load,
selectPage,
addPage,
removePage,
edit,
flush,
}
})