Files
playchat/backend/app/api/chat.py
T
colcaroliandClaude Opus 5 366320c43a 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>
2026-08-23 15:11:00 +09:00

296 lines
10 KiB
Python

import asyncio
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse, JSONResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session as DBSession
from app.db.database import get_db
from app.db.models import Session, Message, MessageTag
from app.core.memory_kb import memory as mem
from app.core import llm
from app.core.memory_kb import knowledge
from app.core.memory_kb import local_knowledge
from app.core.notes import NOTE_TOOLS
from app.core.search.websearch import WEB_SEARCH_TOOL
from app.core.config import DEFAULT_MODEL
from app.core.memory_kb.tagging import tag_message_by_id
from app.core.memory_kb.word_memory import record_message
router = APIRouter(prefix="/chat", tags=["chat"])
class ChatRequest(BaseModel):
session_id: str | None = None
message: str
model: str | None = None
class SessionResponse(BaseModel):
id: str
title: str
model: str
updated_at: datetime
class Config:
from_attributes = True
class MessageResponse(BaseModel):
id: int
role: str
content: str
created_at: datetime
# 要約に取り込まれ、LLMには渡らなくなったメッセージ。人間の履歴確認用に残してある。
archived_at: datetime | None = None
tags: list[str] = []
class Config:
from_attributes = True
class ImportMessage(BaseModel):
role: str
content: str
created_at: datetime | None = None
archived_at: datetime | None = None
tags: list[str] = []
class ImportSessionMeta(BaseModel):
title: str | None = None
model: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
class ImportRequest(BaseModel):
session: ImportSessionMeta = ImportSessionMeta()
messages: list[ImportMessage]
@router.get("/sessions", response_model=list[SessionResponse])
def list_sessions(db: DBSession = Depends(get_db)):
return db.query(Session).order_by(Session.updated_at.desc()).all()
@router.get("/sessions/{session_id}/messages", response_model=list[MessageResponse])
def get_messages(session_id: str, db: DBSession = Depends(get_db)):
session = db.get(Session, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
return [
MessageResponse(
id=m.id,
role=m.role,
content=m.content,
created_at=m.created_at,
archived_at=m.archived_at,
tags=[t.tag for t in m.tags],
)
for m in session.messages
]
@router.get("/sessions/{session_id}/export")
def export_session(session_id: str, db: DBSession = Depends(get_db)):
"""セッションのメタデータと全メッセージをJSONファイルとしてダウンロードする。"""
session = db.get(Session, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
data = {
"session": {
"id": session.id,
"title": session.title,
"model": session.model,
"created_at": session.created_at.isoformat(),
"updated_at": session.updated_at.isoformat(),
},
"messages": [
{
"id": m.id,
"role": m.role,
"content": m.content,
"created_at": m.created_at.isoformat(),
"archived_at": m.archived_at.isoformat() if m.archived_at else None,
"tags": [t.tag for t in m.tags],
}
for m in session.messages
],
}
return JSONResponse(
content=data,
headers={"Content-Disposition": f'attachment; filename="session_{session.id}.json"'},
)
@router.post("/sessions/import", response_model=SessionResponse, status_code=201)
def import_session(req: ImportRequest, db: DBSession = Depends(get_db)):
"""export で出力したJSON(またはその形式に沿ったJSON)から新規セッションを作成する。"""
if not req.messages:
raise HTTPException(status_code=400, detail="messages is empty")
for m in req.messages:
if m.role not in ("user", "assistant", "system"):
raise HTTPException(status_code=400, detail=f"invalid role: {m.role}")
title = req.session.title or req.messages[0].content[:40]
session = Session(
id=str(uuid.uuid4()),
title=title,
model=req.session.model or DEFAULT_MODEL,
)
if req.session.created_at:
session.created_at = req.session.created_at
session.updated_at = req.session.updated_at or datetime.now(timezone.utc)
db.add(session)
db.flush()
for m in req.messages:
msg = Message(session_id=session.id, role=m.role, content=m.content, archived_at=m.archived_at)
if m.created_at:
msg.created_at = m.created_at
db.add(msg)
db.flush()
for tag in m.tags:
db.add(MessageTag(message_id=msg.id, tag=tag))
db.commit()
db.refresh(session)
return session
@router.delete("/sessions/{session_id}", status_code=204)
def delete_session(session_id: str, db: DBSession = Depends(get_db)):
session = db.get(Session, session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
db.delete(session)
db.commit()
@router.post("/knowledge/regenerate")
async def regenerate_knowledge(db: DBSession = Depends(get_db)):
"""既存のKnowledgeEntryを出典URLから取得し直し、構造化要約に再生成する(一括実行用)。"""
count = await knowledge.regenerate_from_sources(db)
return {"regenerated": count}
@router.post("/stream")
async def stream_chat(req: ChatRequest, db: DBSession = Depends(get_db)):
# セッション取得 or 新規作成
if req.session_id:
session = db.get(Session, req.session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
else:
session = Session(
id=str(uuid.uuid4()),
title=req.message[:40],
model=req.model or DEFAULT_MODEL,
)
db.add(session)
db.commit()
# ユーザーメッセージを保存
user_msg = Message(session_id=session.id, role="user", content=req.message)
db.add(user_msg)
db.commit()
db.refresh(user_msg)
record_message(req.message)
asyncio.create_task(tag_message_by_id(user_msg.id))
context = await mem.get_context_messages(db, session.id)
async def generate():
import json as _json
def sse(payload: dict) -> str:
return f"data: {_json.dumps(payload, ensure_ascii=False)}\n\n"
# セッションIDをまず返す(新規セッション時にフロントへ通知)
yield sse({"type": "session_id", "value": session.id})
used_model = req.model or session.model
full_response = []
try:
# ツールの実行状況は 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"})
if searches:
sources = [
{"title": r["title"], "url": r["url"]}
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
async for chunk in llm.stream_chat(
resolved_context, model=used_model, session_id=session.id, tools=used_tools,
):
full_response.append(chunk)
yield sse({"type": "chunk", "value": chunk})
# アシスタントメッセージを保存
assistant_content = "".join(full_response)
assistant_msg = Message(session_id=session.id, role="assistant", content=assistant_content)
db.add(assistant_msg)
session.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(assistant_msg)
record_message(assistant_content)
asyncio.create_task(tag_message_by_id(assistant_msg.id))
# メモリ要約チェック
await mem.maybe_summarize(db, session)
yield sse({"type": "done"})
except Exception as e:
yield sse({"type": "error", "value": str(e)})
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)