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>
260 lines
8.9 KiB
TypeScript
260 lines
8.9 KiB
TypeScript
import { defineStore } from 'pinia'
|
|
import { ref, computed } from 'vue'
|
|
import {
|
|
fetchNotePages,
|
|
createNotePage,
|
|
updateNotePage,
|
|
deleteNotePage,
|
|
exportNotePage,
|
|
exportAllNotePages,
|
|
renameNoteSection,
|
|
reorderNotePages,
|
|
fetchSessionNotes,
|
|
updateSessionNotes,
|
|
importNotePages,
|
|
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())
|
|
/**
|
|
* 本文をシステムプロンプトに載せるページID。チャットセッションごとの設定で、
|
|
* セッションを切り替えるたびに loadAttached() で読み直す。
|
|
*/
|
|
const attachedIds = ref<Set<number>>(new Set())
|
|
/** attachedIds がどのセッションのものか。null は「まだ保存先が無い新規チャット」。 */
|
|
const attachedSessionId = ref<string | null>(null)
|
|
|
|
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 = '新しいページ', section: string | null = null): Promise<void> {
|
|
error.value = null
|
|
try {
|
|
const page = await createNotePage(title, section)
|
|
// サーバー側でセクションの並びに合わせて再配置されている可能性があるため一覧を取り直す
|
|
await load()
|
|
activeId.value = page.id
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : 'ページの作成に失敗しました'
|
|
}
|
|
}
|
|
|
|
/** ページの所属セクションを変更する。section が空/nullならグループ解除。並び順もサーバー側で調整されるため再取得する。 */
|
|
async function setSection(id: number, section: string | null): Promise<void> {
|
|
error.value = null
|
|
flush()
|
|
try {
|
|
await updateNotePage(id, { section })
|
|
await load()
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : 'セクションの変更に失敗しました'
|
|
}
|
|
}
|
|
|
|
/** oldName セクションに属する全ページを newName セクションへ一括で付け替える。newName が空ならグループ解除。 */
|
|
async function renameSection(oldName: string, newName: string | null): Promise<void> {
|
|
error.value = null
|
|
flush()
|
|
try {
|
|
await renameNoteSection(oldName, newName)
|
|
await load()
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : 'セクション名の変更に失敗しました'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* タブのドラッグ&ドロップ用。order には全ページIDをドラッグ後の新しい順序で渡す。
|
|
* section にドロップ先タブの所属セクションを渡せば、並べ替えと同時にセクション間の移動も反映される。
|
|
*/
|
|
async function reorder(pageId: number, section: string | null, order: number[]): Promise<void> {
|
|
error.value = null
|
|
flush()
|
|
try {
|
|
await reorderNotePages(pageId, section, order)
|
|
await load()
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : '並べ替えに失敗しました'
|
|
}
|
|
}
|
|
|
|
/** セッションに紐付いたページを読み込む。sessionId が null(新規チャット)なら選択を空にする。 */
|
|
async function loadAttached(sessionId: string | null): Promise<void> {
|
|
attachedSessionId.value = sessionId
|
|
if (sessionId === null) {
|
|
attachedIds.value = new Set()
|
|
return
|
|
}
|
|
try {
|
|
attachedIds.value = new Set(await fetchSessionNotes(sessionId))
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : '参照ノートの取得に失敗しました'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ページをプロンプトに載せる/載せないを切り替える。
|
|
* まだセッションが無い新規チャットではローカルに保持するだけで、最初の送信時にまとめて保存される。
|
|
*/
|
|
async function toggleAttached(id: number): Promise<void> {
|
|
const next = new Set(attachedIds.value)
|
|
if (next.has(id)) next.delete(id)
|
|
else next.add(id)
|
|
attachedIds.value = next
|
|
|
|
const sessionId = attachedSessionId.value
|
|
if (sessionId === null) return
|
|
try {
|
|
attachedIds.value = new Set(await updateSessionNotes(sessionId, [...next]))
|
|
} 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)
|
|
// 紐付けはサーバー側でもカスケード削除される
|
|
if (attachedIds.value.has(id)) {
|
|
const next = new Set(attachedIds.value)
|
|
next.delete(id)
|
|
attachedIds.value = next
|
|
}
|
|
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 : 'ノートの保存に失敗しました'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。
|
|
* await すれば保存の完了まで待てる(save() は自分でエラーを握るので reject はしない)。
|
|
*/
|
|
function flush(): Promise<void> {
|
|
if (saveTimer !== null) {
|
|
clearTimeout(saveTimer)
|
|
saveTimer = null
|
|
}
|
|
return Promise.all([...dirtyIds.value].map((id) => save(id))).then(() => undefined)
|
|
}
|
|
|
|
/**
|
|
* ファイルをページとして取り込み、最初に作られたページを開く。
|
|
* 戻り値は作成されたページ(失敗時は空配列)。
|
|
*/
|
|
async function importFiles(files: File[]): Promise<NotePage[]> {
|
|
error.value = null
|
|
await flush()
|
|
try {
|
|
const created = await importNotePages(files)
|
|
await load()
|
|
if (created.length > 0) activeId.value = created[0].id
|
|
return created
|
|
} catch (e) {
|
|
error.value = e instanceof Error ? e.message : 'ファイルの取り込みに失敗しました'
|
|
return []
|
|
}
|
|
}
|
|
|
|
/** 編集中の内容を保存してから、そのページを Markdown としてダウンロードする。 */
|
|
async function downloadPage(id: number): Promise<void> {
|
|
await flush()
|
|
exportNotePage(id)
|
|
}
|
|
|
|
/** 編集中の内容を保存してから、全ページを ZIP としてダウンロードする。 */
|
|
async function downloadAll(): Promise<void> {
|
|
await flush()
|
|
exportAllNotePages()
|
|
}
|
|
|
|
return {
|
|
pages,
|
|
activeId,
|
|
activePage,
|
|
error,
|
|
dirtyIds,
|
|
attachedIds,
|
|
attachedSessionId,
|
|
loadAttached,
|
|
toggleAttached,
|
|
load,
|
|
selectPage,
|
|
addPage,
|
|
removePage,
|
|
edit,
|
|
setSection,
|
|
renameSection,
|
|
reorder,
|
|
flush,
|
|
importFiles,
|
|
downloadPage,
|
|
downloadAll,
|
|
}
|
|
})
|