| import type { DirectorState } from '@/lib/types/chat'; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
|
|
| export interface AgentStartItem { |
| kind: 'agent_start'; |
| messageId: string; |
| agentId: string; |
| agentName: string; |
| avatar?: string; |
| color?: string; |
| } |
|
|
| export interface AgentEndItem { |
| kind: 'agent_end'; |
| messageId: string; |
| agentId: string; |
| } |
|
|
| export interface TextItem { |
| kind: 'text'; |
| messageId: string; |
| agentId: string; |
| |
| partId: string; |
| |
| text: string; |
| |
| sealed: boolean; |
| } |
|
|
| export interface ActionItem { |
| kind: 'action'; |
| messageId: string; |
| actionId: string; |
| actionName: string; |
| params: Record<string, unknown>; |
| agentId: string; |
| } |
|
|
| export interface ThinkingItem { |
| kind: 'thinking'; |
| stage: string; |
| agentId?: string; |
| } |
|
|
| export interface CueUserItem { |
| kind: 'cue_user'; |
| fromAgentId?: string; |
| prompt?: string; |
| } |
|
|
| export interface DoneItem { |
| kind: 'done'; |
| totalActions: number; |
| totalAgents: number; |
| agentHadContent?: boolean; |
| directorState?: DirectorState; |
| } |
|
|
| export interface ErrorItem { |
| kind: 'error'; |
| message: string; |
| } |
|
|
| export type BufferItem = |
| | AgentStartItem |
| | AgentEndItem |
| | TextItem |
| | ActionItem |
| | ThinkingItem |
| | CueUserItem |
| | DoneItem |
| | ErrorItem; |
|
|
| |
|
|
| export interface StreamBufferCallbacks { |
| onAgentStart(data: AgentStartItem): void; |
| onAgentEnd(data: AgentEndItem): void; |
| |
| |
| |
| |
| |
| |
| |
| onTextReveal(messageId: string, partId: string, revealedText: string, isComplete: boolean): void; |
| |
| onActionReady(messageId: string, data: ActionItem): void; |
| |
| |
| |
| |
| |
| onLiveSpeech(text: string | null, agentId: string | null): void; |
| |
| |
| |
| |
| |
| onSpeechProgress(ratio: number | null): void; |
| onThinking(data: { stage: string; agentId?: string } | null): void; |
| onCueUser(fromAgentId?: string, prompt?: string): void; |
| onDone(data: { |
| totalActions: number; |
| totalAgents: number; |
| agentHadContent?: boolean; |
| directorState?: DirectorState; |
| }): void; |
| onError(message: string): void; |
| onSegmentSealed?: ( |
| messageId: string, |
| partId: string, |
| fullText: string, |
| agentId: string | null, |
| ) => void; |
| |
| |
| |
| |
| |
| shouldHoldAfterReveal?: () => { holding: boolean; segmentDone: number } | boolean; |
| } |
|
|
| |
|
|
| export interface StreamBufferOptions { |
| |
| tickMs?: number; |
| |
| charsPerTick?: number; |
| |
| |
| |
| |
| |
| postTextDelayMs?: number; |
| |
| |
| |
| |
| actionDelayMs?: number; |
| } |
|
|
| |
|
|
| export class StreamBuffer { |
| |
| private items: BufferItem[] = []; |
| private readIndex = 0; |
| private charCursor = 0; |
|
|
| |
| private currentSegmentText = ''; |
| private currentAgentId: string | null = null; |
|
|
| |
| private _paused = false; |
| private _disposed = false; |
| private timer: ReturnType<typeof setInterval> | null = null; |
|
|
| |
| private _dwellTicksRemaining = 0; |
| |
| private _holdingForTTS = false; |
| private _holdSegmentSnapshot = -1; |
|
|
| |
| private readonly tickMs: number; |
| private readonly charsPerTick: number; |
| private readonly postTextDelayTicks: number; |
| private readonly actionDelayTicks: number; |
| private readonly cb: StreamBufferCallbacks; |
| private partCounter = 0; |
| private _drainResolve: (() => void) | null = null; |
| private _drainReject: ((err: Error) => void) | null = null; |
|
|
| constructor(callbacks: StreamBufferCallbacks, options?: StreamBufferOptions) { |
| this.cb = callbacks; |
| this.tickMs = options?.tickMs ?? 30; |
| this.charsPerTick = options?.charsPerTick ?? 1; |
| this.postTextDelayTicks = Math.ceil((options?.postTextDelayMs ?? 0) / this.tickMs); |
| this.actionDelayTicks = Math.ceil((options?.actionDelayMs ?? 0) / this.tickMs); |
| } |
|
|
| |
|
|
| pushAgentStart(data: Omit<AgentStartItem, 'kind'>): void { |
| if (this._disposed) return; |
| this.sealLastText(); |
| this.items.push({ kind: 'agent_start', ...data }); |
| } |
|
|
| pushAgentEnd(data: Omit<AgentEndItem, 'kind'>): void { |
| if (this._disposed) return; |
| this.sealLastText(); |
| this.items.push({ kind: 'agent_end', ...data }); |
| } |
|
|
| |
| |
| |
| |
| |
| pushText(messageId: string, delta: string, agentId?: string): void { |
| if (this._disposed) return; |
| const last = this.items[this.items.length - 1]; |
| if (last && last.kind === 'text' && last.messageId === messageId && !last.sealed) { |
| last.text += delta; |
| } else { |
| this.items.push({ |
| kind: 'text', |
| messageId, |
| agentId: agentId ?? this.currentAgentId ?? '', |
| partId: `p${this.partCounter++}`, |
| text: delta, |
| sealed: false, |
| }); |
| } |
| } |
|
|
| |
| sealText(messageId: string): void { |
| if (this._disposed) return; |
| for (let i = this.items.length - 1; i >= 0; i--) { |
| const item = this.items[i]; |
| if (item.kind === 'text' && item.messageId === messageId && !item.sealed) { |
| item.sealed = true; |
| break; |
| } |
| } |
| } |
|
|
| pushAction(data: Omit<ActionItem, 'kind'>): void { |
| if (this._disposed) return; |
| this.sealLastText(); |
| this.items.push({ kind: 'action', ...data }); |
| } |
|
|
| pushThinking(data: { stage: string; agentId?: string }): void { |
| if (this._disposed) return; |
| this.items.push({ kind: 'thinking', ...data }); |
| } |
|
|
| pushCueUser(data: { fromAgentId?: string; prompt?: string }): void { |
| if (this._disposed) return; |
| this.items.push({ kind: 'cue_user', ...data }); |
| } |
|
|
| pushDone(data: { |
| totalActions: number; |
| totalAgents: number; |
| agentHadContent?: boolean; |
| directorState?: DirectorState; |
| }): void { |
| if (this._disposed) return; |
| this.sealLastText(); |
| this.items.push({ kind: 'done', ...data }); |
| } |
|
|
| pushError(message: string): void { |
| if (this._disposed) return; |
| this.items.push({ kind: 'error', message }); |
| } |
|
|
| |
|
|
| |
| start(): void { |
| if (this._disposed || this.timer) return; |
| this.timer = setInterval(() => this.tick(), this.tickMs); |
| } |
|
|
| |
| pause(): void { |
| this._paused = true; |
| } |
|
|
| |
| resume(): void { |
| this._paused = false; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| waitUntilDrained(): Promise<void> { |
| if (this._disposed) { |
| return Promise.reject(new Error('Buffer already disposed')); |
| } |
| return new Promise<void>((resolve, reject) => { |
| this._drainResolve = resolve; |
| this._drainReject = reject; |
| }); |
| } |
|
|
| get paused(): boolean { |
| return this._paused; |
| } |
|
|
| get disposed(): boolean { |
| return this._disposed; |
| } |
|
|
| |
| |
| |
| |
| flush(): void { |
| if (this._disposed) return; |
| while (this.readIndex < this.items.length) { |
| const item = this.items[this.readIndex]; |
| switch (item.kind) { |
| case 'text': |
| this.cb.onTextReveal(item.messageId, item.partId, item.text, true); |
| this.currentSegmentText = item.text; |
| this.cb.onLiveSpeech(this.currentSegmentText, this.currentAgentId); |
| this.cb.onSpeechProgress(1); |
| break; |
| case 'action': |
| this.currentSegmentText = ''; |
| this.cb.onActionReady(item.messageId, item); |
| this.cb.onLiveSpeech(null, this.currentAgentId); |
| break; |
| case 'agent_start': |
| this.currentAgentId = item.agentId; |
| this.currentSegmentText = ''; |
| this.cb.onThinking(null); |
| this.cb.onAgentStart(item); |
| this.cb.onLiveSpeech(null, item.agentId); |
| break; |
| case 'agent_end': |
| this.cb.onAgentEnd(item); |
| break; |
| case 'thinking': |
| this.cb.onThinking(item); |
| break; |
| case 'cue_user': |
| this.cb.onCueUser(item.fromAgentId, item.prompt); |
| break; |
| case 'done': |
| this.cb.onLiveSpeech(null, null); |
| this.cb.onSpeechProgress(null); |
| this.cb.onThinking(null); |
| this.cb.onDone(item); |
| |
| this._drainResolve?.(); |
| this._drainResolve = null; |
| this._drainReject = null; |
| break; |
| case 'error': |
| this.cb.onError(item.message); |
| break; |
| } |
| this.readIndex++; |
| this.charCursor = 0; |
| } |
| } |
|
|
| |
| dispose(): void { |
| if (this._disposed) return; |
| this._disposed = true; |
| if (this.timer) { |
| clearInterval(this.timer); |
| this.timer = null; |
| } |
| |
| this._drainReject?.(new Error('Buffer disposed')); |
| this._drainResolve = null; |
| this._drainReject = null; |
| |
| this.cb.onLiveSpeech(null, null); |
| this.cb.onSpeechProgress(null); |
| } |
|
|
| |
| |
| |
| |
| |
| shutdown(): void { |
| if (this._disposed) return; |
| this._disposed = true; |
| if (this.timer) { |
| clearInterval(this.timer); |
| this.timer = null; |
| } |
| |
| this._drainReject?.(new Error('Buffer shutdown')); |
| this._drainResolve = null; |
| this._drainReject = null; |
| } |
|
|
| |
|
|
| |
| private sealLastText(): void { |
| for (let i = this.items.length - 1; i >= 0; i--) { |
| const item = this.items[i]; |
| if (item.kind === 'text' && !item.sealed) { |
| item.sealed = true; |
| |
| |
| this.cb.onSegmentSealed?.(item.messageId, item.partId, item.text, this.currentAgentId); |
| break; |
| } |
| |
| if (item.kind !== 'text') break; |
| } |
| } |
|
|
| private tick(): void { |
| if (this._paused || this._disposed) return; |
|
|
| |
| if (this._dwellTicksRemaining > 0) { |
| this._dwellTicksRemaining--; |
| if (this._dwellTicksRemaining === 0 && this._holdingForTTS) { |
| |
| } else { |
| return; |
| } |
| } |
|
|
| |
| if (this._holdingForTTS) { |
| const result = this.cb.shouldHoldAfterReveal?.(); |
| if (result) { |
| if (typeof result === 'object') { |
| if (!result.holding) { |
| |
| this._holdingForTTS = false; |
| this._holdSegmentSnapshot = -1; |
| this.advanceNonText(); |
| return; |
| } |
| if (result.segmentDone !== this._holdSegmentSnapshot) { |
| |
| this._holdingForTTS = false; |
| this._holdSegmentSnapshot = -1; |
| this.advanceNonText(); |
| return; |
| } |
| return; |
| } |
| |
| return; |
| } |
| this._holdingForTTS = false; |
| this._holdSegmentSnapshot = -1; |
| |
| this.advanceNonText(); |
| return; |
| } |
|
|
| const item = this.items[this.readIndex]; |
| if (!item) return; |
|
|
| switch (item.kind) { |
| case 'text': { |
| |
| this.charCursor = Math.min(this.charCursor + this.charsPerTick, item.text.length); |
| const revealed = item.text.slice(0, this.charCursor); |
| const fullyRevealed = this.charCursor >= item.text.length; |
| const isComplete = fullyRevealed && item.sealed; |
|
|
| |
| this.cb.onTextReveal(item.messageId, item.partId, revealed, isComplete); |
|
|
| |
| |
| |
| |
| this.currentSegmentText = revealed; |
| this.cb.onLiveSpeech(this.currentSegmentText, this.currentAgentId); |
| this.cb.onSpeechProgress(item.text.length > 0 ? this.charCursor / item.text.length : 1); |
|
|
| |
| if (isComplete) { |
| this.readIndex++; |
| this.charCursor = 0; |
|
|
| |
| |
| if (this.postTextDelayTicks > 0) { |
| this._dwellTicksRemaining = this.postTextDelayTicks; |
| |
| if (this.cb.shouldHoldAfterReveal) { |
| this._holdingForTTS = true; |
| const snap = this.cb.shouldHoldAfterReveal(); |
| this._holdSegmentSnapshot = typeof snap === 'object' ? snap.segmentDone : -1; |
| } |
| return; |
| } |
|
|
| |
| { |
| const result = this.cb.shouldHoldAfterReveal?.(); |
| if (result) { |
| this._holdingForTTS = true; |
| this._holdSegmentSnapshot = typeof result === 'object' ? result.segmentDone : -1; |
| return; |
| } |
| } |
|
|
| |
| |
| this.advanceNonText(); |
| } |
| |
| break; |
| } |
|
|
| |
| case 'agent_start': |
| this.currentAgentId = item.agentId; |
| this.currentSegmentText = ''; |
| this.cb.onThinking(null); |
| this.cb.onAgentStart(item); |
| this.cb.onLiveSpeech(null, item.agentId); |
| this.readIndex++; |
| this.charCursor = 0; |
| this.advanceNonText(); |
| break; |
|
|
| case 'agent_end': |
| this.cb.onAgentEnd(item); |
| this.readIndex++; |
| this.charCursor = 0; |
| this.advanceNonText(); |
| break; |
|
|
| case 'action': |
| this.currentSegmentText = ''; |
| this.cb.onActionReady(item.messageId, item); |
| this.cb.onLiveSpeech(null, this.currentAgentId); |
| this.readIndex++; |
| this.charCursor = 0; |
| |
| if (this.actionDelayTicks > 0) { |
| this._dwellTicksRemaining = this.actionDelayTicks; |
| return; |
| } |
| this.advanceNonText(); |
| break; |
|
|
| case 'thinking': |
| this.cb.onThinking(item); |
| this.readIndex++; |
| this.charCursor = 0; |
| this.advanceNonText(); |
| break; |
|
|
| case 'cue_user': |
| this.cb.onCueUser(item.fromAgentId, item.prompt); |
| this.readIndex++; |
| this.charCursor = 0; |
| this.advanceNonText(); |
| break; |
|
|
| case 'done': |
| this.cb.onLiveSpeech(null, null); |
| this.cb.onSpeechProgress(null); |
| this.cb.onThinking(null); |
| this.cb.onDone(item); |
| this.readIndex++; |
| this.charCursor = 0; |
| |
| if (this.timer) { |
| clearInterval(this.timer); |
| this.timer = null; |
| } |
| |
| this._drainResolve?.(); |
| this._drainResolve = null; |
| this._drainReject = null; |
| break; |
|
|
| case 'error': |
| this.cb.onError(item.message); |
| this.readIndex++; |
| this.charCursor = 0; |
| this.advanceNonText(); |
| break; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| private advanceNonText(): void { |
| while (this.readIndex < this.items.length) { |
| const next = this.items[this.readIndex]; |
| if (next.kind === 'text') break; |
|
|
| switch (next.kind) { |
| case 'agent_start': |
| this.currentAgentId = next.agentId; |
| this.currentSegmentText = ''; |
| this.cb.onThinking(null); |
| this.cb.onAgentStart(next); |
| this.cb.onLiveSpeech(null, next.agentId); |
| break; |
| case 'agent_end': |
| this.cb.onAgentEnd(next); |
| break; |
| case 'action': |
| this.currentSegmentText = ''; |
| this.cb.onActionReady(next.messageId, next); |
| this.cb.onLiveSpeech(null, this.currentAgentId); |
| this.readIndex++; |
| this.charCursor = 0; |
| |
| if (this.actionDelayTicks > 0) { |
| this._dwellTicksRemaining = this.actionDelayTicks; |
| return; |
| } |
| continue; |
| case 'thinking': |
| this.cb.onThinking(next); |
| break; |
| case 'cue_user': |
| this.cb.onCueUser(next.fromAgentId, next.prompt); |
| break; |
| case 'done': |
| this.cb.onLiveSpeech(null, null); |
| this.cb.onSpeechProgress(null); |
| this.cb.onThinking(null); |
| this.cb.onDone(next); |
| this.readIndex++; |
| this.charCursor = 0; |
| if (this.timer) { |
| clearInterval(this.timer); |
| this.timer = null; |
| } |
| |
| this._drainResolve?.(); |
| this._drainResolve = null; |
| this._drainReject = null; |
| return; |
| case 'error': |
| this.cb.onError(next.message); |
| break; |
| } |
| this.readIndex++; |
| this.charCursor = 0; |
| } |
| } |
| } |
|
|