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
+8 -1
View File
@@ -6,6 +6,7 @@ import MessageInput from './components/MessageInput.vue'
import ModelSelector from './components/ModelSelector.vue'
import LogViewer from './components/LogViewer.vue'
import SettingsPanel from './components/SettingsPanel.vue'
import NotePanel from './components/NotePanel.vue'
import ToastList from './components/ToastList.vue'
import MemoryMonitor from './components/MemoryMonitor.vue'
import Clock from './components/Clock.vue'
@@ -15,7 +16,7 @@ import { useAuthStore } from './stores/auth'
const authStore = useAuthStore()
type SidePanel = 'none' | 'logs' | 'settings'
type SidePanel = 'none' | 'logs' | 'settings' | 'notes'
const activePanel = ref<SidePanel>('none')
function togglePanel(panel: SidePanel) {
@@ -50,6 +51,9 @@ onUnmounted(() => {
<span class="title">PlayChat</span>
<ModelSelector />
<Clock />
<button class="log-toggle" :class="{ active: activePanel === 'notes' }" @click="togglePanel('notes')">
ノート
</button>
<button class="log-toggle" :class="{ active: activePanel === 'settings' }" @click="togglePanel('settings')">
設定
</button>
@@ -71,6 +75,9 @@ onUnmounted(() => {
<div v-else-if="activePanel === 'settings'" class="log-pane">
<SettingsPanel />
</div>
<div v-else-if="activePanel === 'notes'" class="log-pane">
<NotePanel />
</div>
</div>
</div>
</div>
+39 -1
View File
@@ -22,7 +22,7 @@ export interface Source {
}
export interface SSEChunk {
type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources'
type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' | 'notes_updated'
value?: string | Source[]
}
@@ -257,3 +257,41 @@ export function streamChat(
return () => controller.abort()
}
export interface NotePage {
id: number
title: string
content: string
position: number
updated_at: string
}
export async function fetchNotePages(): Promise<NotePage[]> {
const res = await fetch(`${BASE}/api/notes`)
return parseJson<NotePage[]>(res, 'ノート取得')
}
export async function createNotePage(title: string): Promise<NotePage> {
const res = await fetch(`${BASE}/api/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
})
return parseJson<NotePage>(res, 'ページ作成')
}
export async function updateNotePage(
pageId: number,
patch: { title?: string; content?: string },
): Promise<NotePage> {
const res = await fetch(`${BASE}/api/notes/${pageId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
return parseJson<NotePage>(res, 'ページ更新')
}
export async function deleteNotePage(pageId: number): Promise<void> {
await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' })
}
+197
View File
@@ -0,0 +1,197 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, nextTick } from 'vue'
import { useNotesStore } from '../stores/notes'
const store = useNotesStore()
const renamingId = ref<number | null>(null)
const renameInput = ref<HTMLInputElement | null>(null)
async function startRename(id: number) {
renamingId.value = id
await nextTick()
renameInput.value?.select()
}
function onTitleInput(id: number, e: Event) {
store.edit(id, { title: (e.target as HTMLInputElement).value })
}
function finishRename() {
renamingId.value = null
store.flush()
}
function onContentInput(e: Event) {
if (store.activeId === null) return
store.edit(store.activeId, { content: (e.target as HTMLTextAreaElement).value })
}
async function confirmRemove(id: number, title: string) {
if (!window.confirm(`ページ「${title}」を削除しますか?`)) return
await store.removePage(id)
}
onMounted(() => {
store.load()
})
onUnmounted(() => {
store.flush()
})
</script>
<template>
<div class="note-panel">
<div class="tabs">
<div
v-for="page in store.pages"
:key="page.id"
class="tab"
:class="{ active: page.id === store.activeId }"
@click="store.selectPage(page.id)"
@dblclick="startRename(page.id)"
>
<input
v-if="renamingId === page.id"
ref="renameInput"
class="tab-rename"
:value="page.title"
@input="onTitleInput(page.id, $event)"
@blur="finishRename"
@keydown.enter="finishRename"
@keydown.esc="finishRename"
@click.stop
>
<template v-else>
<span class="tab-title" :title="page.title">{{ page.title }}</span>
<span v-if="store.dirtyIds.has(page.id)" class="dot" title="未保存">●</span>
<button class="tab-del" title="削除" @click.stop="confirmRemove(page.id, page.title)">×</button>
</template>
</div>
<button class="tab-add" title="ページを追加" @click="store.addPage()">+</button>
</div>
<div v-if="store.error" class="error">⚠ {{ store.error }}</div>
<textarea
v-if="store.activePage"
class="editor"
:value="store.activePage.content"
placeholder="ここに書いた内容はAIと共有されます。AIも read_note / write_note でこのページを読み書きできます。"
spellcheck="false"
@input="onContentInput"
@blur="store.flush()"
/>
<div v-else class="empty">
<p>ページがありません。</p>
<button class="create-btn" @click="store.addPage()">+ 最初のページを作成</button>
</div>
<div class="hint">
ページのタイトル一覧はAIのシステムプロンプトに常に含まれます。本文はAIが必要に応じて読み出します。
</div>
</div>
</template>
<style scoped>
.note-panel {
display: flex;
flex-direction: column;
height: 100%;
background: #161616;
}
.tabs {
display: flex;
align-items: stretch;
gap: 2px;
padding: 6px 6px 0;
overflow-x: auto;
flex-shrink: 0;
border-bottom: 1px solid #333;
}
.tab {
display: flex;
align-items: center;
gap: 4px;
padding: 5px 8px;
background: #1f1f1f;
border: 1px solid #333;
border-bottom: none;
border-radius: 6px 6px 0 0;
font-size: 12px;
color: #aaa;
cursor: pointer;
white-space: nowrap;
max-width: 160px;
}
.tab.active { background: #2a2a2a; color: #fff; border-color: #444; }
.tab-title { overflow: hidden; text-overflow: ellipsis; }
.dot { color: #eab308; font-size: 8px; }
.tab-del, .tab-add {
background: none;
border: none;
color: #666;
cursor: pointer;
font-size: 13px;
padding: 0 2px;
line-height: 1;
}
.tab-del:hover, .tab-add:hover { color: #f87171; }
.tab-add { color: #888; padding: 0 8px; }
.tab-add:hover { color: #93c5fd; }
.tab-rename {
background: #111;
border: 1px solid #2563eb;
border-radius: 3px;
color: #eee;
font-size: 12px;
padding: 1px 4px;
width: 110px;
}
.error {
padding: 6px 10px;
font-size: 11px;
color: #fca5a5;
background: #2a1414;
border-bottom: 1px solid #7f1d1d;
}
.editor {
flex: 1;
resize: none;
background: #161616;
color: #ddd;
border: none;
outline: none;
padding: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
line-height: 1.6;
}
.editor::placeholder { color: #555; }
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: #666;
font-size: 13px;
}
.create-btn {
background: #2a2a2a;
color: #ccc;
border: 1px solid #444;
border-radius: 6px;
padding: 6px 14px;
font-size: 12px;
cursor: pointer;
}
.hint {
flex-shrink: 0;
padding: 8px 10px;
border-top: 1px solid #2a2a2a;
color: #666;
font-size: 10px;
line-height: 1.5;
}
</style>
+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,
}
})