Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 63 additions & 9 deletions src/features/ai-assistant/chat-run-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -74,15 +74,27 @@ 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;
};

/** 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<string, ChatRunState>();
// 実行中 run の中断ハンドル(Stop ボタン用)。settle / consume で掃除する。
private controllers = new Map<string, AbortController>();
private listeners = new Set<ChatRunListener>();
private claimedIds = new Set<string>();

Expand All @@ -91,19 +103,54 @@ class ChatRunManager {
* リスナーに通知される。exec 側はエラーを表示用文言(localizeAiError 済み)の
* Error に変換して throw する契約。
*/
start(snapshot: ChatRunSnapshot, exec: () => Promise<ChatRunResult>): void {
start(
snapshot: ChatRunSnapshot,
exec: (signal: AbortSignal) => Promise<ChatRunResult>,
): 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);
Expand Down Expand Up @@ -146,6 +193,7 @@ class ChatRunManager {
consume(runId: string): void {
this.runs.delete(runId);
this.claimedIds.delete(runId);
this.controllers.delete(runId);
}

/** 完了通知を購読する。返り値で解除 */
Expand All @@ -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) {
Expand Down
34 changes: 25 additions & 9 deletions src/features/ai-assistant/panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -36,6 +36,8 @@ type AiAssistantPanelProps = {
scope?: GroundingScope,
rewindIndex?: number,
) => void;
/** 実行中の AI 応答を中断する(Stop ボタン)。未指定なら停止 UI は出さない */
onStop?: () => void;
/** 指定メッセージまで(含む)を引き継いだ新チャットに分岐する */
onForkChat?: (index: number) => void;
/** AI 回答をスコープ内にブロックとして挿入する */
Expand Down Expand Up @@ -64,6 +66,7 @@ type AiAssistantPanelProps = {

export function AiAssistantPanel({
onSubmit,
onStop,
onForkChat,
onInsertToScope,
onReplaceBlocks,
Expand Down Expand Up @@ -541,14 +544,27 @@ export function AiAssistantPanel({
rows={2}
className="flex-1 text-xs resize-none"
/>
<Button
size="sm"
onClick={handleSubmit}
disabled={loading || !input.trim()}
className="self-end"
>
<Send size={12} />
</Button>
{loading && onStop ? (
<Button
size="sm"
variant="outline"
onClick={onStop}
title={t("aiChat.stop")}
aria-label={t("aiChat.stop")}
className="self-end"
>
<Square size={12} className="fill-current" />
</Button>
) : (
<Button
size="sm"
onClick={handleSubmit}
disabled={loading || !input.trim()}
className="self-end"
>
<Send size={12} />
</Button>
)}
</div>
{/* 3 セグメントのチップは縮まないため、320px 級の狭幅では 2 行目に折り返す */}
<div className="text-xs text-muted-foreground mt-2 flex flex-wrap items-center justify-between gap-3">
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,7 @@ export const en: Record<string, string> = {
// ── 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.",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,7 @@ export const ja: Record<string, string> = {
// ── AI チャット追加 ──
"aiChat.helpText": "ページ全体や選択ブロックについて AI に質問できます。\nCmd+Enter で送信",
"aiChat.thinking": "考え中...",
"aiChat.stop": "停止",
"aiChat.sendHint": "Cmd+Enter で送信",
"aiChat.newChat": "+ 新しいチャット",
"aiChat.noBackend": "AI 機能を使うには Docker またはローカル開発環境で Graphium を起動してください。GitHub Pages では利用できません。",
Expand Down
36 changes: 31 additions & 5 deletions src/note-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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」一覧を付ける。
Expand Down Expand Up @@ -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));
Expand All @@ -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 の呼び出し点は
// グローバルで、ブロック選択スコープに紐付かないため、末尾が最も予測可能)。
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -4123,6 +4146,7 @@ function NoteEditorInner({
{rightTab === "chat" && (
<AiAssistantPanel
onSubmit={handleAiChatSubmit}
onStop={handleAiChatStop}
onForkChat={handleAiChatFork}
onInsertToScope={handleInsertToScope}
onReplaceBlocks={handleReplaceBlocks}
Expand Down Expand Up @@ -4508,6 +4532,7 @@ export function NoteApp() {
if (apply && (run.noteId ? apply.noteId === run.noteId : apply.ownsRun(run.runId))) {
if (!chatRunManager.claim(run.runId)) return;
if (run.status === "done") apply.applyResult(run);
else if (run.status === "aborted") apply.applyAborted(run);
else apply.applyError(run);
chatRunManager.consume(run.runId);
return;
Expand All @@ -4519,9 +4544,10 @@ export function NoteApp() {
chatRunManager.consume(run.runId);
return;
}
// 3) エラーはファイルに書き戻さない(エラーを保存しない現行仕様に合わせる)。
// manager に残し、元ノートの再マウント時に表示して消費する
if (run.status === "error") return;
// 3) エラー・中断はファイルに書き戻さない(エラーを保存しない現行仕様に合わせる)。
// アクティブノートで止めた場合は case 1 の applyAborted が markDirty 経由で
// 保存する。切替後に止めた稀ケースはここへ来るが、破棄で許容する。
if (run.status === "error" || run.status === "aborted") return;
// 4) 対象ノートが一覧・アセットビューのサイドピークで開かれている間は保留する。
// ピークの doSave(docRef spread)と書き戻しの load→save が交錯すると、
// 直前のピーク保存の本文が巻き戻るため。ピークが閉じたら effect 再実行
Expand Down
7 changes: 7 additions & 0 deletions src/server/routes/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ app.post("/run", async (c) => {
maxSteps: body.options?.max_turns ?? 10,
feature: "agent.chat",
modelConfig,
// クライアントが Stop で接続を切ると発火する。LLM 呼び出しを打ち切ってトークン消費を止める。
abortSignal: c.req.raw.signal,
});

// 検索 MCP(Tavily 等)のツール結果から web 出典を決定論的に抽出する。
Expand All @@ -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);
Expand Down
5 changes: 4 additions & 1 deletion src/server/services/agent-loop-text-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ type ParsedCall = {
const TOOL_CALL_REGEX = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;

export async function runTextToolsLoop(params: AgentRunParams): Promise<AgentRunResult> {
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<string, AnyTool> = (tools ?? {}) as Record<string, AnyTool>;
const toolNames = Object.keys(toolMap);
Expand All @@ -48,10 +48,13 @@ export async function runTextToolsLoop(params: AgentRunParams): Promise<AgentRun
const startedAt = Date.now();

for (let step = 0; step < maxSteps; step += 1) {
// 中断されたらツール実行の合間でも離脱する(次ステップの LLM 呼び出しを避ける)
if (abortSignal?.aborted) break;
const r = await generateText({
model,
system: augmentedSystem,
messages: currentMessages,
...(abortSignal ? { abortSignal } : {}),
});
const tokens = extractTokenFields(r.usage);
totalInput += tokens.inputTokens;
Expand Down
5 changes: 4 additions & 1 deletion src/server/services/agent-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export type AgentRunParams = {
modelConfig?: ModelConfig;
/** サンプリング温度。0 で実行間のブレを抑える(翻訳など決定性が欲しい用途向け)。未指定はモデル既定。 */
temperature?: number;
/** 中断シグナル。ユーザーが Stop したとき LLM 呼び出しを打ち切る(無駄なトークン消費を止める)。 */
abortSignal?: AbortSignal;
};

export type AgentRunResult = {
Expand Down Expand Up @@ -110,7 +112,7 @@ async function runAgentLoopInner(params: AgentRunParams): Promise<AgentRunResult
systemPrompt: toWellFormed(params.systemPrompt),
messages: sanitizeMessages(params.messages),
};
const { model, modelId, systemPrompt, messages, tools, maxSteps = 10, feature, modelConfig, temperature } = safeParams;
const { model, modelId, systemPrompt, messages, tools, maxSteps = 10, feature, modelConfig, temperature, abortSignal } = safeParams;

// openai-compatible(sakura / gpt-oss-120b 等)に加え、claude-subscription
// (ai-sdk-provider-claude-code 経由)も AI SDK の tools パラメータをネイティブに
Expand All @@ -137,6 +139,7 @@ async function runAgentLoopInner(params: AgentRunParams): Promise<AgentRunResult
// tools が空の場合は undefined にする
...(tools && Object.keys(tools).length > 0 ? { tools: tools as any } : {}),
stopWhen: stepCountIs(maxSteps),
...(abortSignal ? { abortSignal } : {}),
});
const durationMs = Date.now() - startedAt;

Expand Down
Loading