Pin notes to a chat, and import notes from files
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>
This commit is contained in:
@@ -18,6 +18,7 @@ export interface Message {
|
||||
export interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
kind: 'error' | 'success'
|
||||
}
|
||||
|
||||
let toastSeq = 0
|
||||
@@ -34,9 +35,9 @@ export const useChatStore = defineStore('chat', () => {
|
||||
sessions.value.find((s) => s.id === currentSessionId.value) ?? null,
|
||||
)
|
||||
|
||||
function pushToast(message: string, durationMs = 5000) {
|
||||
function pushToast(message: string, kind: Toast['kind'] = 'error', durationMs = 5000) {
|
||||
const id = ++toastSeq
|
||||
toasts.value.push({ id, message })
|
||||
toasts.value.push({ id, message, kind })
|
||||
setTimeout(() => {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||
}, durationMs)
|
||||
@@ -53,6 +54,8 @@ export const useChatStore = defineStore('chat', () => {
|
||||
async function selectSession(sessionId: string | null) {
|
||||
currentSessionId.value = sessionId
|
||||
messages.value = []
|
||||
// プロンプトに載せるノートはセッションごとの設定なので、切り替えのたびに読み直す
|
||||
useNotesStore().loadAttached(sessionId).catch(() => {})
|
||||
if (!sessionId) return
|
||||
try {
|
||||
const records = await fetchMessages(sessionId)
|
||||
@@ -98,7 +101,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const session = await importSession(payload)
|
||||
await loadSessions()
|
||||
await selectSession(session.id)
|
||||
pushToast(`「${session.title}」をインポートしました`)
|
||||
pushToast(`「${session.title}」をインポートしました`, 'success')
|
||||
} catch (e) {
|
||||
pushToast(`インポート失敗: ${e instanceof Error ? e.message : e}`)
|
||||
}
|
||||
@@ -132,13 +135,18 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const msgIdx = messages.value.length - 1 // reactive proxy 経由で更新するためインデックスを保持
|
||||
isStreaming.value = true
|
||||
|
||||
const notesStore = useNotesStore()
|
||||
streamChat(
|
||||
text,
|
||||
currentSessionId.value,
|
||||
selectedModel.value,
|
||||
[...notesStore.attachedIds],
|
||||
(chunk) => {
|
||||
if (chunk.type === 'session_id' && chunk.value) {
|
||||
currentSessionId.value = chunk.value as string
|
||||
// 新規セッションはこの時点で作られる。送信時に渡したノートの紐付けが
|
||||
// サーバー側に保存されているので、その保存先を控え直す
|
||||
notesStore.loadAttached(chunk.value as string).catch(() => {})
|
||||
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
|
||||
} else if (chunk.type === 'chunk' && chunk.value) {
|
||||
messages.value[msgIdx].content += chunk.value as string
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
exportAllNotePages,
|
||||
renameNoteSection,
|
||||
reorderNotePages,
|
||||
fetchSessionNotes,
|
||||
updateSessionNotes,
|
||||
importNotePages,
|
||||
type NotePage,
|
||||
} from '../api/client'
|
||||
|
||||
@@ -20,6 +23,13 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
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
|
||||
|
||||
@@ -99,11 +109,50 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** セッションに紐付いたページを読み込む。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) {
|
||||
@@ -154,6 +203,24 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
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()
|
||||
@@ -172,6 +239,10 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
activePage,
|
||||
error,
|
||||
dirtyIds,
|
||||
attachedIds,
|
||||
attachedSessionId,
|
||||
loadAttached,
|
||||
toggleAttached,
|
||||
load,
|
||||
selectPage,
|
||||
addPage,
|
||||
@@ -181,6 +252,7 @@ export const useNotesStore = defineStore('notes', () => {
|
||||
renameSection,
|
||||
reorder,
|
||||
flush,
|
||||
importFiles,
|
||||
downloadPage,
|
||||
downloadAll,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user