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:
@@ -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>
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user