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>
507 lines
16 KiB
Vue
507 lines
16 KiB
Vue
<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 {
|
||
section: string | null
|
||
pages: NotePage[]
|
||
}
|
||
|
||
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[]>([])
|
||
|
||
/** ドラッグ中のページID */
|
||
const draggingId = ref<number | null>(null)
|
||
/** ドラッグ中に今どのタブの前/後にドロップされるかの表示用インジケータ */
|
||
const dropTarget = ref<{ id: number; place: 'before' | 'after' } | null>(null)
|
||
/** タブ列の末尾(+ボタンの手前)にドロップしようとしているか */
|
||
const dropAtEnd = ref(false)
|
||
|
||
/** 隣接して同じsectionが続く区間を1グループとして表示する(並び順はサーバー側が保証) */
|
||
const groups = computed<TabGroup[]>(() => {
|
||
const result: TabGroup[] = []
|
||
for (const page of store.pages) {
|
||
const last = result[result.length - 1]
|
||
if (last && last.section === page.section) {
|
||
last.pages.push(page)
|
||
} else {
|
||
result.push({ section: page.section, pages: [page] })
|
||
}
|
||
}
|
||
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()
|
||
renameInput.value[0]?.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)
|
||
}
|
||
|
||
async function assignSection(page: NotePage) {
|
||
const input = window.prompt('このページが属するセクション名(空にするとグループ解除)', page.section ?? '')
|
||
if (input === null) return
|
||
await store.setSection(page.id, input.trim())
|
||
}
|
||
|
||
async function renameSectionGroup(currentName: string) {
|
||
const input = window.prompt('セクション名を変更(空にするとグループを解除)', currentName)
|
||
if (input === null) return
|
||
await store.renameSection(currentName, input.trim())
|
||
}
|
||
|
||
function onDragStart(id: number, e: DragEvent) {
|
||
draggingId.value = id
|
||
e.dataTransfer?.setData('text/plain', String(id)) // Firefoxはdataが無いとドラッグを許可しない
|
||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
|
||
}
|
||
|
||
function onDragEnd() {
|
||
draggingId.value = null
|
||
dropTarget.value = null
|
||
dropAtEnd.value = false
|
||
}
|
||
|
||
function onDragOverTab(page: NotePage, e: DragEvent) {
|
||
if (draggingId.value === null || draggingId.value === page.id) return
|
||
dropAtEnd.value = false
|
||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||
const place: 'before' | 'after' = e.clientX - rect.left < rect.width / 2 ? 'before' : 'after'
|
||
dropTarget.value = { id: page.id, place }
|
||
}
|
||
|
||
function onDragOverEnd() {
|
||
if (draggingId.value === null) return
|
||
dropTarget.value = null
|
||
dropAtEnd.value = true
|
||
}
|
||
|
||
/** ドラッグ後の新しい並びを計算してサーバーへ送る。section にドロップ先タブの所属セクションを渡すと、並べ替えと同時にセクション間の移動も反映される。 */
|
||
async function moveTab(id: number, targetId: number | null, place: 'before' | 'after', section: string | null) {
|
||
const order = store.pages.map((p) => p.id)
|
||
const fromIdx = order.indexOf(id)
|
||
if (fromIdx === -1) return
|
||
order.splice(fromIdx, 1)
|
||
const insertAt = targetId === null
|
||
? order.length
|
||
: order.indexOf(targetId) + (place === 'after' ? 1 : 0)
|
||
order.splice(insertAt, 0, id)
|
||
await store.reorder(id, section, order)
|
||
}
|
||
|
||
async function onDropTab(page: NotePage, e: DragEvent) {
|
||
const id = draggingId.value
|
||
onDragEnd()
|
||
if (id === null || id === page.id) return
|
||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||
const place: 'before' | 'after' = e.clientX - rect.left < rect.width / 2 ? 'before' : 'after'
|
||
await moveTab(id, page.id, place, page.section)
|
||
}
|
||
|
||
async function onDropEnd() {
|
||
const id = draggingId.value
|
||
onDragEnd()
|
||
if (id === null) return
|
||
await moveTab(id, null, 'after', null)
|
||
}
|
||
|
||
onMounted(() => {
|
||
store.load()
|
||
})
|
||
onUnmounted(() => {
|
||
store.flush()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="note-panel">
|
||
<div class="tabs">
|
||
<div
|
||
v-for="group in groups"
|
||
:key="`${group.section ?? '_'}-${group.pages[0].id}`"
|
||
class="tab-group"
|
||
:class="{ named: group.section }"
|
||
>
|
||
<button
|
||
v-if="group.section"
|
||
class="section-label"
|
||
:title="`「${group.section}」セクション。クリックで名前変更・解除`"
|
||
@click="renameSectionGroup(group.section)"
|
||
>{{ group.section }}</button>
|
||
<div
|
||
v-for="page in group.pages"
|
||
:key="page.id"
|
||
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',
|
||
}"
|
||
:draggable="renamingId !== page.id"
|
||
@click="store.selectPage(page.id)"
|
||
@dblclick="startRename(page.id)"
|
||
@dragstart="onDragStart(page.id, $event)"
|
||
@dragend="onDragEnd"
|
||
@dragover.prevent="onDragOverTab(page, $event)"
|
||
@drop.prevent="onDropTab(page, $event)"
|
||
>
|
||
<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-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>
|
||
</div>
|
||
</div>
|
||
<div
|
||
v-if="draggingId !== null"
|
||
class="drop-end"
|
||
:class="{ active: dropAtEnd }"
|
||
@dragover.prevent="onDragOverEnd"
|
||
@drop.prevent="onDropEnd"
|
||
/>
|
||
<button class="tab-add" title="ページを追加" @click="store.addPage()">+</button>
|
||
</div>
|
||
|
||
<div v-if="store.error" class="error">⚠ {{ store.error }}</div>
|
||
|
||
<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 でこのページを読み書きできます。 .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"
|
||
title="開いているページを Markdown ファイルとして保存"
|
||
@click="store.activeId !== null && store.downloadPage(store.activeId)"
|
||
>⬇ このページ (.md)</button>
|
||
<button
|
||
class="action-btn"
|
||
:disabled="store.pages.length === 0"
|
||
title="全ページを ZIP(1ページ1 Markdown、セクションはフォルダ)として保存"
|
||
@click="store.downloadAll()"
|
||
>⬇ すべて (.zip)</button>
|
||
</div>
|
||
|
||
<div class="hint">
|
||
ページのタイトル一覧はAIのシステムプロンプトに常に含まれます。本文はAIが必要に応じて読み出します。
|
||
🏷 でページをセクションにまとめられます(セクション名をクリックすると変更・解除)。
|
||
タブはドラッグで並べ替え・他のセクションへの移動ができます。
|
||
⬇ でノートを手元にダウンロードできます(未保存の編集は保存してから書き出します)。
|
||
⬆ または本文エリアへのドロップで .md / .txt / .zip を取り込めます(⬇ で出した ZIP を戻せばセクションごと復元されます)。
|
||
<span class="hint-pin">
|
||
📌 を付けたページは、<strong>今開いているチャット</strong>のプロンプトに本文がそのまま載ります
|
||
(AIが read_note で読みに行くのを待たずに確実に参照されます)。指定はチャットごとに保存されます。
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.note-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
background: #161616;
|
||
}
|
||
.tabs {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 2px;
|
||
padding: 6px 6px 0;
|
||
overflow-x: auto;
|
||
flex-shrink: 0;
|
||
border-bottom: 1px solid #333;
|
||
}
|
||
.tab-group {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 2px;
|
||
}
|
||
.tab-group.named {
|
||
background: #161d2e;
|
||
border: 1px solid #29314a;
|
||
border-bottom: none;
|
||
border-radius: 8px 8px 0 0;
|
||
padding: 3px 3px 0;
|
||
margin-right: 4px;
|
||
}
|
||
.section-label {
|
||
background: none;
|
||
border: none;
|
||
color: #7fa8e0;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
padding: 2px 5px 4px;
|
||
white-space: nowrap;
|
||
max-width: 90px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.section-label:hover { color: #a8c8f0; text-decoration: underline; }
|
||
.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.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; }
|
||
.drop-end {
|
||
align-self: stretch;
|
||
width: 14px;
|
||
flex-shrink: 0;
|
||
margin: 0 2px;
|
||
border-radius: 3px;
|
||
}
|
||
.drop-end.active { background: #93c5fd; opacity: 0.5; }
|
||
.tab-title { overflow: hidden; text-overflow: ellipsis; }
|
||
.dot { color: #eab308; font-size: 8px; }
|
||
.tab-pin, .tab-tag, .tab-del, .tab-add {
|
||
background: none;
|
||
border: none;
|
||
color: #666;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
padding: 0 2px;
|
||
line-height: 1;
|
||
}
|
||
.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-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; }
|
||
.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;
|
||
}
|
||
.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;
|
||
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-hint { color: #555; font-size: 11px; }
|
||
.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;
|
||
}
|
||
.actions {
|
||
flex-shrink: 0;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 6px;
|
||
padding: 6px 8px;
|
||
border-top: 1px solid #2a2a2a;
|
||
}
|
||
.action-btn {
|
||
background: #1f1f1f;
|
||
color: #aaa;
|
||
border: 1px solid #333;
|
||
border-radius: 6px;
|
||
padding: 4px 10px;
|
||
font-size: 11px;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
}
|
||
.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;
|
||
border-top: 1px solid #2a2a2a;
|
||
color: #666;
|
||
font-size: 10px;
|
||
line-height: 1.5;
|
||
}
|
||
</style>
|