Merge branch 'main' into feature/katex-math-rendering

This commit is contained in:
2026-08-23 11:12:14 +00:00
22 changed files with 1062 additions and 51 deletions
+71 -3
View File
@@ -21,9 +21,23 @@ export interface Source {
url: string
}
/** ツール実行1件の進行状況。開始時に status='running' で届き、完了時に同じ id で status='done' が届く。 */
export interface ToolActivity {
id: string
/** バックエンドのツール名 (web_search / read_note / write_note / read_knowledge / save_knowledge)。 */
name?: string
/** 「Web検索」などの表示用ラベル。 */
label?: string
/** 検索クエリやページタイトルなど、何に対して実行したか。 */
detail?: string
status: 'running' | 'done' | 'error'
/** 完了時の短い結果(「5件の結果」など)。 */
result?: string
}
export interface SSEChunk {
type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' | 'notes_updated'
value?: string | Source[]
type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' | 'notes_updated' | 'tool'
value?: string | Source[] | ToolActivity
}
const BASE = ''
@@ -61,6 +75,24 @@ export async function fetchMessages(sessionId: string): Promise<MessageRecord[]>
return parseJson<MessageRecord[]>(res, 'メッセージ取得')
}
/** このセッションで本文をプロンプトに載せるよう指定されたノートのページID。 */
export async function fetchSessionNotes(sessionId: string): Promise<number[]> {
const res = await fetch(`${BASE}/chat/sessions/${sessionId}/notes`)
const body = await parseJson<{ page_ids: number[] }>(res, '参照ノート取得')
return body.page_ids
}
/** セッションに紐付けるノートを丸ごと置き換える。戻り値は実際に紐付いたページID。 */
export async function updateSessionNotes(sessionId: string, pageIds: number[]): Promise<number[]> {
const res = await fetch(`${BASE}/chat/sessions/${sessionId}/notes`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_ids: pageIds }),
})
const body = await parseJson<{ page_ids: number[] }>(res, '参照ノート更新')
return body.page_ids
}
export async function deleteSession(sessionId: string): Promise<void> {
await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' })
}
@@ -211,6 +243,8 @@ export function streamChat(
message: string,
sessionId: string | null,
model: string,
/** 本文をプロンプトに載せるノートのページID。新規セッションではここでしか指定できない。 */
notePageIds: number[],
onChunk: (chunk: SSEChunk) => void,
onError: (err: Error) => void,
): () => void {
@@ -219,7 +253,7 @@ export function streamChat(
fetch(`${BASE}/chat/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, session_id: sessionId, model }),
body: JSON.stringify({ message, session_id: sessionId, model, note_page_ids: notePageIds }),
signal: controller.signal,
})
.then(async (res) => {
@@ -300,6 +334,40 @@ export async function deleteNotePage(pageId: number): Promise<void> {
await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' })
}
/**
* ファイルをページとして取り込む。.md / .txt は1ファイル1ページ、.zip は中身を展開して複数ページ。
* 取り込めるファイルが1つも無ければサーバーが 400 を返す。
*/
export async function importNotePages(files: File[]): Promise<NotePage[]> {
const form = new FormData()
for (const file of files) form.append('files', file)
const res = await fetch(`${BASE}/api/notes/import`, { method: 'POST', body: form })
return parseJson<NotePage[]>(res, 'ノート取り込み')
}
/**
* ダウンロードのためだけにアンカーをクリックする。
* ファイル名はサーバーの Content-Disposition に任せたいので download 属性は付けない
* (ノートのタイトルには日本語やスラッシュが入りうるため、名前付けはサーバー側に一本化している)。
*/
function triggerDownload(url: string): void {
const a = document.createElement('a')
a.href = url
document.body.appendChild(a)
a.click()
a.remove()
}
/** 1ページを Markdown ファイルとしてダウンロードする。 */
export function exportNotePage(pageId: number): void {
triggerDownload(`${BASE}/api/notes/${pageId}/export`)
}
/** 全ページを ZIP(1ページ1 Markdown、セクションはフォルダ)としてダウンロードする。 */
export function exportAllNotePages(): void {
triggerDownload(`${BASE}/api/notes/export`)
}
/** section = old のページ全体を section = new へ一括で付け替える。new が空/nullならグループ解除。 */
export async function renameNoteSection(oldName: string, newName: string | null): Promise<{ renamed: number }> {
const res = await fetch(`${BASE}/api/notes/sections/rename`, {
+2
View File
@@ -2,6 +2,7 @@
import { computed, ref, watch, nextTick } from 'vue'
import { useChatStore } from '../stores/chat'
import MarkdownContent from './MarkdownContent.vue'
import ToolActivityList from './ToolActivityList.vue'
import type { Source } from '../api/client'
const store = useChatStore()
@@ -43,6 +44,7 @@ watch(
:class="['message-wrap', msg.role, { archived: msg.archived }]"
:title="msg.archived ? '要約済み: 内容は長期メモリに引き継がれ、この発言自体はモデルに渡りません' : undefined"
>
<ToolActivityList v-if="msg.tools && msg.tools.length > 0" :tools="msg.tools" />
<div :class="['message', msg.role, { error: msg.error }]">
<div class="bubble">
<!-- ユーザーメッセージはプレーンテキスト -->
+155 -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,24 +246,68 @@ 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"
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>
@@ -279,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; }
@@ -292,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;
@@ -301,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; }
@@ -323,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;
@@ -336,6 +453,7 @@ onUnmounted(() => {
line-height: 1.6;
}
.editor::placeholder { color: #555; }
.empty-hint { color: #555; font-size: 11px; }
.empty {
flex: 1;
display: flex;
@@ -355,6 +473,28 @@ onUnmounted(() => {
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;
+4 -3
View File
@@ -9,10 +9,10 @@ const store = useChatStore()
<div
v-for="toast in store.toasts"
:key="toast.id"
class="toast"
:class="['toast', toast.kind]"
@click="store.dismissToast(toast.id)"
>
<span class="icon">⚠</span>
<span class="icon">{{ toast.kind === 'success' ? '✓' : '⚠' }}</span>
<span class="msg">{{ toast.message }}</span>
<button class="close">✕</button>
</div>
@@ -46,9 +46,10 @@ const store = useChatStore()
animation: slide-in 0.2s ease;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
.toast.success { background: #0a1f14; border-color: #166534; color: #6ee7b7; }
.icon { flex-shrink: 0; }
.msg { flex: 1; word-break: break-word; }
.close { background: none; border: none; color: #f87171; cursor: pointer; font-size: 11px; padding: 0; }
.close { background: none; border: none; color: inherit; cursor: pointer; font-size: 11px; padding: 0; }
@keyframes slide-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
@@ -0,0 +1,77 @@
<script setup lang="ts">
/** アシスタント応答の生成中に実行したツール(Web検索・ノート読み書きなど)を明示的に見せるリスト。 */
import type { ToolActivity } from '../api/client'
defineProps<{ tools: ToolActivity[] }>()
const ICONS: Record<string, string> = {
web_search: '🔍',
read_note: '📖',
write_note: '✍️',
read_knowledge: '🧠',
save_knowledge: '💾',
}
function icon(tool: ToolActivity): string {
return ICONS[tool.name ?? ''] ?? '🛠'
}
</script>
<template>
<div class="tool-activity">
<div v-for="tool in tools" :key="tool.id" :class="['tool-row', tool.status]">
<span class="icon">{{ icon(tool) }}</span>
<span class="label">{{ tool.label || tool.name }}</span>
<span v-if="tool.detail" class="detail" :title="tool.detail">{{ tool.detail }}</span>
<span v-if="tool.status === 'running'" class="spinner" aria-label="実行中" />
<span v-else class="result">{{ tool.status === 'error' ? '⚠' : '✓' }} {{ tool.result }}</span>
</div>
</div>
</template>
<style scoped>
.tool-activity {
display: flex;
flex-direction: column;
gap: 2px;
max-width: 72%;
padding: 6px 10px;
border-left: 2px solid #3f6212;
background: #1b2114;
border-radius: 0 8px 8px 0;
font-size: 11px;
}
.tool-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
color: #a3a3a3;
}
.tool-row.running .label { color: #d9f99d; }
.icon { flex: none; }
.label { flex: none; color: #bef264; }
.detail {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #d4d4d4;
background: #262f1a;
border-radius: 8px;
padding: 1px 7px;
}
.result { flex: none; color: #86efac; }
.tool-row.error .result { color: #f87171; }
.spinner {
flex: none;
width: 10px;
height: 10px;
border: 2px solid #3f6212;
border-top-color: #bef264;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
+39 -4
View File
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { useNotesStore } from './notes'
import { ref, computed } from 'vue'
import { fetchSessions, fetchMessages, deleteSession, exportSession, importSession, streamChat, type SessionInfo, type Source, type ImportPayload } from '../api/client'
import { fetchSessions, fetchMessages, deleteSession, exportSession, importSession, streamChat, type SessionInfo, type Source, type ToolActivity, type ImportPayload } from '../api/client'
export interface Message {
role: 'user' | 'assistant'
@@ -11,11 +11,14 @@ export interface Message {
archived?: boolean
tags?: string[]
sources?: Source[]
/** この応答を作る際に実行したツール(Web検索・ノート読み書きなど)。表示専用で永続化はしない。 */
tools?: ToolActivity[]
}
export interface Toast {
id: number
message: string
kind: 'error' | 'success'
}
let toastSeq = 0
@@ -32,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)
@@ -51,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)
@@ -96,12 +101,32 @@ 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}`)
}
}
/** tool イベントを同じ id の行にマージする(開始時に追加し、完了時に上書きする)。 */
function mergeTool(msgIdx: number, activity: ToolActivity) {
const msg = messages.value[msgIdx]
if (!msg) return
const tools = msg.tools ?? (msg.tools = [])
const existing = tools.find((t) => t.id === activity.id)
if (existing) Object.assign(existing, activity)
else tools.push({ ...activity })
}
/** ストリームが終わった時点で running のまま残っているツールを中断扱いにする。 */
function settleTools(msgIdx: number) {
for (const tool of messages.value[msgIdx]?.tools ?? []) {
if (tool.status === 'running') {
tool.status = 'error'
tool.result = '中断されました'
}
}
}
function sendMessage(text: string): void {
if (isStreaming.value || !text.trim()) return
@@ -110,26 +135,35 @@ 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
} else if (chunk.type === 'sources' && chunk.value) {
messages.value[msgIdx].sources = chunk.value as Source[]
} else if (chunk.type === 'tool' && chunk.value) {
mergeTool(msgIdx, chunk.value as ToolActivity)
} else if (chunk.type === 'notes_updated') {
// AIが共有ノートを書き換えたので、開いているノートパネルに反映する
useNotesStore().load().catch((e) => pushToast(`ノート更新失敗: ${e.message}`))
} else if (chunk.type === 'done') {
settleTools(msgIdx)
messages.value[msgIdx].pending = false
isStreaming.value = false
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
} else if (chunk.type === 'error') {
settleTools(msgIdx)
messages.value[msgIdx].content = (chunk.value as string) ?? 'バックエンドでエラーが発生しました。'
messages.value[msgIdx].pending = false
messages.value[msgIdx].error = true
@@ -137,6 +171,7 @@ export const useChatStore = defineStore('chat', () => {
}
},
(err) => {
settleTools(msgIdx)
// 途中まで届いていた内容は残しつつ、エラーメッセージを付記する
const partial = messages.value[msgIdx].content
messages.value[msgIdx].content = partial ? `${partial}\n\n⚠️ ${err.message}` : err.message
+94 -3
View File
@@ -5,8 +5,13 @@ import {
createNotePage,
updateNotePage,
deleteNotePage,
exportNotePage,
exportAllNotePages,
renameNoteSection,
reorderNotePages,
fetchSessionNotes,
updateSessionNotes,
importNotePages,
type NotePage,
} from '../api/client'
@@ -18,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
@@ -97,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) {
@@ -140,13 +191,46 @@ export const useNotesStore = defineStore('notes', () => {
}
}
/** 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。 */
function flush(): void {
/**
* 保留中の保存を直ちに実行する(タブ切替・パネルを閉じる時など)。
* await すれば保存の完了まで待てる(save() は自分でエラーを握るので reject はしない)。
*/
function flush(): Promise<void> {
if (saveTimer !== null) {
clearTimeout(saveTimer)
saveTimer = null
}
for (const id of [...dirtyIds.value]) void save(id)
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 {
@@ -155,6 +239,10 @@ export const useNotesStore = defineStore('notes', () => {
activePage,
error,
dirtyIds,
attachedIds,
attachedSessionId,
loadAttached,
toggleAttached,
load,
selectPage,
addPage,
@@ -164,5 +252,8 @@ export const useNotesStore = defineStore('notes', () => {
renameSection,
reorder,
flush,
importFiles,
downloadPage,
downloadAll,
}
})