Feature/tool activity and notes #3
@@ -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