mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-12 09:02:08 +08:00
The frontend previously juggled placeholder IDs, provider-native IDs, and session_created handoffs, which caused race conditions and provider-specific branching. This introduces app-allocated session IDs, a chat run registry with event replay, delta sidebar updates, and one kind-based websocket contract so the UI can treat every provider the same while JSONL remains the source of truth.
146 lines
5.1 KiB
TypeScript
146 lines
5.1 KiB
TypeScript
import { WS_OPEN_STATE } from '@/modules/websocket/services/websocket-state.service.js';
|
|
import type {
|
|
LLMProvider,
|
|
NormalizedMessage,
|
|
RealtimeClientConnection,
|
|
} from '@/shared/types.js';
|
|
import { createCompleteMessage, readObjectRecord } from '@/shared/utils.js';
|
|
|
|
type ChatSessionWriterOptions = {
|
|
connection: RealtimeClientConnection;
|
|
userId: string | number | null;
|
|
provider: LLMProvider;
|
|
/** Provider-native id when resuming an existing session, otherwise null. */
|
|
providerSessionId: string | null;
|
|
/**
|
|
* Invoked the moment the provider runtime reveals its native session id
|
|
* (either via `setSessionId` or a `session_created` event). The registry
|
|
* persists the app-id-to-provider-id mapping from this callback.
|
|
*/
|
|
onProviderSessionId: (providerSessionId: string) => void;
|
|
/**
|
|
* Remaps/sequences/buffers one outbound live event. Implemented by the chat
|
|
* run registry; the writer never forwards a provider event untouched.
|
|
* Returns `null` when the event must be dropped (duplicate terminal
|
|
* `complete` after an abort already completed the run).
|
|
*/
|
|
decorateOutboundEvent: (message: NormalizedMessage) => NormalizedMessage | null;
|
|
};
|
|
|
|
/**
|
|
* Gateway writer handed to provider runtimes instead of a raw websocket writer.
|
|
*
|
|
* It exposes the exact same surface as `WebSocketWriter` (`send`,
|
|
* `setSessionId`, `getSessionId`, `updateWebSocket`, `userId`,
|
|
* `isWebSocketWriter`) so the provider runtimes (`claude-sdk.js`,
|
|
* `cursor-cli.js`, ...) need zero changes — but everything that flows through
|
|
* it is translated from the provider's world into the app's protocol:
|
|
*
|
|
* - `session_created` events are swallowed and turned into a provider-id
|
|
* mapping; the frontend never learns provider-native ids.
|
|
* - every other event gets `sessionId` remapped to the app session id and a
|
|
* per-run `seq` assigned before being forwarded.
|
|
* - `setSessionId(...)` calls (used by runtimes to label captured ids) are
|
|
* intercepted and recorded as the provider-id mapping as well.
|
|
*/
|
|
export class ChatSessionWriter {
|
|
ws: RealtimeClientConnection;
|
|
userId: string | number | null;
|
|
/**
|
|
* Some runtimes feature-detect their writer with this flag; keep it so the
|
|
* gateway writer is a drop-in replacement for `WebSocketWriter`.
|
|
*/
|
|
isWebSocketWriter = true;
|
|
|
|
private readonly options: ChatSessionWriterOptions;
|
|
/**
|
|
* The provider-native session id as the runtime knows it. Kept locally
|
|
* (besides the registry) because runtimes read it back via `getSessionId()`
|
|
* to label their own outgoing events — those labels are remapped on send
|
|
* anyway, but the runtime-visible value must stay provider-native.
|
|
*/
|
|
private providerSessionId: string | null;
|
|
|
|
constructor(options: ChatSessionWriterOptions) {
|
|
this.options = options;
|
|
this.ws = options.connection;
|
|
this.userId = options.userId;
|
|
this.providerSessionId = options.providerSessionId;
|
|
}
|
|
|
|
send(data: unknown): void {
|
|
const record = readObjectRecord(data);
|
|
if (!record || typeof record.kind !== 'string') {
|
|
// Provider runtimes only emit kind-based normalized messages. Anything
|
|
// else indicates a programming error; drop it rather than leaking an
|
|
// un-remapped payload to the client.
|
|
console.error('[ChatSessionWriter] Dropping non-normalized outbound payload', data);
|
|
return;
|
|
}
|
|
|
|
const message = record as NormalizedMessage;
|
|
|
|
if (message.kind === 'session_created') {
|
|
const announcedId =
|
|
typeof message.newSessionId === 'string' && message.newSessionId
|
|
? message.newSessionId
|
|
: message.sessionId;
|
|
if (announcedId) {
|
|
this.captureProviderSessionId(announcedId);
|
|
}
|
|
// Swallowed on purpose: the frontend already has the stable app session
|
|
// id, so there is no client-side handoff to perform anymore.
|
|
return;
|
|
}
|
|
|
|
const outbound = this.options.decorateOutboundEvent(message);
|
|
if (outbound) {
|
|
this.forward(outbound);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Emits the synthetic terminal `complete` for runs that ended without one
|
|
* (runtime crash before completing, or user abort).
|
|
*/
|
|
sendComplete(opts: { exitCode: number; aborted?: boolean }): void {
|
|
const message = createCompleteMessage({
|
|
provider: this.options.provider,
|
|
sessionId: this.providerSessionId,
|
|
exitCode: opts.exitCode,
|
|
aborted: opts.aborted,
|
|
});
|
|
const outbound = this.options.decorateOutboundEvent(message);
|
|
if (outbound) {
|
|
this.forward(outbound);
|
|
}
|
|
}
|
|
|
|
updateWebSocket(newConnection: RealtimeClientConnection): void {
|
|
this.ws = newConnection;
|
|
}
|
|
|
|
setSessionId(sessionId: string): void {
|
|
this.captureProviderSessionId(sessionId);
|
|
}
|
|
|
|
getSessionId(): string | null {
|
|
return this.providerSessionId;
|
|
}
|
|
|
|
private captureProviderSessionId(providerSessionId: string): void {
|
|
if (!providerSessionId || this.providerSessionId === providerSessionId) {
|
|
return;
|
|
}
|
|
|
|
this.providerSessionId = providerSessionId;
|
|
this.options.onProviderSessionId(providerSessionId);
|
|
}
|
|
|
|
private forward(message: NormalizedMessage): void {
|
|
if (this.ws.readyState === WS_OPEN_STATE) {
|
|
this.ws.send(JSON.stringify(message));
|
|
}
|
|
}
|
|
}
|