Files
playchat/frontend/src/components/NotePanel.vue
T
colcaroliandClaude Opus 5 0e58a07af4 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>
2026-08-23 16:04:41 +09:00

507 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 でこのページを読み書きできます。&#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"
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>