diff --git a/CLAUDE.md b/CLAUDE.md index 1317f5d..ee8e510 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte **要約**: `maybe_summarize()` がアシスタント応答の保存直後に走り、メッセージ数が `MEMORY_SUMMARIZE_THRESHOLD`(既定 20)以上になると古い半分を LLM で要約して `memories` に保存する。**要約した元メッセージは `archived_at` を立ててアーカイブされる**(物理削除はしない)。アーカイブ済みメッセージは `get_context_messages()` と要約の閾値判定の両方から除外されるため LLM には渡らないが、DB には残り、履歴表示・エクスポートからは参照できる。`memory_id` にどの要約に取り込まれたかを保持する。 -**遅延読み込み**: 重い情報は目次だけ注入し、`llm.resolve_tool_calls()` が本番生成の前にツール(`web_search` / `read_note` / `write_note` / `read_knowledge`、最大 2 ラウンド)を解決する。Web 検索の結果は構造化要約にされ `knowledge_entries` と `knowledge_base/web_search/*.md` の両方に保存されるため、次回以降は層 1・層 3 として自動的に効いてくる。 +**遅延読み込み**: 重い情報は目次だけ注入し、`llm.stream_tool_calls()`(結果だけ欲しい場合は同等のラッパー `resolve_tool_calls()`)が本番生成の前にツール(`web_search` / `read_note` / `write_note` / `read_knowledge`、最大 2 ラウンド)を解決する。前者は実行の開始・完了を逐次 yield し、`api/chat.py` がそれを SSE の `tool` イベントとして中継して GUI に進行状況を出す。Web 検索の結果は構造化要約にされ `knowledge_entries` と `knowledge_base/web_search/*.md` の両方に保存されるため、次回以降は層 1・層 3 として自動的に効いてくる。 **付随機能**: `memory_kb/tagging.py` が各メッセージ・要約にバックグラウンドでタグを 3 つ付ける(コンテキストには注入せず、Discord 雑談 Bot の話題選びに使う)。`memory_kb/word_memory.py` は Janome で抽出した名詞を `deque(maxlen=200)` にプロセス内保持する(DB 非永続。起動時に直近 200 件から再シード)。 @@ -101,7 +101,7 @@ LLM に渡すコンテキストは `app/core/memory_kb/memory.py` の `get_conte ### SSE ストリーミング -`POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `sources`(Web 検索の出典)/ `notes_updated`(AI が共有ノートを更新)/ `done`(完了)/ `error`(エラー)。 +`POST /chat/stream` が SSE を返す。イベント種別: `session_id`(新規セッション ID)/ `chunk`(テキスト断片)/ `tool`(ツール実行の開始・完了)/ `sources`(Web 検索の出典)/ `notes_updated`(AI が共有ノートを更新)/ `done`(完了)/ `error`(エラー)。 フロントは `src/api/client.ts` の `streamChat()` で fetch + ReadableStream により受信し、`messages.value[msgIdx]` を reactive proxy 経由で更新する。 ### Markdown レンダリング diff --git a/backend/app/api/chat.py b/backend/app/api/chat.py index 0c63980..364b125 100644 --- a/backend/app/api/chat.py +++ b/backend/app/api/chat.py @@ -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 diff --git a/backend/app/core/llm.py b/backend/app/core/llm.py index 2818a87..69fed5b 100644 --- a/backend/app/core/llm.py +++ b/backend/app/core/llm.py @@ -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 diff --git a/docs/api.md b/docs/api.md index ffcb827..cc3ebf3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -24,6 +24,8 @@ SSE でストリーミング応答を返す。 |---|---|---| | `session_id` | セッション UUID | 新規セッション作成直後 | | `chunk` | テキスト断片 | LLM トークン生成ごと | +| `tool` | `{id, name, label, detail, status, result}` | ツール実行の開始時 (`status: running`) と完了時 (`status: done`)。同じ `id` で 2 回届くのでフロントはマージして表示する | +| `sources` | `[{title, url}]` | Web 検索した時のみ(出典一覧) | | `notes_updated` | — | AI が共有ノートを書き換えた直後 | | `done` | — | 正常完了 | | `error` | エラーメッセージ | 例外発生時 | diff --git a/docs/architecture.md b/docs/architecture.md index 4407178..c94bcc0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -146,6 +146,7 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag ``` data: {"type":"session_id","value":""} # 新規セッション時のみ +data: {"type":"tool","value":{...}} # ツール実行ごとに開始・完了の2回 data: {"type":"notes_updated"} # AIが共有ノートを更新した時のみ data: {"type":"sources","value":[{...}]} # Web検索した時のみ(出典の title/url) data: {"type":"chunk","value":"テキスト断片"} # 0回以上 @@ -153,6 +154,13 @@ data: {"type":"done"} # 正常完了 data: {"type":"error","value":"エラー内容"} # 例外発生時 ``` +`tool` イベントは `llm.stream_tool_calls()`(`resolve_tool_calls()` の逐次版)が yield する進行状況を +そのまま中継したもので、`{id, name, label, detail, status}` で始まり、同じ `id` の +`{id, status:"done", result}` で終わる。検索結果の要約・蓄積知識への保存も +`id: "save_knowledge"` の同形式のイベントとして送る。フロント(`ToolActivityList.vue`)は +`id` をキーにマージし、アシスタントバブルの上に実行中スピナーつきで一覧表示する。 +表示専用なので DB には保存せず、履歴を読み直すと消える。 + バッファリング防止のため `Cache-Control: no-cache` と `X-Accel-Buffering: no` を付与する。 --- diff --git a/docs/features.md b/docs/features.md index bd7030d..c0b5e17 100644 --- a/docs/features.md +++ b/docs/features.md @@ -46,11 +46,14 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は ## 1. チャット - `POST /chat/stream` が SSE を返し、フロントは fetch + ReadableStream で受けて 1 文字ずつ描画する。 - イベントは `session_id` / `chunk` / `sources` / `notes_updated` / `done` / `error` の 6 種。 + イベントは `session_id` / `chunk` / `tool` / `sources` / `notes_updated` / `done` / `error` の 7 種。 - アシスタントの発言は `marked` + `DOMPurify` で描画する。ストリーミング途中で閉じていないコードフェンスは 自動で補完してから描画するので、生成中でもレイアウトが崩れない。 - セッションは左サイドバーの一覧から切り替え・削除できる。タイトルは最初の発言の先頭 40 文字。 - Web 検索が走った応答には出典(`sources`)が、各メッセージには自動生成されたタグが添えて表示される。 +- Web 検索・ノートの読み書き・蓄積知識の読み出しといったツール実行は、バブルの上に + 「🔍 Web検索 / クエリ / ✓ 5件の結果」の形でリアルタイムに表示される(`ToolActivityList.vue`)。 + 実行中はスピナーが回り、応答が途中で切れたツールは「⚠ 中断されました」になる。表示専用で永続化はしない。 - 失敗時は赤いバブルとトースト通知の両方で知らせる。 ## 2. セッションの書き出し / 取り込み diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a1bcade..236fe53 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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 = '' diff --git a/frontend/src/components/ChatWindow.vue b/frontend/src/components/ChatWindow.vue index b71ca52..bed187c 100644 --- a/frontend/src/components/ChatWindow.vue +++ b/frontend/src/components/ChatWindow.vue @@ -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" > +
diff --git a/frontend/src/components/ToolActivityList.vue b/frontend/src/components/ToolActivityList.vue new file mode 100644 index 0000000..0c64660 --- /dev/null +++ b/frontend/src/components/ToolActivityList.vue @@ -0,0 +1,77 @@ + + + + + diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts index 8e73a6c..84ef6cf 100644 --- a/frontend/src/stores/chat.ts +++ b/frontend/src/stores/chat.ts @@ -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