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:
2026-08-23 16:04:41 +09:00
co-authored by Claude Opus 5
parent 216d38c7bd
commit 0e58a07af4
18 changed files with 629 additions and 33 deletions
+119 -15
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref, nextTick, computed } from 'vue'
import { useNotesStore } from '../stores/notes'
import { useChatStore } from '../stores/chat'
import type { NotePage } from '../api/client'
interface TabGroup {
@@ -9,7 +10,11 @@ interface TabGroup {
}
const store = useNotesStore()
const chat = useChatStore()
const renamingId = ref<number | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
/** ファイルを本文エリアの上までドラッグしてきているか(取り込み用のハイライト表示) */
const fileOver = ref(false)
// ref="renameInput" は入れ子の v-for の中にあるため、Vue 3 では単一要素ではなく配列になる
const renameInput = ref<HTMLInputElement[]>([])
@@ -34,6 +39,40 @@ const groups = computed<TabGroup[]>(() => {
return result
})
/** ファイルをページとして取り込む。取り込めた件数はトーストで知らせる。 */
async function importFiles(files: FileList | File[] | null) {
const list = [...(files ?? [])]
if (list.length === 0) return
const created = await store.importFiles(list)
if (created.length > 0) {
chat.pushToast(`${created.length}ページを取り込みました: ${created.map((p) => p.title).join(', ')}`, 'success')
}
}
function onPickFiles(e: Event) {
const input = e.target as HTMLInputElement
void importFiles(input.files)
input.value = '' // 同じファイルを続けて選び直せるようにする
}
/** タブのドラッグ&ドロップ(draggingId が立つ)とは別物。ファイルのドロップだけを拾う。 */
function isFileDrag(e: DragEvent): boolean {
return !!e.dataTransfer && [...e.dataTransfer.types].includes('Files')
}
function onFileDragOver(e: DragEvent) {
if (!isFileDrag(e)) return
e.preventDefault()
fileOver.value = true
}
function onFileDrop(e: DragEvent) {
if (!isFileDrag(e)) return
e.preventDefault()
fileOver.value = false
void importFiles(e.dataTransfer!.files)
}
async function startRename(id: number) {
renamingId.value = id
await nextTick()
@@ -155,6 +194,7 @@ onUnmounted(() => {
class="tab"
:class="{
active: page.id === store.activeId,
attached: store.attachedIds.has(page.id),
dragging: draggingId === page.id,
'drop-before': dropTarget?.id === page.id && dropTarget.place === 'before',
'drop-after': dropTarget?.id === page.id && dropTarget.place === 'after',
@@ -181,6 +221,14 @@ onUnmounted(() => {
<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-pin"
:class="{ on: store.attachedIds.has(page.id) }"
:title="store.attachedIds.has(page.id)
? 'この会話のプロンプトに本文を載せている(クリックで解除)'
: 'この会話のプロンプトに本文を載せる'"
@click.stop="store.toggleAttached(page.id)"
>📌</button>
<button class="tab-tag" title="セクションに割り当て" @click.stop="assignSection(page)">🏷</button>
<button class="tab-del" title="削除" @click.stop="confirmRemove(page.id, page.title)">×</button>
</template>
@@ -198,21 +246,44 @@ onUnmounted(() => {
<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
class="body"
:class="{ 'file-over': fileOver }"
@dragover="onFileDragOver"
@dragleave="fileOver = false"
@drop="onFileDrop"
>
<textarea
v-if="store.activePage"
class="editor"
:value="store.activePage.content"
placeholder="ここに書いた内容はAIと共有されます。AIも read_note / write_note でこのページを読み書きできます。&#10;.md / .txt / .zip をここにドロップすると新しいページとして取り込みます。"
spellcheck="false"
@input="onContentInput"
@blur="store.flush()"
/>
<div v-else class="empty">
<p>ページがありません。</p>
<button class="create-btn" @click="store.addPage()">+ 最初のページを作成</button>
<p class="empty-hint">.md / .txt / .zip をここにドロップしても取り込めます。</p>
</div>
<div v-if="fileOver" class="drop-overlay">ドロップしてページとして取り込む</div>
</div>
<div class="actions">
<input
ref="fileInput"
type="file"
class="file-input"
multiple
accept=".md,.markdown,.txt,.text,.zip"
@change="onPickFiles"
>
<button
class="action-btn"
title=".md / .txt / .zip をページとして取り込む"
@click="fileInput?.click()"
>⬆ 取り込み</button>
<button
class="action-btn"
:disabled="store.activeId === null"
@@ -232,6 +303,11 @@ onUnmounted(() => {
🏷 でページをセクションにまとめられます(セクション名をクリックすると変更・解除)。
タブはドラッグで並べ替え・他のセクションへの移動ができます。
⬇ でノートを手元にダウンロードできます(未保存の編集は保存してから書き出します)。
⬆ または本文エリアへのドロップで .md / .txt / .zip を取り込めます(⬇ で出した ZIP を戻せばセクションごと復元されます)。
<span class="hint-pin">
📌 を付けたページは、<strong>今開いているチャット</strong>のプロンプトに本文がそのまま載ります
(AIが read_note で読みに行くのを待たずに確実に参照されます)。指定はチャットごとに保存されます。
</span>
</div>
</div>
</template>
@@ -295,6 +371,8 @@ onUnmounted(() => {
max-width: 160px;
}
.tab.active { background: #2a2a2a; color: #fff; border-color: #444; }
/* プロンプトに本文を載せているページ。タブ上端の線で「積んでいる」ことを示す */
.tab.attached { box-shadow: inset 0 2px 0 #eab308; }
.tab.dragging { opacity: 0.4; }
.tab.drop-before { box-shadow: inset 2px 0 0 #93c5fd; }
.tab.drop-after { box-shadow: inset -2px 0 0 #93c5fd; }
@@ -308,7 +386,7 @@ onUnmounted(() => {
.drop-end.active { background: #93c5fd; opacity: 0.5; }
.tab-title { overflow: hidden; text-overflow: ellipsis; }
.dot { color: #eab308; font-size: 8px; }
.tab-tag, .tab-del, .tab-add {
.tab-pin, .tab-tag, .tab-del, .tab-add {
background: none;
border: none;
color: #666;
@@ -317,9 +395,13 @@ onUnmounted(() => {
padding: 0 2px;
line-height: 1;
}
.tab-tag { font-size: 11px; opacity: 0; transition: opacity 0.15s; }
.tab-pin, .tab-tag { font-size: 11px; opacity: 0; transition: opacity 0.15s; }
.tab:hover .tab-pin, .tab.active .tab-pin,
.tab:hover .tab-tag, .tab.active .tab-tag { opacity: 0.8; }
.tab-tag:hover { opacity: 1 !important; }
.tab-pin:hover, .tab-tag:hover { opacity: 1 !important; }
/* 載せているページのピンは、タブが非アクティブでも常に見えるようにする */
.tab-pin.on { opacity: 1; filter: none; }
.tab-pin:not(.on) { filter: grayscale(1); }
.tab-del:hover, .tab-add:hover { color: #f87171; }
.tab-add { color: #888; padding: 0 8px; align-self: center; }
.tab-add:hover { color: #93c5fd; }
@@ -339,6 +421,25 @@ onUnmounted(() => {
background: #2a1414;
border-bottom: 1px solid #7f1d1d;
}
.body {
flex: 1;
position: relative;
display: flex;
min-height: 0;
}
.body.file-over { outline: 2px dashed #93c5fd; outline-offset: -6px; }
.drop-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(22, 22, 22, 0.85);
color: #93c5fd;
font-size: 13px;
pointer-events: none; /* dragleave が誤発火しないように、当たり判定を持たせない */
}
.file-input { display: none; }
.editor {
flex: 1;
resize: none;
@@ -352,6 +453,7 @@ onUnmounted(() => {
line-height: 1.6;
}
.editor::placeholder { color: #555; }
.empty-hint { color: #555; font-size: 11px; }
.empty {
flex: 1;
display: flex;
@@ -391,6 +493,8 @@ onUnmounted(() => {
}
.action-btn:hover:not(:disabled) { background: #2a2a2a; color: #93c5fd; border-color: #444; }
.action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.hint-pin { display: block; margin-top: 4px; color: #8a7a3a; }
.hint-pin strong { color: #eab308; font-weight: 600; }
.hint {
flex-shrink: 0;
padding: 8px 10px;