Tool calls were invisible until the answer arrived: the only trace was the sources list, and only for web searches. Surface them as they happen instead. Add llm.stream_tool_calls(), an incremental version of resolve_tool_calls() that yields a start and a completion event per tool call and ends with a "resolved" event carrying the previous return value. resolve_tool_calls() is kept as a thin wrapper that drops the progress events. api/chat.py relays those events as a new SSE "tool" event, and wraps the search-summary/knowledge-save step in one of the same shape, since it is a noticeable pause of its own. On the front end, ToolActivityList.vue renders one row per tool above the assistant bubble (label, target, spinner while running, short result when done). The store merges events by id and marks anything still running as interrupted when the stream ends early. Display only - not persisted, so the rows disappear when the session is reloaded, same as sources. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
344 lines
11 KiB
TypeScript
344 lines
11 KiB
TypeScript
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<T>(res: Response, label: string): Promise<T> {
|
|
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<SessionInfo[]> {
|
|
const res = await fetch(`${BASE}/chat/sessions`)
|
|
return parseJson<SessionInfo[]>(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<MessageRecord[]> {
|
|
const res = await fetch(`${BASE}/chat/sessions/${sessionId}/messages`)
|
|
return parseJson<MessageRecord[]>(res, 'メッセージ取得')
|
|
}
|
|
|
|
export async function deleteSession(sessionId: string): Promise<void> {
|
|
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<SessionInfo> {
|
|
const res = await fetch(`${BASE}/chat/sessions/import`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
return parseJson<SessionInfo>(res, 'インポート')
|
|
}
|
|
|
|
export async function fetchLogs(params: {
|
|
session_id?: string
|
|
status?: 'ok' | 'error'
|
|
limit?: number
|
|
offset?: number
|
|
} = {}): Promise<ApiLog[]> {
|
|
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<ApiLog[]>(res, 'ログ取得')
|
|
}
|
|
|
|
export async function deleteLog(logId: number): Promise<void> {
|
|
await fetch(`${BASE}/logs/${logId}`, { method: 'DELETE' })
|
|
}
|
|
|
|
export interface AuthStatus {
|
|
required: boolean
|
|
authenticated: boolean
|
|
}
|
|
|
|
export async function fetchAuthStatus(): Promise<AuthStatus> {
|
|
const res = await fetch(`${BASE}/api/auth/status`)
|
|
return parseJson<AuthStatus>(res, '認証状態取得')
|
|
}
|
|
|
|
export async function login(password: string): Promise<AuthStatus> {
|
|
const res = await fetch(`${BASE}/api/auth/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ password }),
|
|
})
|
|
return parseJson<AuthStatus>(res, 'ログイン')
|
|
}
|
|
|
|
export async function logout(): Promise<void> {
|
|
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<SettingEntry[]> {
|
|
const res = await fetch(`${BASE}/api/settings`, { headers: authHeader(password) })
|
|
return parseJson<SettingEntry[]>(res, '設定取得')
|
|
}
|
|
|
|
export async function saveSettings(values: Record<string, string>, password: string): Promise<SettingEntry[]> {
|
|
const res = await fetch(`${BASE}/api/settings`, {
|
|
method: 'PUT',
|
|
headers: { ...authHeader(password), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ values }),
|
|
})
|
|
return parseJson<SettingEntry[]>(res, '設定保存')
|
|
}
|
|
|
|
export async function clearSetting(key: string, password: string): Promise<SettingEntry[]> {
|
|
const res = await fetch(`${BASE}/api/settings/${key}`, {
|
|
method: 'DELETE',
|
|
headers: authHeader(password),
|
|
})
|
|
return parseJson<SettingEntry[]>(res, '設定クリア')
|
|
}
|
|
|
|
export interface ModelPreset {
|
|
id: number
|
|
label: string
|
|
value: string
|
|
}
|
|
|
|
export async function fetchModelPresets(): Promise<ModelPreset[]> {
|
|
const res = await fetch(`${BASE}/api/models`)
|
|
return parseJson<ModelPreset[]>(res, 'モデル一覧取得')
|
|
}
|
|
|
|
export async function createModelPreset(label: string, value: string, password: string): Promise<ModelPreset> {
|
|
const res = await fetch(`${BASE}/api/models`, {
|
|
method: 'POST',
|
|
headers: { ...authHeader(password), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ label, value }),
|
|
})
|
|
return parseJson<ModelPreset>(res, 'モデル追加')
|
|
}
|
|
|
|
export async function updateModelPreset(id: number, label: string, value: string, password: string): Promise<ModelPreset> {
|
|
const res = await fetch(`${BASE}/api/models/${id}`, {
|
|
method: 'PUT',
|
|
headers: { ...authHeader(password), 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ label, value }),
|
|
})
|
|
return parseJson<ModelPreset>(res, 'モデル更新')
|
|
}
|
|
|
|
export async function deleteModelPreset(id: number, password: string): Promise<void> {
|
|
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<NotePage[]> {
|
|
const res = await fetch(`${BASE}/api/notes`)
|
|
return parseJson<NotePage[]>(res, 'ノート取得')
|
|
}
|
|
|
|
export async function createNotePage(title: string, section?: string | null): Promise<NotePage> {
|
|
const res = await fetch(`${BASE}/api/notes`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title, section: section ?? null }),
|
|
})
|
|
return parseJson<NotePage>(res, 'ページ作成')
|
|
}
|
|
|
|
export async function updateNotePage(
|
|
pageId: number,
|
|
patch: { title?: string; content?: string; section?: string | null },
|
|
): Promise<NotePage> {
|
|
const res = await fetch(`${BASE}/api/notes/${pageId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(patch),
|
|
})
|
|
return parseJson<NotePage>(res, 'ページ更新')
|
|
}
|
|
|
|
export async function deleteNotePage(pageId: number): Promise<void> {
|
|
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, 'ノート並べ替え')
|
|
}
|