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.
- package/eslint.config.js +30 -0
- package/package.json +55 -0
- package/src/client/context.tsx +45 -0
- package/src/client/index.ts +9 -0
- package/src/client/manager.ts +814 -0
- package/src/client/types.ts +56 -0
- package/src/client/useChatSession.ts +120 -0
- package/src/server/createChatHandler.ts +247 -0
- package/src/server/index.ts +11 -0
- package/src/server/types.ts +66 -0
- package/src/shared/error-utils.ts +13 -0
- package/src/shared/generate-id.ts +16 -0
- package/src/shared/index.ts +3 -0
- package/src/shared/types.ts +15 -0
- package/src/shared/wire.ts +40 -0
- package/tsconfig.json +23 -0
|
@@ -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();
|