simple-ai-sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,814 @@
1
+ import type {
2
+ ChatSession,
3
+ StreamManagerListener,
4
+ StreamManagerState,
5
+ } from "./types.js";
6
+ import type { Message, ChatRequest, JSONValue } from "../shared/index.js";
7
+ import { generateId } from "../shared/generate-id.js";
8
+ import { isAbortError } from "../shared/error-utils.js";
9
+
10
+ export interface IStreamManager {
11
+ subscribe(listener: StreamManagerListener): () => void;
12
+ getSnapshot(): StreamManagerState;
13
+ getSession(sessionId: string): ChatSession | undefined;
14
+ initSession(sessionId: string, initialMessages?: Message[]): void;
15
+ /**
16
+ * セッション単位で最新の onFinish ハンドラを登録する。
17
+ * - ストリーム完了時は常にこの最新ハンドラを呼び出す。
18
+ * - コンポーネントがアンマウントされても保持され、完了時に呼ばれる。
19
+ */
20
+ setOnFinishHandler(
21
+ sessionId: string,
22
+ handler: ((messages: Message[]) => void) | undefined
23
+ ): void;
24
+ /**
25
+ * セッション単位で最新の onMetadata ハンドラを登録する。
26
+ */
27
+ setOnMetadataHandler(
28
+ sessionId: string,
29
+ handler: ((metadata: Record<string, JSONValue>) => void) | undefined
30
+ ): void;
31
+ /**
32
+ * セッション単位で最新の onError ハンドラを登録する。
33
+ */
34
+ setOnErrorHandler(
35
+ sessionId: string,
36
+ handler: ((error: Error) => void) | undefined
37
+ ): void;
38
+ /**
39
+ * セッションに購読者を関連付ける(マウント時に呼ぶ)。
40
+ */
41
+ retainSession(sessionId: string): void;
42
+ /**
43
+ * セッションから購読者を外す(アンマウント時に呼ぶ)。
44
+ */
45
+ releaseSession(sessionId: string): void;
46
+ /**
47
+ * セッションの最終アクセス時刻を更新する(LRU用)。
48
+ * レンダリング中に呼ばれる可能性があるため、通知は行わない。
49
+ */
50
+ touchSession(sessionId: string): void;
51
+ updateSession(sessionId: string, updates: Partial<ChatSession>): void;
52
+ streamChat(
53
+ sessionId: string,
54
+ messages: Message[],
55
+ options: {
56
+ api: string;
57
+ body?: Record<string, JSONValue>;
58
+ }
59
+ ): Promise<void>;
60
+ stopStream(sessionId: string): void;
61
+ /**
62
+ * ライフサイクル終了時のクリーンアップ用。
63
+ * SimpleAIProvider から呼び出される想定。
64
+ */
65
+ dispose(): void;
66
+ }
67
+
68
+ export type StreamManagerOptions = {
69
+ maxSessions?: number;
70
+ debug?: boolean;
71
+ /**
72
+ * TTL(ミリ秒)を過ぎてアクセスのない ready/error セッションを自動掃除する。
73
+ * 指定しない場合は自動掃除を行わない。
74
+ */
75
+ ttlMs?: number;
76
+ };
77
+
78
+ class StreamManager implements IStreamManager {
79
+ private state: StreamManagerState = {
80
+ sessions: new Map(),
81
+ };
82
+
83
+ private listeners = new Set<StreamManagerListener>();
84
+ // setTimeout の戻り値型に依存しないための型指定
85
+ // rAF-based batching for frequent updates
86
+ private rafScheduled = false;
87
+ private rafId: number | null = null;
88
+ private rafFallbackTimer: ReturnType<typeof setTimeout> | null = null;
89
+ // Typed accessors for rAF/cAF without relying on DOM types
90
+ private getRAF(): ((cb: (t: number) => void) => number) | undefined {
91
+ const g = globalThis as { requestAnimationFrame?: unknown };
92
+ return typeof g.requestAnimationFrame === "function"
93
+ ? (g.requestAnimationFrame as (cb: (t: number) => void) => number)
94
+ : undefined;
95
+ }
96
+ private getCAF(): ((id: number) => void) | undefined {
97
+ const g = globalThis as { cancelAnimationFrame?: unknown };
98
+ return typeof g.cancelAnimationFrame === "function"
99
+ ? (g.cancelAnimationFrame as (id: number) => void)
100
+ : undefined;
101
+ }
102
+ private maxSessions: number;
103
+ private debug: boolean;
104
+ private ttlMs?: number;
105
+ private sweepTimer: ReturnType<typeof setInterval> | null = null;
106
+ // 各セッションの購読者数(同一 sessionId を複数コンポーネントが参照するケースに対応)
107
+ private activeSubscribers = new Map<string, number>();
108
+ // セッション単位で「最新の onFinish」を保持(常に最新を呼びたい要件に対応)
109
+ private latestOnFinish = new Map<string, (messages: Message[]) => void>();
110
+ // セッション単位で「最新の onMetadata」を保持
111
+ private latestOnMetadata = new Map<
112
+ string,
113
+ (metadata: Record<string, JSONValue>) => void
114
+ >();
115
+ // セッション単位で「最新の onError」を保持
116
+ private latestOnError = new Map<string, (error: Error) => void>();
117
+ // 意図: 各セッションの最新ストリームIDを保持し、古いストリームの遅延イベントを無視する
118
+ private streamIds = new Map<string, number>();
119
+
120
+ constructor(options: StreamManagerOptions = {}) {
121
+ this.maxSessions = options.maxSessions ?? 5;
122
+ this.debug = options.debug ?? false;
123
+ this.ttlMs = options.ttlMs;
124
+
125
+ // TTL が設定されている場合、一定間隔で古いセッションを掃除する
126
+ const sweepEnabled = this.ttlMs && this.ttlMs > 0;
127
+ if (sweepEnabled) {
128
+ this.sweepTimer = setInterval(() => {
129
+ if (this.debug)
130
+ console.info("[SM] Running TTL sweep", this.state.sessions);
131
+ if (this.state.sessions.size === 0) return;
132
+ const now = Date.now();
133
+ let removed = false; // 意図: TTL 掃除でセッションが削除された時のみ通知して無駄な再レンダリングを回避
134
+ for (const [id, session] of this.state.sessions) {
135
+ // ストリーミング中のセッションは対象外
136
+ if (session.status === "streaming" || session.status === "submitted")
137
+ continue;
138
+ // 購読者がいないセッションのみ TTL 対象
139
+ const subCount = this.activeSubscribers.get(id) ?? 0;
140
+ if (subCount > 0) continue;
141
+ if (now - session.lastAccessed > this.ttlMs!) {
142
+ // 安全のため abortController が残っていれば中断
143
+ try {
144
+ session.abortController?.abort();
145
+ } catch (err) {
146
+ if (this.debug) {
147
+ console.warn("[SM] abort error during TTL sweep", err);
148
+ }
149
+ }
150
+ this.state.sessions.delete(id);
151
+ // 意図: ハンドラと streamId も破棄してリークを防止
152
+ this.latestOnFinish.delete(id);
153
+ this.latestOnMetadata.delete(id);
154
+ this.latestOnError.delete(id);
155
+ this.streamIds.delete(id);
156
+ removed = true;
157
+ if (this.debug) {
158
+ this.logSessionInfo(`Session removed by TTL GC: ${id}`);
159
+ }
160
+ }
161
+ }
162
+ // 意図: 実際に削除があった場合のみ通知して、不要な通知を抑制
163
+ if (removed) this.notify(true);
164
+ }, this.ttlMs);
165
+ }
166
+ }
167
+
168
+ // 状態変更を監視するリスナーを登録する
169
+ // 戻り値は登録解除用の関数(Reactのクリーンアップで使用)
170
+ subscribe(listener: StreamManagerListener): () => void {
171
+ this.listeners.add(listener);
172
+ return () => {
173
+ this.listeners.delete(listener);
174
+ };
175
+ }
176
+
177
+ setOnFinishHandler(
178
+ sessionId: string,
179
+ handler: ((messages: Message[]) => void) | undefined
180
+ ) {
181
+ if (handler) {
182
+ this.latestOnFinish.set(sessionId, handler);
183
+ } else {
184
+ this.latestOnFinish.delete(sessionId);
185
+ }
186
+ }
187
+
188
+ setOnMetadataHandler(
189
+ sessionId: string,
190
+ handler: ((metadata: Record<string, JSONValue>) => void) | undefined
191
+ ) {
192
+ if (handler) {
193
+ this.latestOnMetadata.set(sessionId, handler);
194
+ } else {
195
+ this.latestOnMetadata.delete(sessionId);
196
+ }
197
+ }
198
+
199
+ setOnErrorHandler(
200
+ sessionId: string,
201
+ handler: ((error: Error) => void) | undefined
202
+ ) {
203
+ if (handler) {
204
+ this.latestOnError.set(sessionId, handler);
205
+ } else {
206
+ this.latestOnError.delete(sessionId);
207
+ }
208
+ }
209
+
210
+ // 現在の状態のスナップショットを返す
211
+ // useSyncExternalStoreが状態を取得する際に使用
212
+ getSnapshot(): StreamManagerState {
213
+ return this.state;
214
+ }
215
+
216
+ // リスナーに状態変更を通知する
217
+ // immediate=true の場合は即座に、false の場合は rAF で 1 フレームにまとめて通知
218
+ private notify(immediate = false) {
219
+ // 新しいstateオブジェクトを作成して参照を変更
220
+ // これによりuseSyncExternalStoreが変更を検知できる
221
+ this.state = {
222
+ ...this.state,
223
+ sessions: new Map(this.state.sessions),
224
+ };
225
+
226
+ if (immediate) {
227
+ // 重要な状態変更(ステータス変更など)は即座に通知
228
+ if (this.rafId != null) {
229
+ const caf = this.getCAF();
230
+ if (caf) {
231
+ try {
232
+ caf(this.rafId);
233
+ } catch {
234
+ // ignore
235
+ }
236
+ }
237
+ }
238
+ this.rafId = null;
239
+ if (this.rafFallbackTimer) {
240
+ clearTimeout(this.rafFallbackTimer);
241
+ this.rafFallbackTimer = null;
242
+ }
243
+ this.rafScheduled = false;
244
+ this.listeners.forEach((listener) => listener());
245
+ } else {
246
+ // ストリーミング中の頻繁な更新は rAF で 1 フレームにまとめる
247
+ if (this.rafScheduled) return;
248
+ this.rafScheduled = true;
249
+ const raf = this.getRAF();
250
+ if (raf) {
251
+ this.rafId = raf(() => {
252
+ this.rafId = null;
253
+ this.rafScheduled = false;
254
+ this.listeners.forEach((listener) => listener());
255
+ });
256
+ } else {
257
+ // Fallback (e.g., non-browser env) ~60fps
258
+ this.rafFallbackTimer = setTimeout(() => {
259
+ this.rafFallbackTimer = null;
260
+ this.rafScheduled = false;
261
+ this.listeners.forEach((listener) => listener());
262
+ }, 16);
263
+ }
264
+ }
265
+ }
266
+
267
+ getSession(sessionId: string): ChatSession | undefined {
268
+ const session = this.state.sessions.get(sessionId);
269
+ if (session) {
270
+ // Update last accessed time when getting a session
271
+ session.lastAccessed = Date.now();
272
+ }
273
+ return session;
274
+ }
275
+
276
+ // LRU 用に最終アクセス時刻のみを更新する(通知はしない)
277
+ touchSession(sessionId: string) {
278
+ const session = this.state.sessions.get(sessionId);
279
+ if (session) {
280
+ session.lastAccessed = Date.now();
281
+ }
282
+ }
283
+
284
+ initSession(sessionId: string, initialMessages: Message[] = []) {
285
+ if (!this.state.sessions.has(sessionId)) {
286
+ // Check if we need to remove old sessions
287
+ if (this.state.sessions.size >= this.maxSessions) {
288
+ this.removeOldestSession();
289
+ }
290
+
291
+ this.state.sessions.set(sessionId, {
292
+ id: sessionId,
293
+ messages: initialMessages,
294
+ status: "ready",
295
+ error: null,
296
+ abortController: null,
297
+ lastAccessed: Date.now(),
298
+ });
299
+
300
+ if (this.debug) {
301
+ this.logSessionInfo(`Session added: ${sessionId}`);
302
+ }
303
+
304
+ this.notify(true); // Immediate for initialization
305
+ }
306
+ if (this.debug)
307
+ console.log(
308
+ "[SM] current sessions",
309
+ this.state.sessions,
310
+ this.activeSubscribers
311
+ );
312
+ }
313
+
314
+ retainSession(sessionId: string) {
315
+ const prev = this.activeSubscribers.get(sessionId) ?? 0;
316
+ this.activeSubscribers.set(sessionId, prev + 1);
317
+ if (this.debug) {
318
+ console.info(`[SM] retainSession: ${sessionId} -> ${prev + 1}`);
319
+ }
320
+ }
321
+
322
+ releaseSession(sessionId: string) {
323
+ const prev = this.activeSubscribers.get(sessionId) ?? 0;
324
+ const next = Math.max(0, prev - 1);
325
+ if (next === 0) {
326
+ this.activeSubscribers.delete(sessionId);
327
+ } else {
328
+ this.activeSubscribers.set(sessionId, next);
329
+ }
330
+ if (this.debug) {
331
+ console.info(`[SM] releaseSession: ${sessionId} -> ${next}`);
332
+ }
333
+ }
334
+
335
+ private removeOldestSession() {
336
+ let oldestSessionId: string | null = null;
337
+ let oldestTime = Date.now();
338
+
339
+ // まずは「非ストリーミング かつ 購読者なし」の中から最古を探す
340
+ for (const [id, session] of this.state.sessions) {
341
+ const subCount = this.activeSubscribers.get(id) ?? 0;
342
+ if (
343
+ session.status !== "streaming" &&
344
+ session.status !== "submitted" &&
345
+ subCount === 0
346
+ ) {
347
+ if (session.lastAccessed < oldestTime) {
348
+ oldestTime = session.lastAccessed;
349
+ oldestSessionId = id;
350
+ }
351
+ }
352
+ }
353
+
354
+ // 上で見つからない場合は、状態・購読を問わず最古を探す(上限を強制的に守るため)
355
+ if (!oldestSessionId) {
356
+ for (const [id, session] of this.state.sessions) {
357
+ if (session.lastAccessed < oldestTime) {
358
+ oldestTime = session.lastAccessed;
359
+ oldestSessionId = id;
360
+ }
361
+ }
362
+ }
363
+
364
+ // Remove the oldest session
365
+ if (oldestSessionId) {
366
+ const session = this.state.sessions.get(oldestSessionId);
367
+ if (session?.abortController) {
368
+ session.abortController.abort();
369
+ }
370
+ this.state.sessions.delete(oldestSessionId);
371
+ // セッション破棄時はハンドラも破棄
372
+ this.latestOnFinish.delete(oldestSessionId);
373
+ this.latestOnMetadata.delete(oldestSessionId);
374
+ this.latestOnError.delete(oldestSessionId);
375
+ // 意図: streamId も破棄してメモリリーク・誤判定を防止
376
+ this.streamIds.delete(oldestSessionId);
377
+
378
+ if (this.debug) {
379
+ this.logSessionInfo(
380
+ `Session removed: ${oldestSessionId} (LRU eviction)`
381
+ );
382
+ }
383
+ }
384
+ }
385
+
386
+ private logSessionInfo(action: string) {
387
+ const sessionInfo = Array.from(this.state.sessions.entries()).map(
388
+ ([id, session]) => ({
389
+ id,
390
+ status: session.status,
391
+ messageCount: session.messages.length,
392
+ lastAccessed: new Date(session.lastAccessed).toISOString(),
393
+ hasError: !!session.error,
394
+ subscriberCount: this.activeSubscribers.get(id) ?? 0,
395
+ })
396
+ );
397
+
398
+ console.info(`[SM] ${action}`, {
399
+ totalSessions: this.state.sessions.size,
400
+ maxSessions: this.maxSessions,
401
+ sessions: sessionInfo,
402
+ });
403
+ }
404
+
405
+ updateSession(
406
+ sessionId: string,
407
+ updates: Partial<ChatSession>,
408
+ immediate = false
409
+ ) {
410
+ const session = this.state.sessions.get(sessionId);
411
+ if (session) {
412
+ // Update last accessed time
413
+ session.lastAccessed = Date.now();
414
+ Object.assign(session, updates);
415
+ // 再レンダリング抑制: 購読者が0のセッションに対する
416
+ // ストリーミング由来の message 更新では通知しない。
417
+ // (onFinish/onError等のイベントは immediate=true で通知される)
418
+ if (immediate) {
419
+ this.notify(true);
420
+ } else {
421
+ const subCount = this.activeSubscribers.get(sessionId) ?? 0;
422
+ if (subCount > 0) {
423
+ this.notify(false);
424
+ } else if (this.debug) {
425
+ // デバッグ時は抑制されたことをログ
426
+ console.info(`[SM] notify suppressed (no subscribers): ${sessionId}`);
427
+ }
428
+ }
429
+ }
430
+ }
431
+
432
+ async streamChat(
433
+ sessionId: string,
434
+ messages: Message[],
435
+ options: {
436
+ api: string;
437
+ body?: Record<string, JSONValue>;
438
+ }
439
+ ) {
440
+ const session = this.state.sessions.get(sessionId);
441
+ if (!session) return;
442
+
443
+ // 既存のストリームがあれば中断してから新規ストリームを開始する
444
+ // (同一セッションでの重複ストリーミングを防止)
445
+ if (session.abortController) {
446
+ try {
447
+ session.abortController.abort();
448
+ } catch {
449
+ console.error("[SM] Failed to abort stream during initiation");
450
+ }
451
+ }
452
+
453
+ const abortController = new AbortController();
454
+
455
+ // 意図: セッションごとの streamId をインクリメントし、古いストリームからの遅延イベントを無視するためのガードに使用
456
+ const currentStreamId = this.streamIds.get(sessionId) ?? 0;
457
+ this.streamIds.set(sessionId, currentStreamId + 1);
458
+ const streamId = currentStreamId + 1;
459
+
460
+ this.updateSession(
461
+ sessionId,
462
+ {
463
+ messages,
464
+ status: "submitted",
465
+ error: null,
466
+ abortController,
467
+ },
468
+ true
469
+ ); // Immediate for status change
470
+
471
+ try {
472
+ const requestBody: ChatRequest = {
473
+ messages,
474
+ body: options.body,
475
+ };
476
+
477
+ const response = await fetch(options.api, {
478
+ method: "POST",
479
+ headers: {
480
+ "Content-Type": "application/json",
481
+ },
482
+ body: JSON.stringify(requestBody),
483
+ signal: abortController.signal,
484
+ });
485
+
486
+ // HTTP レベルのエラーハンドリング:SSE 解析に入る前に早期失敗
487
+ if (!response.ok) {
488
+ let message = `HTTP ${response.status}`;
489
+ try {
490
+ const text = await response.text();
491
+ message += text ? `: ${text}` : "";
492
+ } catch (err) {
493
+ console.warn("[SM] response.text() error", err);
494
+ }
495
+ const err = new Error(message);
496
+ this.updateSession(
497
+ sessionId,
498
+ {
499
+ status: "error",
500
+ error: err,
501
+ abortController: null,
502
+ },
503
+ true
504
+ );
505
+ // セッションに登録された最新の onError を使用
506
+ try {
507
+ this.latestOnError.get(sessionId)?.(err);
508
+ } catch {
509
+ console.error("[SM] Failed to call onError handler");
510
+ }
511
+ return;
512
+ }
513
+
514
+ const reader = response.body?.getReader();
515
+ if (!reader) {
516
+ throw new Error("No response body");
517
+ }
518
+
519
+ const decoder = new TextDecoder();
520
+ let buffer = "";
521
+ const assistantMessage: Message = {
522
+ id: generateId({ prefix: "msg-" }),
523
+ role: "assistant",
524
+ content: "",
525
+ };
526
+
527
+ // 配列の再構築コストを下げるため、最初に一度だけ配列を作り
528
+ // 以降は assistantMessage の content を更新していく
529
+ // (Map 自体の参照を変えて通知するため、参照が同一でも UI は更新される)
530
+ const updatedMessages = [...messages, assistantMessage];
531
+ this.updateSession(
532
+ sessionId,
533
+ {
534
+ messages: updatedMessages,
535
+ status: "streaming",
536
+ },
537
+ true
538
+ ); // Immediate for status change
539
+
540
+ // SSE の標準仕様に従い、空行でイベントを区切って解析する
541
+ // - 複数行の data: をサポート
542
+ // - コメント行(先頭 ':')は無視
543
+ // - event: が無い場合はデフォルトの 'message' を想定するが、本実装ではサーバ仕様に合わせ 'chunk' 等のみ処理
544
+ let failed = false; // 意図: error イベント処理後に通常完了経路や catch を踏まないためのフラグ
545
+ let shouldTerminate = false; // 意図: done/error 受信時にループ終了を指示
546
+ while (true) {
547
+ const { done, value } = await reader.read();
548
+ if (done) break;
549
+
550
+ buffer += decoder.decode(value, { stream: true });
551
+ const lines = buffer.split("\n");
552
+ buffer = lines.pop() || ""; // 末尾の未完成行をバッファに残す
553
+
554
+ let eventName: string | null = null;
555
+ let dataLines: string[] = [];
556
+
557
+ const flushEvent = () => {
558
+ if (dataLines.length === 0 && !eventName) return;
559
+ const dataStr = dataLines.join("\n");
560
+ // 本ワイヤ仕様では data は常に JSON
561
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
562
+ let parsed: any;
563
+ try {
564
+ parsed = dataStr ? JSON.parse(dataStr) : null;
565
+ } catch (err) {
566
+ console.warn("[SM] Failed to parse JSON data:", dataStr, err);
567
+ // JSON でない場合は無視(接続維持のために例外は投げない)
568
+ parsed = null;
569
+ }
570
+
571
+ switch (eventName) {
572
+ case "chunk": {
573
+ if (parsed && typeof parsed.text === "string") {
574
+ // stale stream を無視(state 変更前に判定)
575
+ const latestId = this.streamIds.get(sessionId);
576
+ if (latestId !== streamId) return; // 意図: 中断済みのストリームからの遅延イベントを破棄
577
+ // 大きな文字列連結は O(n^2) になり得るが、まずは単純連結。
578
+ // 必要なら閾値超過で配列バッファ化する最適化を検討。
579
+ assistantMessage.content += parsed.text;
580
+ // 購読者がいる場合のみ通知(いない場合は状態は更新されるが通知は抑制)
581
+ const subCount = this.activeSubscribers.get(sessionId) ?? 0;
582
+ if (subCount > 0) {
583
+ // 配列の再構築を避け、同一配列参照で通知
584
+ this.updateSession(sessionId, {
585
+ messages: updatedMessages,
586
+ }); // Debounced for streaming chunks
587
+ } else if (this.debug) {
588
+ console.info(
589
+ `[SM] chunk suppressed (no subscribers): ${sessionId}`
590
+ );
591
+ }
592
+ }
593
+ break;
594
+ }
595
+ case "fullContent": {
596
+ if (
597
+ parsed &&
598
+ parsed.type === "replace" &&
599
+ typeof parsed.content === "string"
600
+ ) {
601
+ // stale stream を無視(state 変更前に判定)
602
+ const latestId = this.streamIds.get(sessionId);
603
+ if (latestId !== streamId) return; // 意図: 中断済みのストリームからの遅延イベントを破棄
604
+ // Replace message content with transformed version
605
+ assistantMessage.content = parsed.content;
606
+ this.updateSession(
607
+ sessionId,
608
+ {
609
+ messages: updatedMessages,
610
+ },
611
+ true
612
+ ); // Immediate update for full content
613
+ }
614
+ break;
615
+ }
616
+ case "metadata": {
617
+ if (parsed) {
618
+ // セッションに登録された最新の onMetadata を使用
619
+ try {
620
+ this.latestOnMetadata.get(sessionId)?.(parsed);
621
+ } catch {
622
+ console.error("[SM] Failed to call onMetadata handler");
623
+ }
624
+ }
625
+ break;
626
+ }
627
+ case "error": {
628
+ const err = new Error(parsed?.message || "Stream error");
629
+ // ストリームを明示的に中断
630
+ try {
631
+ abortController.abort();
632
+ } catch {
633
+ console.error("[SM] Failed to abort stream on error");
634
+ }
635
+ // stale stream を無視
636
+ const latestId = this.streamIds.get(sessionId);
637
+ if (latestId !== streamId) return; // 意図: 中断済みのストリームからの遅延イベントを破棄
638
+ this.updateSession(
639
+ sessionId,
640
+ {
641
+ status: "error",
642
+ error: err,
643
+ abortController: null,
644
+ },
645
+ true
646
+ );
647
+ // セッションに登録された最新の onError を使用
648
+ try {
649
+ this.latestOnError.get(sessionId)?.(err);
650
+ } catch {
651
+ console.error("[SM] Failed to call onError handler");
652
+ }
653
+ // 意図: catch に落とさず通常完了経路も避けるため、フラグを立ててループ終了
654
+ failed = true;
655
+ shouldTerminate = true;
656
+ break;
657
+ }
658
+ case "done": {
659
+ // stale stream を無視
660
+ const latestId = this.streamIds.get(sessionId);
661
+ if (latestId !== streamId) return; // 意図: 中断済みのストリームからの遅延イベントを破棄
662
+ this.updateSession(
663
+ sessionId,
664
+ {
665
+ status: "ready",
666
+ abortController: null,
667
+ },
668
+ true
669
+ );
670
+ // セッションに登録された最新の onFinish のみ呼ぶ
671
+ try {
672
+ this.latestOnFinish.get(sessionId)?.(
673
+ (this.state.sessions.get(sessionId)?.messages ||
674
+ []) as Message[]
675
+ );
676
+ } catch {
677
+ console.error("[SM] Failed to call onFinish handler");
678
+ }
679
+ // 意図: 正常終了。外側の catch へは行かず処理を終了するためループ終了を指示
680
+ shouldTerminate = true;
681
+ break;
682
+ }
683
+ default:
684
+ // 未知イベントは無視
685
+ break;
686
+ }
687
+ };
688
+
689
+ for (const line of lines) {
690
+ if (line === "") {
691
+ // 空行で一つのイベントをフラッシュ
692
+ flushEvent();
693
+ eventName = null;
694
+ dataLines = [];
695
+ continue;
696
+ }
697
+ if (line.startsWith(":")) {
698
+ // コメント(無視)
699
+ continue;
700
+ }
701
+ if (line.startsWith("event:")) {
702
+ eventName = line.slice(6).trim();
703
+ continue;
704
+ }
705
+ if (line.startsWith("data:")) {
706
+ dataLines.push(line.slice(5).trimStart());
707
+ continue;
708
+ }
709
+ // 他のフィールド(id, retry など)は無視
710
+ }
711
+
712
+ // 意図: error/done を受け取ったら読み取りループを終了
713
+ if (shouldTerminate) break;
714
+ }
715
+
716
+ // 意図: 正常完了(サーバが done を送らないケース)だけをここで処理。
717
+ // failed=true(error)の場合は既に状態更新済みなので何もしない。
718
+ if (!failed && !shouldTerminate) {
719
+ this.updateSession(
720
+ sessionId,
721
+ {
722
+ status: "ready",
723
+ abortController: null,
724
+ },
725
+ true
726
+ ); // Immediate for status change
727
+ try {
728
+ this.latestOnFinish.get(sessionId)?.(
729
+ (this.state.sessions.get(sessionId)?.messages || []) as Message[]
730
+ );
731
+ } catch {
732
+ console.error("[SM] Failed to call onFinish handler on completion");
733
+ }
734
+ }
735
+ } catch (error) {
736
+ // 意図: エラーイベントは catch に到達しない(failed フラグで分岐)ため、ここは接続エラーや中断のみ
737
+ if (isAbortError(error)) {
738
+ this.updateSession(
739
+ sessionId,
740
+ {
741
+ status: "ready",
742
+ abortController: null,
743
+ },
744
+ true
745
+ ); // Immediate for status change
746
+ } else {
747
+ const err = error instanceof Error ? error : new Error("Unknown error");
748
+ // 安全のため接続を明示的に中断
749
+ try {
750
+ abortController.abort();
751
+ } catch {
752
+ console.error("[SM] Failed to abort stream during cleanup");
753
+ }
754
+ this.updateSession(
755
+ sessionId,
756
+ {
757
+ status: "error",
758
+ error: err,
759
+ abortController: null,
760
+ },
761
+ true
762
+ ); // Immediate for error status
763
+ // セッションに登録された最新の onError を使用
764
+ try {
765
+ this.latestOnError.get(sessionId)?.(err);
766
+ } catch {
767
+ console.error("[SM] Failed to call onError handler");
768
+ }
769
+ }
770
+ }
771
+ }
772
+
773
+ stopStream(sessionId: string) {
774
+ const session = this.state.sessions.get(sessionId);
775
+ if (session?.abortController) {
776
+ session.abortController.abort();
777
+ }
778
+ }
779
+
780
+ /**
781
+ * ライフサイクル終了時のクリーンアップ用。
782
+ * SimpleAIProvider から呼び出される想定。
783
+ */
784
+ dispose(): void {
785
+ // 通知用スケジューラの解放
786
+ if (this.rafId != null) {
787
+ const caf = this.getCAF();
788
+ if (caf) {
789
+ try {
790
+ caf(this.rafId);
791
+ } catch {
792
+ // ignore
793
+ }
794
+ }
795
+ }
796
+ this.rafId = null;
797
+ if (this.rafFallbackTimer) {
798
+ clearTimeout(this.rafFallbackTimer);
799
+ this.rafFallbackTimer = null;
800
+ }
801
+ this.rafScheduled = false;
802
+ if (this.sweepTimer) {
803
+ clearInterval(this.sweepTimer);
804
+ this.sweepTimer = null;
805
+ }
806
+ }
807
+ }
808
+
809
+ // Export the StreamManager class for custom initialization
810
+ export { StreamManager };
811
+
812
+ // Create default instance without options
813
+ // Will be replaced in SimpleAIProvider if custom options are provided
814
+ export const streamManager: IStreamManager = new StreamManager();