Feature/tool activity and notes #3

Merged
colcaroli merged 4 commits from feature/tool-activity-and-notes into main 2026-08-23 07:06:36 +00:00
10 changed files with 236 additions and 14 deletions
Showing only changes of commit 366320c43a - Show all commits
+2 -2
View File
@@ -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` にどの要約に取り込まれたかを保持する。 **要約**: `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 件から再シード)。 **付随機能**: `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 ストリーミング ### 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 経由で更新する。 フロントは `src/api/client.ts` の `streamChat()` で fetch + ReadableStream により受信し、`messages.value[msgIdx]` を reactive proxy 経由で更新する。
### Markdown レンダリング ### Markdown レンダリング
+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 used_model = req.model or session.model
full_response = [] full_response = []
try: 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, 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: if notes_changed:
# AIがノートを書き換えたので、フロントのノートパネルを再読み込みさせる # AIがノートを書き換えたので、フロントのノートパネルを再読み込みさせる
yield sse({"type": "notes_updated"}) 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"] for s in searches for r in s["results"]
] ]
yield sse({"type": "sources", "value": sources}) 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: for s in searches:
headline, summary = await knowledge.summarize_results(s["query"], s["results"]) headline, summary = await knowledge.summarize_results(s["query"], s["results"])
if summary: if summary:
knowledge.save_knowledge(db, s["query"], summary, s["results"], headline=headline) knowledge.save_knowledge(db, s["query"], summary, s["results"], headline=headline)
local_knowledge.save_search_result(s["query"], summary, s["results"]) 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が含まれる場合、最終呼び出しにも同じツール定義を渡す必要がある # 履歴にtool_callsが含まれる場合、最終呼び出しにも同じツール定義を渡す必要がある
used_tools = [WEB_SEARCH_TOOL, *NOTE_TOOLS] if len(resolved_context) > len(context) else None 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] 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], messages: list[dict],
model: str | None = None, model: str | None = None,
session_id: str | None = None, session_id: str | None = None,
db: "DBSession | None" = None, db: "DBSession | None" = None,
max_rounds: int = 2, max_rounds: int = 2,
) -> tuple[list[dict], list[dict], bool]: ) -> AsyncIterator[dict]:
"""必要に応じてLLMにツールを呼ばせ、その結果を会話に組み込んだ上でメッセージ列を返す。 """必要に応じてLLMにツールを呼ばせ、その進行状況をイベントとして逐次 yield する。
扱うツールは web_search、共有ノート(read_note / write_note)、蓄積知識(read_knowledge)。 扱うツールは web_search、共有ノート(read_note / write_note)、蓄積知識(read_knowledge)。
DBを見るものは db が渡されたときだけ提示する。モデルがtool callingに対応していない場合は DBを見るものは db が渡されたときだけ提示する。モデルがtool callingに対応していない場合は
何もせず元のmessagesをそのまま返す。 何もせず元の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 しているため、 # app.core.memory_kb.knowledge は app.core.llm を import しているため、
# モジュールレベルで読むと循環 import になる(_save_log と同じ理由で関数内 import する)。 # モジュールレベルで読むと循環 import になる(_save_log と同じ理由で関数内 import する)。
@@ -142,7 +168,8 @@ async def resolve_tool_calls(
) )
except Exception: except Exception:
logger.info("Model %s does not support tool calling; skipping tools", used_model) 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 choice_message = response.choices[0].message
tool_calls = getattr(choice_message, "tool_calls", None) tool_calls = getattr(choice_message, "tool_calls", None)
@@ -169,19 +196,35 @@ async def resolve_tool_calls(
args = {} args = {}
name = tc.function.name 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: if name in ("read_note", "write_note") and db is not None:
tool_text, changed = execute_note_tool(db, name, args) tool_text, changed = execute_note_tool(db, name, args)
notes_changed = notes_changed or changed notes_changed = notes_changed or changed
result = _first_line(tool_text)
elif name == "read_knowledge" and db is not None: elif name == "read_knowledge" and db is not None:
tool_text = execute_knowledge_tool(db, name, args) tool_text = execute_knowledge_tool(db, name, args)
result = _first_line(tool_text)
else: else:
query = args.get("query", "") query = args.get("query", "")
results = await search_web(query) results = await search_web(query)
if results: if results:
tool_text = "\n\n".join(f"[{r['title']}]({r['url']})\n{r['content']}" for r in results) tool_text = "\n\n".join(f"[{r['title']}]({r['url']})\n{r['content']}" for r in results)
searches.append({"query": query, "results": results}) searches.append({"query": query, "results": results})
result = f"{len(results)}件の結果"
else: else:
tool_text = "検索結果が見つかりませんでした。" tool_text = "検索結果が見つかりませんでした。"
result = "結果なし"
yield {"type": "tool", "id": tc.id, "status": "done", "result": result}
working_messages.append({ working_messages.append({
"role": "tool", "role": "tool",
@@ -189,4 +232,27 @@ async def resolve_tool_calls(
"content": tool_text, "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
+2
View File
@@ -24,6 +24,8 @@ SSE でストリーミング応答を返す。
|---|---|---| |---|---|---|
| `session_id` | セッション UUID | 新規セッション作成直後 | | `session_id` | セッション UUID | 新規セッション作成直後 |
| `chunk` | テキスト断片 | LLM トークン生成ごと | | `chunk` | テキスト断片 | LLM トークン生成ごと |
| `tool` | `{id, name, label, detail, status, result}` | ツール実行の開始時 (`status: running`) と完了時 (`status: done`)。同じ `id` で 2 回届くのでフロントはマージして表示する |
| `sources` | `[{title, url}]` | Web 検索した時のみ(出典一覧) |
| `notes_updated` | — | AI が共有ノートを書き換えた直後 | | `notes_updated` | — | AI が共有ノートを書き換えた直後 |
| `done` | — | 正常完了 | | `done` | — | 正常完了 |
| `error` | エラーメッセージ | 例外発生時 | | `error` | エラーメッセージ | 例外発生時 |
+8
View File
@@ -146,6 +146,7 @@ LLM に渡すコンテキストは `memory_kb/memory.py` の `get_context_messag
``` ```
data: {"type":"session_id","value":"<uuid>"} # 新規セッション時のみ data: {"type":"session_id","value":"<uuid>"} # 新規セッション時のみ
data: {"type":"tool","value":{...}} # ツール実行ごとに開始・完了の2回
data: {"type":"notes_updated"} # AIが共有ノートを更新した時のみ data: {"type":"notes_updated"} # AIが共有ノートを更新した時のみ
data: {"type":"sources","value":[{...}]} # Web検索した時のみ(出典の title/url) data: {"type":"sources","value":[{...}]} # Web検索した時のみ(出典の title/url)
data: {"type":"chunk","value":"テキスト断片"} # 0回以上 data: {"type":"chunk","value":"テキスト断片"} # 0回以上
@@ -153,6 +154,13 @@ data: {"type":"done"} # 正常完了
data: {"type":"error","value":"エラー内容"} # 例外発生時 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` を付与する。 バッファリング防止のため `Cache-Control: no-cache` と `X-Accel-Buffering: no` を付与する。
--- ---
+4 -1
View File
@@ -46,11 +46,14 @@ Discord 連携は [discord-bot.md](discord-bot.md)、ノートの操作方法は
## 1. チャット ## 1. チャット
- `POST /chat/stream` が SSE を返し、フロントは fetch + ReadableStream で受けて 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` で描画する。ストリーミング途中で閉じていないコードフェンスは - アシスタントの発言は `marked` + `DOMPurify` で描画する。ストリーミング途中で閉じていないコードフェンスは
自動で補完してから描画するので、生成中でもレイアウトが崩れない。 自動で補完してから描画するので、生成中でもレイアウトが崩れない。
- セッションは左サイドバーの一覧から切り替え・削除できる。タイトルは最初の発言の先頭 40 文字。 - セッションは左サイドバーの一覧から切り替え・削除できる。タイトルは最初の発言の先頭 40 文字。
- Web 検索が走った応答には出典(`sources`)が、各メッセージには自動生成されたタグが添えて表示される。 - Web 検索が走った応答には出典(`sources`)が、各メッセージには自動生成されたタグが添えて表示される。
- Web 検索・ノートの読み書き・蓄積知識の読み出しといったツール実行は、バブルの上に
「🔍 Web検索 / クエリ / ✓ 5件の結果」の形でリアルタイムに表示される(`ToolActivityList.vue`)。
実行中はスピナーが回り、応答が途中で切れたツールは「⚠ 中断されました」になる。表示専用で永続化はしない。
- 失敗時は赤いバブルとトースト通知の両方で知らせる。 - 失敗時は赤いバブルとトースト通知の両方で知らせる。
## 2. セッションの書き出し / 取り込み ## 2. セッションの書き出し / 取り込み
+16 -2
View File
@@ -21,9 +21,23 @@ export interface Source {
url: string 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 { export interface SSEChunk {
type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' | 'notes_updated' type: 'session_id' | 'chunk' | 'done' | 'error' | 'sources' | 'notes_updated' | 'tool'
value?: string | Source[] value?: string | Source[] | ToolActivity
} }
const BASE = '' const BASE = ''
+2
View File
@@ -2,6 +2,7 @@
import { computed, ref, watch, nextTick } from 'vue' import { computed, ref, watch, nextTick } from 'vue'
import { useChatStore } from '../stores/chat' import { useChatStore } from '../stores/chat'
import MarkdownContent from './MarkdownContent.vue' import MarkdownContent from './MarkdownContent.vue'
import ToolActivityList from './ToolActivityList.vue'
import type { Source } from '../api/client' import type { Source } from '../api/client'
const store = useChatStore() const store = useChatStore()
@@ -43,6 +44,7 @@ watch(
:class="['message-wrap', msg.role, { archived: msg.archived }]" :class="['message-wrap', msg.role, { archived: msg.archived }]"
:title="msg.archived ? '要約済み: 内容は長期メモリに引き継がれ、この発言自体はモデルに渡りません' : undefined" :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="['message', msg.role, { error: msg.error }]">
<div class="bubble"> <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>
+28 -1
View File
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { useNotesStore } from './notes' import { useNotesStore } from './notes'
import { ref, computed } from 'vue' 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 { export interface Message {
role: 'user' | 'assistant' role: 'user' | 'assistant'
@@ -11,6 +11,8 @@ export interface Message {
archived?: boolean archived?: boolean
tags?: string[] tags?: string[]
sources?: Source[] sources?: Source[]
/** この応答を作る際に実行したツール(Web検索・ノート読み書きなど)。表示専用で永続化はしない。 */
tools?: ToolActivity[]
} }
export interface Toast { 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 { function sendMessage(text: string): void {
if (isStreaming.value || !text.trim()) return if (isStreaming.value || !text.trim()) return
@@ -122,14 +144,18 @@ export const useChatStore = defineStore('chat', () => {
messages.value[msgIdx].content += chunk.value as string messages.value[msgIdx].content += chunk.value as string
} else if (chunk.type === 'sources' && chunk.value) { } else if (chunk.type === 'sources' && chunk.value) {
messages.value[msgIdx].sources = chunk.value as Source[] 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') { } else if (chunk.type === 'notes_updated') {
// AIが共有ノートを書き換えたので、開いているノートパネルに反映する // AIが共有ノートを書き換えたので、開いているノートパネルに反映する
useNotesStore().load().catch((e) => pushToast(`ノート更新失敗: ${e.message}`)) useNotesStore().load().catch((e) => pushToast(`ノート更新失敗: ${e.message}`))
} else if (chunk.type === 'done') { } else if (chunk.type === 'done') {
settleTools(msgIdx)
messages.value[msgIdx].pending = false messages.value[msgIdx].pending = false
isStreaming.value = false isStreaming.value = false
loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`)) loadSessions().catch((e) => pushToast(`セッション更新失敗: ${e.message}`))
} else if (chunk.type === 'error') { } else if (chunk.type === 'error') {
settleTools(msgIdx)
messages.value[msgIdx].content = (chunk.value as string) ?? 'バックエンドでエラーが発生しました。' messages.value[msgIdx].content = (chunk.value as string) ?? 'バックエンドでエラーが発生しました。'
messages.value[msgIdx].pending = false messages.value[msgIdx].pending = false
messages.value[msgIdx].error = true messages.value[msgIdx].error = true
@@ -137,6 +163,7 @@ export const useChatStore = defineStore('chat', () => {
} }
}, },
(err) => { (err) => {
settleTools(msgIdx)
// 途中まで届いていた内容は残しつつ、エラーメッセージを付記する // 途中まで届いていた内容は残しつつ、エラーメッセージを付記する
const partial = messages.value[msgIdx].content const partial = messages.value[msgIdx].content
messages.value[msgIdx].content = partial ? `${partial}\n\n⚠️ ${err.message}` : err.message messages.value[msgIdx].content = partial ? `${partial}\n\n⚠️ ${err.message}` : err.message