export interface ApiLog { id: number session_id: string | null model: string request_json: string response_text: string | null error: string | null latency_ms: number | null created_at: string } export interface SessionInfo { id: string title: string model: string updated_at: string } export interface Source { title: string 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' | 'tool' value?: string | Source[] | ToolActivity } const BASE = '' async function parseJson(res: Response, label: string): Promise { const ct = res.headers.get('content-type') ?? '' if (!ct.includes('application/json')) { throw new Error(`${label}: バックエンドに接続できません (${res.status} ${res.statusText})`) } if (!res.ok) { if (res.status === 401) window.dispatchEvent(new Event('playchat:unauthorized')) const body = await res.json().catch(() => ({})) throw new Error(`${label}: ${body.detail ?? res.statusText}`) } return res.json() } export async function fetchSessions(): Promise { const res = await fetch(`${BASE}/chat/sessions`) return parseJson(res, 'セッション取得') } export interface MessageRecord { id: number role: 'user' | 'assistant' | 'system' content: string created_at: string // 要約に取り込まれたメッセージ。モデルには渡らないが履歴としては残っている archived_at: string | null tags: string[] } export async function fetchMessages(sessionId: string): Promise { const res = await fetch(`${BASE}/chat/sessions/${sessionId}/messages`) return parseJson(res, 'メッセージ取得') } export async function deleteSession(sessionId: string): Promise { await fetch(`${BASE}/chat/sessions/${sessionId}`, { method: 'DELETE' }) } export function exportSession(sessionId: string): void { const a = document.createElement('a') a.href = `${BASE}/chat/sessions/${sessionId}/export` a.download = `session_${sessionId}.json` document.body.appendChild(a) a.click() a.remove() } export interface ImportPayload { session?: { title?: string | null model?: string | null created_at?: string | null updated_at?: string | null } messages: { role: string content: string created_at?: string | null archived_at?: string | null tags?: string[] }[] } export async function importSession(payload: ImportPayload): Promise { const res = await fetch(`${BASE}/chat/sessions/import`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) return parseJson(res, 'インポート') } export async function fetchLogs(params: { session_id?: string status?: 'ok' | 'error' limit?: number offset?: number } = {}): Promise { const q = new URLSearchParams() if (params.session_id) q.set('session_id', params.session_id) if (params.status) q.set('status', params.status) if (params.limit != null) q.set('limit', String(params.limit)) if (params.offset != null) q.set('offset', String(params.offset)) const res = await fetch(`${BASE}/logs?${q}`) return parseJson(res, 'ログ取得') } export async function deleteLog(logId: number): Promise { await fetch(`${BASE}/logs/${logId}`, { method: 'DELETE' }) } export interface AuthStatus { required: boolean authenticated: boolean } export async function fetchAuthStatus(): Promise { const res = await fetch(`${BASE}/api/auth/status`) return parseJson(res, '認証状態取得') } export async function login(password: string): Promise { const res = await fetch(`${BASE}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), }) return parseJson(res, 'ログイン') } export async function logout(): Promise { await fetch(`${BASE}/api/auth/logout`, { method: 'POST' }) } export interface SettingEntry { key: string is_set: boolean masked: string | null overridden: boolean } function authHeader(password: string): HeadersInit { return { Authorization: `Basic ${btoa(`x:${password}`)}` } } export async function fetchSettings(password: string): Promise { const res = await fetch(`${BASE}/api/settings`, { headers: authHeader(password) }) return parseJson(res, '設定取得') } export async function saveSettings(values: Record, password: string): Promise { const res = await fetch(`${BASE}/api/settings`, { method: 'PUT', headers: { ...authHeader(password), 'Content-Type': 'application/json' }, body: JSON.stringify({ values }), }) return parseJson(res, '設定保存') } export async function clearSetting(key: string, password: string): Promise { const res = await fetch(`${BASE}/api/settings/${key}`, { method: 'DELETE', headers: authHeader(password), }) return parseJson(res, '設定クリア') } export interface ModelPreset { id: number label: string value: string } export async function fetchModelPresets(): Promise { const res = await fetch(`${BASE}/api/models`) return parseJson(res, 'モデル一覧取得') } export async function createModelPreset(label: string, value: string, password: string): Promise { const res = await fetch(`${BASE}/api/models`, { method: 'POST', headers: { ...authHeader(password), 'Content-Type': 'application/json' }, body: JSON.stringify({ label, value }), }) return parseJson(res, 'モデル追加') } export async function updateModelPreset(id: number, label: string, value: string, password: string): Promise { const res = await fetch(`${BASE}/api/models/${id}`, { method: 'PUT', headers: { ...authHeader(password), 'Content-Type': 'application/json' }, body: JSON.stringify({ label, value }), }) return parseJson(res, 'モデル更新') } export async function deleteModelPreset(id: number, password: string): Promise { await fetch(`${BASE}/api/models/${id}`, { method: 'DELETE', headers: authHeader(password) }) } export function streamChat( message: string, sessionId: string | null, model: string, onChunk: (chunk: SSEChunk) => void, onError: (err: Error) => void, ): () => void { const controller = new AbortController() fetch(`${BASE}/chat/stream`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message, session_id: sessionId, model }), signal: controller.signal, }) .then(async (res) => { if (!res.body) throw new Error('No response body') const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' let finished = false while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n\n') buffer = lines.pop() ?? '' for (const line of lines) { if (line.startsWith('data: ')) { try { const parsed: SSEChunk = JSON.parse(line.slice(6)) if (parsed.type === 'done' || parsed.type === 'error') finished = true onChunk(parsed) } catch (e) { console.error('[SSE] JSON parse error:', e, '| raw:', line) } } } } // サーバーが done/error を送らずに接続を終了した場合(再起動・切断など)はストリーミング状態が // 固定されたままになるので、ここで明示的にエラーとして扱い入力をロック解除する if (!finished) { onError(new Error('接続が予期せず終了しました。もう一度お試しください。')) } }) .catch((err) => { if (err.name !== 'AbortError') onError(err) }) return () => controller.abort() } export interface NotePage { id: number title: string content: string position: number section: string | null updated_at: string } export async function fetchNotePages(): Promise { const res = await fetch(`${BASE}/api/notes`) return parseJson(res, 'ノート取得') } export async function createNotePage(title: string, section?: string | null): Promise { const res = await fetch(`${BASE}/api/notes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, section: section ?? null }), }) return parseJson(res, 'ページ作成') } export async function updateNotePage( pageId: number, patch: { title?: string; content?: string; section?: string | null }, ): Promise { const res = await fetch(`${BASE}/api/notes/${pageId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }) return parseJson(res, 'ページ更新') } export async function deleteNotePage(pageId: number): Promise { await fetch(`${BASE}/api/notes/${pageId}`, { method: 'DELETE' }) } /** 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`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ old_name: oldName, new_name: newName }), }) return parseJson<{ renamed: number }>(res, 'セクション名変更') } /** * タブのドラッグ&ドロップによる並べ替えをサーバーへ反映する。 * order には全ページIDをドラッグ後の新しい順序で渡す。pageId のページは section へ移動する * (ドロップ先のタブと同じセクションを渡せば、その場でセクション間の移動を兼ねる)。 */ export async function reorderNotePages( pageId: number, section: string | null, order: number[], ): Promise<{ reordered: number }> { const res = await fetch(`${BASE}/api/notes/reorder`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ page_id: pageId, section, order }), }) return parseJson<{ reordered: number }>(res, 'ノート並べ替え') }