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:
@@ -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 レンダリング
|
||||
|
||||
+25
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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` | エラーメッセージ | 例外発生時 |
|
||||
|
||||
@@ -146,6 +146,7 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag
|
||||
|
||||
```
|
||||
data: {"type":"session_id","value":"<uuid>"} # 新規セッション時のみ
|
||||
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` を付与する。
|
||||
|
||||
---
|
||||
|
||||
+4
-1
@@ -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. セッションの書き出し / 取り込み
|
||||
|
||||
@@ -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