Show tool and web search activity in the chat UI
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>
This commit is contained in:
@@ -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 = ''
|
||||
|
||||
@@ -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">
|
||||
<!-- ユーザーメッセージはプレーンテキスト -->
|
||||
|
||||
@@ -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>
|
||||
@@ -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,6 +11,8 @@ export interface Message {
|
||||
archived?: boolean
|
||||
tags?: string[]
|
||||
sources?: Source[]
|
||||
/** この応答を作る際に実行したツール(Web検索・ノート読み書きなど)。表示専用で永続化はしない。 */
|
||||
tools?: ToolActivity[]
|
||||
}
|
||||
|
||||
export interface Toast {
|
||||
@@ -102,6 +104,26 @@ export const useChatStore = defineStore('chat', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
@@ -122,14 +144,18 @@ export const useChatStore = defineStore('chat', () => {
|
||||
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 +163,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
|
||||
|
||||
Reference in New Issue
Block a user