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:
2026-08-23 15:11:00 +09:00
co-authored by Claude Opus 5
parent 8cac5d0de0
commit 366320c43a
10 changed files with 236 additions and 14 deletions
+25 -2
View File
@@ -216,9 +216,18 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
used_model = req.model or session.model
full_response = []
try:
resolved_context, searches, notes_changed = await llm.resolve_tool_calls(
# ツールの実行状況は tool イベントとしてそのままフロントへ流し、GUIに進行状況を出す
resolved_context, searches, notes_changed = context, [], False
async for event in llm.stream_tool_calls(
context, model=used_model, session_id=session.id, db=db,
)
):
if event["type"] == "resolved":
resolved_context = event["messages"]
searches = event["searches"]
notes_changed = event["notes_changed"]
else:
yield sse({"type": "tool", "value": {k: v for k, v in event.items() if k != "type"}})
if notes_changed:
# AIがノートを書き換えたので、フロントのノートパネルを再読み込みさせる
yield sse({"type": "notes_updated"})
@@ -228,11 +237,25 @@ async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
for s in searches for r in s["results"]
]
yield sse({"type": "sources", "value": sources})
yield sse({"type": "tool", "value": {
"id": "save_knowledge",
"name": "save_knowledge",
"label": "検索結果を要約して保存",
"detail": " / ".join(s["query"] for s in searches),
"status": "running",
}})
saved = 0
for s in searches:
headline, summary = await knowledge.summarize_results(s["query"], s["results"])
if summary:
knowledge.save_knowledge(db, s["query"], summary, s["results"], headline=headline)
local_knowledge.save_search_result(s["query"], summary, s["results"])
saved += 1
yield sse({"type": "tool", "value": {
"id": "save_knowledge",
"status": "done",
"result": f"{saved}件を蓄積知識に保存" if saved else "保存できる要約なし",
}})
# 履歴にtool_callsが含まれる場合、最終呼び出しにも同じツール定義を渡す必要がある
used_tools = [WEB_SEARCH_TOOL, *NOTE_TOOLS] if len(resolved_context) > len(context) else None
+72 -6
View File
@@ -107,19 +107,45 @@ async def embed(texts: list[str], model: str) -> list[list[float]]:
return [item["embedding"] for item in response.data]
async def resolve_tool_calls(
def _tool_display(name: str, args: dict) -> tuple[str, str]:
"""ツール呼び出しをGUI表示用の (ラベル, 詳細) に変換する。"""
if name == "web_search":
return "Web検索", args.get("query", "")
if name == "read_note":
return "ノートを読む", args.get("title", "")
if name == "write_note":
label = "ノートを上書き" if args.get("mode") == "replace" else "ノートに追記"
return label, args.get("title", "")
if name == "read_knowledge":
return "蓄積知識を読む", args.get("query", "")
return name, ""
def _first_line(text: str, limit: int = 80) -> str:
"""ツール結果の要点だけをGUIに出すため、1行目を切り出して長さを詰める。"""
line = text.strip().splitlines()[0] if text.strip() else ""
return line[:limit] + "…" if len(line) > limit else line
async def stream_tool_calls(
messages: list[dict],
model: str | None = None,
session_id: str | None = None,
db: "DBSession | None" = None,
max_rounds: int = 2,
) -> tuple[list[dict], list[dict], bool]:
"""必要に応じてLLMにツールを呼ばせ、その結果を会話に組み込んだ上でメッセージ列を返す。
) -> AsyncIterator[dict]:
"""必要に応じてLLMにツールを呼ばせ、その進行状況をイベントとして逐次 yield する。
扱うツールは web_search、共有ノート(read_note / write_note)、蓄積知識(read_knowledge)。
DBを見るものは db が渡されたときだけ提示する。モデルがtool callingに対応していない場合は
何もせず元のmessagesをそのまま返す。
戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト, ノートを変更したか)
yield するイベント:
- {"type": "tool", "id", "name", "label", "detail", "status": "running"} … 実行開始
- {"type": "tool", "id", "status": "done", "result"} … 実行完了(GUI用の短い結果つき)
- {"type": "resolved", "messages", "searches", "notes_changed"} … 最後に必ず1回
最後の resolved イベントが従来の戻り値にあたる(resolve_tool_calls がそれを取り出す)。
"""
# app.core.memory_kb.knowledge は app.core.llm を import しているため、
# モジュールレベルで読むと循環 import になる(_save_log と同じ理由で関数内 import する)。
@@ -142,7 +168,8 @@ async def resolve_tool_calls(
)
except Exception:
logger.info("Model %s does not support tool calling; skipping tools", used_model)
return messages, searches, notes_changed
yield {"type": "resolved", "messages": messages, "searches": searches, "notes_changed": notes_changed}
return
choice_message = response.choices[0].message
tool_calls = getattr(choice_message, "tool_calls", None)
@@ -169,19 +196,35 @@ async def resolve_tool_calls(
args = {}
name = tc.function.name
label, detail = _tool_display(name, args)
yield {
"type": "tool",
"id": tc.id,
"name": name,
"label": label,
"detail": detail,
"status": "running",
}
if name in ("read_note", "write_note") and db is not None:
tool_text, changed = execute_note_tool(db, name, args)
notes_changed = notes_changed or changed
result = _first_line(tool_text)
elif name == "read_knowledge" and db is not None:
tool_text = execute_knowledge_tool(db, name, args)
result = _first_line(tool_text)
else:
query = args.get("query", "")
results = await search_web(query)
if results:
tool_text = "\n\n".join(f"[{r['title']}]({r['url']})\n{r['content']}" for r in results)
searches.append({"query": query, "results": results})
result = f"{len(results)}件の結果"
else:
tool_text = "検索結果が見つかりませんでした。"
result = "結果なし"
yield {"type": "tool", "id": tc.id, "status": "done", "result": result}
working_messages.append({
"role": "tool",
@@ -189,4 +232,27 @@ async def resolve_tool_calls(
"content": tool_text,
})
return working_messages, searches, notes_changed
yield {
"type": "resolved",
"messages": working_messages,
"searches": searches,
"notes_changed": notes_changed,
}
async def resolve_tool_calls(
messages: list[dict],
model: str | None = None,
session_id: str | None = None,
db: "DBSession | None" = None,
max_rounds: int = 2,
) -> tuple[list[dict], list[dict], bool]:
"""stream_tool_calls の進行状況イベントを捨て、最終結果だけを返すラッパー。
戻り値: (拡張後のmessages, 実際に検索したクエリと結果のリスト, ノートを変更したか)
"""
resolved = (messages, [], False)
async for event in stream_tool_calls(messages, model, session_id, db, max_rounds):
if event["type"] == "resolved":
resolved = (event["messages"], event["searches"], event["notes_changed"])
return resolved