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
+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>