diff --git a/src/features/ai-assistant/chat-run-manager.ts b/src/features/ai-assistant/chat-run-manager.ts index d27fdb4c..19bff98a 100644 --- a/src/features/ai-assistant/chat-run-manager.ts +++ b/src/features/ai-assistant/chat-run-manager.ts @@ -20,7 +20,7 @@ import type { ChatMessage, GraphiumDocument, ScopeChat } from "../../lib/document-types"; -export type ChatRunStatus = "running" | "done" | "error"; +export type ChatRunStatus = "running" | "done" | "error" | "aborted"; export type ChatRunSnapshot = { runId: string; @@ -74,6 +74,8 @@ export type ChatRunApplyHandle = { applyResult: (run: ChatRunState) => void; /** error した run をライブ store に反映する(保存はしない) */ applyError: (run: ChatRunState) => void; + /** 中断(aborted)した run をライブ store に反映する。応答なしで会話を確定し loading 解除・保存する */ + applyAborted: (run: ChatRunState) => void; /** ファイル書き戻し後の doc.chats を store のチャット一覧へ反映する */ refreshChats: (doc: GraphiumDocument) => void; }; @@ -81,8 +83,18 @@ export type ChatRunApplyHandle = { /** run が完了(done / error)したときに呼ばれるリスナー */ type ChatRunListener = (run: ChatRunState) => void; +/** fetch / AI SDK が中断時に投げる AbortError を判定する(DOMException / 非 DOM 環境の両対応) */ +function isAbortError(err: unknown): boolean { + if (typeof DOMException !== "undefined" && err instanceof DOMException) { + return err.name === "AbortError"; + } + return typeof err === "object" && err !== null && (err as { name?: string }).name === "AbortError"; +} + class ChatRunManager { private runs = new Map(); + // 実行中 run の中断ハンドル(Stop ボタン用)。settle / consume で掃除する。 + private controllers = new Map(); private listeners = new Set(); private claimedIds = new Set(); @@ -91,19 +103,54 @@ class ChatRunManager { * リスナーに通知される。exec 側はエラーを表示用文言(localizeAiError 済み)の * Error に変換して throw する契約。 */ - start(snapshot: ChatRunSnapshot, exec: () => Promise): void { + start( + snapshot: ChatRunSnapshot, + exec: (signal: AbortSignal) => Promise, + ): void { + // 各 run に中断ハンドルを紐づける。Stop ボタン → abort(runId) で + // controller.abort() し、exec の fetch(signal 経由)とサーバー側 LLM 呼び出し + // (リクエスト切断で発火)の両方を止める。 + const controller = new AbortController(); + this.controllers.set(snapshot.runId, controller); const run: ChatRunState = { ...snapshot, status: "running" }; this.runs.set(snapshot.runId, run); - void exec().then( + void exec(controller.signal).then( (result) => this.settle(snapshot.runId, { status: "done", result }), - (err: unknown) => - this.settle(snapshot.runId, { - status: "error", - errorMessage: err instanceof Error ? err.message : String(err), - }), + (err: unknown) => { + // ユーザーの中断(Stop)は「エラー」ではなく "aborted" として確定する。 + // exec 側で AbortError を握りつぶした場合の保険に signal.aborted も見る。 + if (isAbortError(err) || controller.signal.aborted) { + this.settle(snapshot.runId, { status: "aborted" }); + } else { + this.settle(snapshot.runId, { + status: "error", + errorMessage: err instanceof Error ? err.message : String(err), + }); + } + }, ); } + /** + * 実行中の run を中断する(Stop ボタン)。running でなければ何もしない。 + * controller.abort() が exec の fetch を止め、その reject が settle("aborted") を発火する。 + */ + abort(runId: string): void { + const controller = this.controllers.get(runId); + const run = this.runs.get(runId); + if (!controller || !run || run.status !== "running") return; + controller.abort(); + } + + /** 指定チャット(activeChatId)宛の実行中 run をすべて中断する */ + abortRunsForChat(chatId: string): void { + for (const run of this.runs.values()) { + if (run.chatId === chatId && run.status === "running") { + this.abort(run.runId); + } + } + } + /** 未採番だった run に、オートセーブで確定したノートのフルキーを紐づける */ assignNoteId(runId: string, noteId: string): void { const run = this.runs.get(runId); @@ -146,6 +193,7 @@ class ChatRunManager { consume(runId: string): void { this.runs.delete(runId); this.claimedIds.delete(runId); + this.controllers.delete(runId); } /** 完了通知を購読する。返り値で解除 */ @@ -159,16 +207,22 @@ class ChatRunManager { /** テスト用: 全状態を破棄する */ reset(): void { this.runs.clear(); + this.controllers.clear(); this.listeners.clear(); this.claimedIds.clear(); } private settle( runId: string, - outcome: { status: "done"; result: ChatRunResult } | { status: "error"; errorMessage: string }, + outcome: + | { status: "done"; result: ChatRunResult } + | { status: "error"; errorMessage: string } + | { status: "aborted" }, ): void { const run = this.runs.get(runId); if (!run) return; // consume 済み(リロード間際など) + // 中断ハンドルは用済み(このあと done/error/aborted で確定)。参照を残さない。 + this.controllers.delete(runId); const settled: ChatRunState = { ...run, ...outcome }; this.runs.set(runId, settled); for (const listener of this.listeners) { diff --git a/src/features/ai-assistant/panel.tsx b/src/features/ai-assistant/panel.tsx index f5a4b911..c963b5ab 100644 --- a/src/features/ai-assistant/panel.tsx +++ b/src/features/ai-assistant/panel.tsx @@ -2,7 +2,7 @@ // 右パネルの Chat タブに表示される継続対話 UI import { Children, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import { Bot, BookPlus, Send, Trash2, FileDown, FilePlus, List, Replace, AlertCircle, X, AtSign, Info, Lightbulb, Sparkles, Loader2, Check, Pencil, RotateCcw, GitFork } from "lucide-react"; +import { Bot, BookPlus, Send, Square, Trash2, FileDown, FilePlus, List, Replace, AlertCircle, X, AtSign, Info, Lightbulb, Sparkles, Loader2, Check, Pencil, RotateCcw, GitFork } from "lucide-react"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; import { Button } from "@ui/button"; @@ -36,6 +36,8 @@ type AiAssistantPanelProps = { scope?: GroundingScope, rewindIndex?: number, ) => void; + /** 実行中の AI 応答を中断する(Stop ボタン)。未指定なら停止 UI は出さない */ + onStop?: () => void; /** 指定メッセージまで(含む)を引き継いだ新チャットに分岐する */ onForkChat?: (index: number) => void; /** AI 回答をスコープ内にブロックとして挿入する */ @@ -64,6 +66,7 @@ type AiAssistantPanelProps = { export function AiAssistantPanel({ onSubmit, + onStop, onForkChat, onInsertToScope, onReplaceBlocks, @@ -541,14 +544,27 @@ export function AiAssistantPanel({ rows={2} className="flex-1 text-xs resize-none" /> - + {loading && onStop ? ( + + ) : ( + + )} {/* 3 セグメントのチップは縮まないため、320px 級の狭幅では 2 行目に折り返す */}
diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 7367fb30..fcc88acc 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -639,6 +639,7 @@ export const en: Record = { // ── AI チャット追加 ── "aiChat.helpText": "Ask AI about this page, or select a block for focused questions.\nCmd+Enter to send", "aiChat.thinking": "Thinking...", + "aiChat.stop": "Stop", "aiChat.sendHint": "Cmd+Enter to send", "aiChat.newChat": "+ New chat", "aiChat.noBackend": "AI features require running Graphium with Docker or local development setup. They are not available on GitHub Pages.", diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 40bc95e2..b8649c7b 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -639,6 +639,7 @@ export const ja: Record = { // ── AI チャット追加 ── "aiChat.helpText": "ページ全体や選択ブロックについて AI に質問できます。\nCmd+Enter で送信", "aiChat.thinking": "考え中...", + "aiChat.stop": "停止", "aiChat.sendHint": "Cmd+Enter で送信", "aiChat.newChat": "+ 新しいチャット", "aiChat.noBackend": "AI 機能を使うには Docker またはローカル開発環境で Graphium を起動してください。GitHub Pages では利用できません。", diff --git a/src/note-app.tsx b/src/note-app.tsx index f30f9991..d1b36774 100644 --- a/src/note-app.tsx +++ b/src/note-app.tsx @@ -2330,9 +2330,9 @@ function NoteEditorInner({ baseMessages, userMessage: userChatMessage, }, - async () => { + async (signal) => { try { - const response = await runAgent(req); + const response = await runAgent(req, signal); // Wiki コンテキストが使われた場合、引用情報を処理する。 // 番号引用 [#N] / タイトル引用 / 全角【】を正規の [Source: "title"] に揃え、 // hallucination を除去して、末尾に「Knowledge referenced」一覧を付ける。 @@ -2382,6 +2382,9 @@ function NoteEditorInner({ sessionId: response.session_id, }; } catch (err) { + // ユーザーが Stop した場合は中断(AbortError)。エラー文言に変換せず + // そのまま投げ直し、manager 側に "aborted" と判定させる(エラー表示しない)。 + if (signal.aborted) throw err; // 既知の code(NO_MODEL_REGISTERED / 401 系)は i18n 文言に変換し、 // manager には表示用文言を message に持つ Error として渡す契約 throw new Error(localizeAiError(err)); @@ -2407,6 +2410,13 @@ function NoteEditorInner({ [aiAssistant, markDirty], ); + // AI チャット停止(Stop ボタン): 現在アクティブな会話の実行中 run を中断する。 + // fetch abort + サーバー側 LLM 停止で、無駄なトークン消費を止めて入力に戻れる。 + const handleAiChatStop = useCallback(() => { + const chatId = aiAssistant.activeChatId; + if (chatId) chatRunManager.abortRunsForChat(chatId); + }, [aiAssistant.activeChatId]); + // Composer 結果をドキュメント末尾にブロックとして挿入するヘルパー。 // Compose / Insert PROV で共通利用。scope は意図的に気にせず常に末尾挿入(Composer の呼び出し点は // グローバルで、ブロック選択スコープに紐付かないため、末尾が最も予測可能)。 @@ -3078,6 +3088,19 @@ function NoteEditorInner({ { keepLoading: hasOtherRunningRun(run) }, ); }, + applyAborted: (run) => { + // 中断はエラーではない。応答(assistant)なしで会話を確定し loading を解除する。 + // buildRunScopeChat は status !== "done" のとき user メッセージまでを返すので、 + // applyChatRunResult に通せば「質問だけ」の会話になる。sessionId は送信時のものを + // 維持し(result が無いので run.sessionId)、markDirty で保存に乗せる。 + const h = chatRunHandlersRef.current; + const existing = h.aiAssistant.chats.find((c) => c.id === run.chatId) ?? null; + const chat = buildRunScopeChat(run, existing); + h.aiAssistant.applyChatRunResult(chat, run.sessionId, { + keepLoading: hasOtherRunningRun(run), + }); + h.markDirty(); + }, refreshChats: (doc) => { if (doc.chats && doc.chats.length > 0) { chatRunHandlersRef.current.aiAssistant.restoreChats(doc.chats); @@ -4123,6 +4146,7 @@ function NoteEditorInner({ {rightTab === "chat" && ( { maxSteps: body.options?.max_turns ?? 10, feature: "agent.chat", modelConfig, + // クライアントが Stop で接続を切ると発火する。LLM 呼び出しを打ち切ってトークン消費を止める。 + abortSignal: c.req.raw.signal, }); // 検索 MCP(Tavily 等)のツール結果から web 出典を決定論的に抽出する。 @@ -216,6 +218,11 @@ app.post("/run", async (c) => { model: result.model, }); } catch (err) { + // クライアントが Stop で接続を切った場合(AbortError)はエラーではない。 + // レスポンスは届かない(fetch は既に abort 済み)ので、ログを汚さず静かに返す。 + if (err instanceof Error && err.name === "AbortError") { + return c.json({ error: "aborted" }, 408); + } console.error("Agent run error:", err); // runAgentLoop 由来の CodedError(認証エラー等)は code を JSON に通す return c.json(errorBody(err), 500); diff --git a/src/server/services/agent-loop-text-tools.ts b/src/server/services/agent-loop-text-tools.ts index feef78b7..646607be 100644 --- a/src/server/services/agent-loop-text-tools.ts +++ b/src/server/services/agent-loop-text-tools.ts @@ -25,7 +25,7 @@ type ParsedCall = { const TOOL_CALL_REGEX = /\s*([\s\S]*?)\s*<\/tool_call>/g; export async function runTextToolsLoop(params: AgentRunParams): Promise { - const { model, modelId, systemPrompt, messages, tools, maxSteps = 10, feature, modelConfig } = params; + const { model, modelId, systemPrompt, messages, tools, maxSteps = 10, feature, modelConfig, abortSignal } = params; const toolMap: Record = (tools ?? {}) as Record; const toolNames = Object.keys(toolMap); @@ -48,10 +48,13 @@ export async function runTextToolsLoop(params: AgentRunParams): Promise 0 ? { tools: tools as any } : {}), stopWhen: stepCountIs(maxSteps), + ...(abortSignal ? { abortSignal } : {}), }); const durationMs = Date.now() - startedAt;