Merge pull request #26 from ibelick/feat/improve-chat-streaming

Feat/improve chat streaming
This commit is contained in:
Julien Thibeaut
2026-02-12 14:59:08 +01:00
committed by GitHub
24 changed files with 1356 additions and 259 deletions
@@ -3,7 +3,6 @@ import { HugeiconsIcon } from '@hugeicons/react'
import { Copy01Icon, Tick02Icon } from '@hugeicons/core-free-icons'
import { createHighlighterCore } from 'shiki/core'
import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'
import type { HighlighterCore } from 'shiki/core'
import vitesseDark from '@shikijs/themes/vitesse-dark'
import vitesseLight from '@shikijs/themes/vitesse-light'
import langBash from '@shikijs/langs/bash'
@@ -35,10 +34,11 @@ import langTypescript from '@shikijs/langs/typescript'
import langTsx from '@shikijs/langs/tsx'
import langXml from '@shikijs/langs/xml'
import langYaml from '@shikijs/langs/yaml'
import { formatLanguageName, normalizeLanguage, resolveLanguage } from './utils'
import type { HighlighterCore } from 'shiki/core'
import { useResolvedTheme } from '@/hooks/use-chat-settings'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { formatLanguageName, normalizeLanguage, resolveLanguage } from './utils'
type CodeBlockProps = {
content: string
@@ -46,7 +46,6 @@ function ScrollButton({
}
const observer = new MutationObserver(() => {
if (!element) return
if (element.scrollTop !== lastScrollTopRef.current) {
lastScrollTopRef.current = element.scrollTop
}
@@ -1,12 +1,12 @@
'use client'
import {
Collapsible,
CollapsibleTrigger,
CollapsiblePanel,
} from '@/components/ui/collapsible'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowDown01Icon } from '@hugeicons/core-free-icons'
import {
Collapsible,
CollapsiblePanel,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Button } from '@/components/ui/button'
export type ThinkingProps = {
@@ -1,12 +1,12 @@
'use client'
import {
Collapsible,
CollapsibleTrigger,
CollapsiblePanel,
} from '@/components/ui/collapsible'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowDown01Icon } from '@hugeicons/core-free-icons'
import {
Collapsible,
CollapsiblePanel,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Button } from '@/components/ui/button'
export type ToolPart = {
+21
View File
@@ -13,6 +13,7 @@ import { Route as NewRouteImport } from './routes/new'
import { Route as ConnectRouteImport } from './routes/connect'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ChatSessionKeyRouteImport } from './routes/chat/$sessionKey'
import { Route as ApiStreamRouteImport } from './routes/api/stream'
import { Route as ApiSessionsRouteImport } from './routes/api/sessions'
import { Route as ApiSendRouteImport } from './routes/api/send'
import { Route as ApiPingRouteImport } from './routes/api/ping'
@@ -39,6 +40,11 @@ const ChatSessionKeyRoute = ChatSessionKeyRouteImport.update({
path: '/chat/$sessionKey',
getParentRoute: () => rootRouteImport,
} as any)
const ApiStreamRoute = ApiStreamRouteImport.update({
id: '/api/stream',
path: '/api/stream',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSessionsRoute = ApiSessionsRouteImport.update({
id: '/api/sessions',
path: '/api/sessions',
@@ -74,6 +80,7 @@ export interface FileRoutesByFullPath {
'/api/ping': typeof ApiPingRoute
'/api/send': typeof ApiSendRoute
'/api/sessions': typeof ApiSessionsRoute
'/api/stream': typeof ApiStreamRoute
'/chat/$sessionKey': typeof ChatSessionKeyRoute
}
export interface FileRoutesByTo {
@@ -85,6 +92,7 @@ export interface FileRoutesByTo {
'/api/ping': typeof ApiPingRoute
'/api/send': typeof ApiSendRoute
'/api/sessions': typeof ApiSessionsRoute
'/api/stream': typeof ApiStreamRoute
'/chat/$sessionKey': typeof ChatSessionKeyRoute
}
export interface FileRoutesById {
@@ -97,6 +105,7 @@ export interface FileRoutesById {
'/api/ping': typeof ApiPingRoute
'/api/send': typeof ApiSendRoute
'/api/sessions': typeof ApiSessionsRoute
'/api/stream': typeof ApiStreamRoute
'/chat/$sessionKey': typeof ChatSessionKeyRoute
}
export interface FileRouteTypes {
@@ -110,6 +119,7 @@ export interface FileRouteTypes {
| '/api/ping'
| '/api/send'
| '/api/sessions'
| '/api/stream'
| '/chat/$sessionKey'
fileRoutesByTo: FileRoutesByTo
to:
@@ -121,6 +131,7 @@ export interface FileRouteTypes {
| '/api/ping'
| '/api/send'
| '/api/sessions'
| '/api/stream'
| '/chat/$sessionKey'
id:
| '__root__'
@@ -132,6 +143,7 @@ export interface FileRouteTypes {
| '/api/ping'
| '/api/send'
| '/api/sessions'
| '/api/stream'
| '/chat/$sessionKey'
fileRoutesById: FileRoutesById
}
@@ -144,6 +156,7 @@ export interface RootRouteChildren {
ApiPingRoute: typeof ApiPingRoute
ApiSendRoute: typeof ApiSendRoute
ApiSessionsRoute: typeof ApiSessionsRoute
ApiStreamRoute: typeof ApiStreamRoute
ChatSessionKeyRoute: typeof ChatSessionKeyRoute
}
@@ -177,6 +190,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ChatSessionKeyRouteImport
parentRoute: typeof rootRouteImport
}
'/api/stream': {
id: '/api/stream'
path: '/api/stream'
fullPath: '/api/stream'
preLoaderRoute: typeof ApiStreamRouteImport
parentRoute: typeof rootRouteImport
}
'/api/sessions': {
id: '/api/sessions'
path: '/api/sessions'
@@ -224,6 +244,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiPingRoute: ApiPingRoute,
ApiSendRoute: ApiSendRoute,
ApiSessionsRoute: ApiSessionsRoute,
ApiStreamRoute: ApiStreamRoute,
ChatSessionKeyRoute: ChatSessionKeyRoute,
}
export const routeTree = rootRouteImport
+16 -12
View File
@@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto'
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
import { gatewayRpc } from '../../server/gateway'
import { gatewayRpc, gatewayRpcShared } from '../../server/gateway'
type SessionsResolveResponse = {
ok?: boolean
@@ -70,18 +70,22 @@ export const Route = createFileRoute('/api/send')({
sessionKey = 'main'
}
const res = await gatewayRpc<{ runId: string }>('chat.send', {
const res = await gatewayRpcShared<{ runId: string }>(
'chat.send',
{
sessionKey,
message,
thinking,
attachments,
deliver: true,
timeoutMs: 120_000,
idempotencyKey:
typeof body.idempotencyKey === 'string'
? body.idempotencyKey
: randomUUID(),
},
sessionKey,
message,
thinking,
attachments,
deliver: false,
timeoutMs: 120_000,
idempotencyKey:
typeof body.idempotencyKey === 'string'
? body.idempotencyKey
: randomUUID(),
})
)
return json({ ok: true, ...res, sessionKey })
} catch (err) {
+110
View File
@@ -0,0 +1,110 @@
import { createFileRoute } from '@tanstack/react-router'
import { acquireGatewayClient, gatewayRpcShared } from '../../server/gateway'
type StreamEventPayload = {
event: string
payload?: unknown
seq?: number
stateVersion?: number
}
export const Route = createFileRoute('/api/stream')({
server: {
handlers: {
GET: ({ request }) => {
const url = new URL(request.url)
const sessionKey = url.searchParams.get('sessionKey')?.trim() || ''
const friendlyId = url.searchParams.get('friendlyId')?.trim() || ''
const encoder = new TextEncoder()
let releaseClient: (() => void) | null = null
let closed = false
const stream = new ReadableStream({
start(controller) {
function send(data: StreamEventPayload) {
if (closed) return
try {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
)
} catch {
closed = true
}
}
const heartbeat = setInterval(() => {
controller.enqueue(encoder.encode('event: ping\ndata: {}\n\n'))
}, 15000)
const key = sessionKey || friendlyId
if (key) {
void acquireGatewayClient(key, {
onEvent(event) {
send({
event: event.event,
payload: event.payload,
seq: event.seq,
stateVersion: event.stateVersion,
})
},
onError(error) {
send({ event: 'error', payload: error.message })
},
})
.then((handle) => {
if (closed) {
handle.release()
return
}
releaseClient = handle.release
if (sessionKey) {
void gatewayRpcShared(
'chat.history',
{ sessionKey, limit: 1 },
sessionKey,
)
}
})
.catch((error: unknown) => {
if (closed) return
const message =
error instanceof Error ? error.message : String(error)
send({ event: 'error', payload: message })
})
}
request.signal.addEventListener(
'abort',
() => {
if (closed) return
closed = true
clearInterval(heartbeat)
releaseClient?.()
try {
controller.close()
} catch {
return
}
},
{ once: true },
)
},
cancel() {
if (closed) return
closed = true
releaseClient?.()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
},
},
},
})
@@ -162,9 +162,7 @@ export function moveHistoryMessages(
) {
const fromKey = chatQueryKeys.history(fromFriendlyId, fromSessionKey)
const toKey = chatQueryKeys.history(toFriendlyId, toSessionKey)
const fromData = queryClient.getQueryData(fromKey) as
| HistoryResponse
| undefined
const fromData = queryClient.getQueryData(fromKey)
if (!fromData) return
const messages = Array.isArray(fromData.messages) ? fromData.messages : []
queryClient.setQueryData(toKey, {
@@ -9,7 +9,7 @@ type OptimisticMessagePayload = {
export function createOptimisticMessage(
body: string,
attachments?: AttachmentFile[],
attachments?: Array<AttachmentFile>,
): OptimisticMessagePayload {
const clientId = crypto.randomUUID()
const optimisticId = `opt-${clientId}`
@@ -23,7 +23,7 @@ export function createOptimisticMessage(
if (attachments && attachments.length > 0) {
for (const att of attachments) {
if (att.type === 'image' && att.base64) {
if (att.base64) {
content.push({
type: 'image',
source: {
+154 -195
View File
@@ -1,11 +1,4 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
@@ -13,7 +6,6 @@ import {
deriveFriendlyIdFromKey,
isMissingGatewayAuth,
readError,
textFromMessage,
} from './utils'
import { createOptimisticMessage } from './chat-screen-utils'
import {
@@ -32,11 +24,9 @@ import { ChatMessageList } from './components/chat-message-list'
import { ChatComposer } from './components/chat-composer'
import { GatewayStatusMessage } from './components/gateway-status-message'
import {
consumePendingSend,
hasPendingGeneration,
hasPendingSend,
isRecentSession,
resetPendingSend,
setPendingGeneration,
setRecentSession,
stashPendingSend,
@@ -45,9 +35,13 @@ import { useChatMeasurements } from './hooks/use-chat-measurements'
import { useChatHistory } from './hooks/use-chat-history'
import { useChatMobile } from './hooks/use-chat-mobile'
import { useChatSessions } from './hooks/use-chat-sessions'
import { useChatStream } from './hooks/use-chat-stream'
import { useChatPendingSend } from './hooks/use-chat-pending-send'
import { useChatGenerationGuard } from './hooks/use-chat-generation-guard'
import { useChatErrorState } from './hooks/use-chat-error-state'
import { useChatRedirect } from './hooks/use-chat-redirect'
import type { AttachmentFile } from '@/components/attachment-button'
import type { ChatComposerHelpers } from './components/chat-composer'
import type { HistoryResponse } from './types'
import { useExport } from '@/hooks/use-export'
import { cn } from '@/lib/utils'
@@ -81,11 +75,8 @@ export function ChatScreen({
const [pinToTop, setPinToTop] = useState(
() => hasPendingSend() || hasPendingGeneration(),
)
const streamTimer = useRef<number | null>(null)
const streamIdleTimer = useRef<number | null>(null)
const lastAssistantSignature = useRef('')
const refreshHistoryRef = useRef<() => void>(() => {})
const pendingStartRef = useRef(false)
const pendingRunIdsRef = useRef(new Set<string>())
const pendingRunTimersRef = useRef(new Map<string, number>())
const { isMobile } = useChatMobile(queryClient)
const {
sessionsQuery,
@@ -98,7 +89,6 @@ export function ChatScreen({
} = useChatSessions({ activeFriendlyId, isNewChat, forcedSessionKey })
const {
historyQuery,
historyMessages,
displayMessages,
historyError,
resolvedSessionKey,
@@ -156,72 +146,7 @@ export function ChatScreen({
setIsRedirecting(true)
navigate({ to: '/new', replace: true })
}, [navigate])
const streamStop = useCallback(() => {
if (streamTimer.current) {
window.clearInterval(streamTimer.current)
streamTimer.current = null
}
if (streamIdleTimer.current) {
window.clearTimeout(streamIdleTimer.current)
streamIdleTimer.current = null
}
}, [])
const streamFinish = useCallback(() => {
streamStop()
setPendingGeneration(false)
setWaitingForResponse(false)
}, [streamStop])
const streamStart = useCallback(() => {
if (!activeFriendlyId || isNewChat) return
if (streamTimer.current) window.clearInterval(streamTimer.current)
streamTimer.current = window.setInterval(() => {
refreshHistoryRef.current()
}, 350)
}, [activeFriendlyId, isNewChat])
const stableContentStyle = useMemo<React.CSSProperties>(() => ({}), [])
refreshHistoryRef.current = function refreshHistory() {
void historyQuery.refetch()
}
useEffect(() => {
if (isRedirecting) {
if (error) setError(null)
return
}
if (shouldRedirectToNew) {
if (error) setError(null)
return
}
if (sessionsQuery.isSuccess && !activeExists) {
if (error) setError(null)
return
}
const messageText = sessionsError ?? historyError ?? gatewayStatusError
if (!messageText) {
if (error?.startsWith('Failed to load')) {
setError(null)
}
return
}
if (isMissingGatewayAuth(messageText)) {
navigate({ to: '/connect', replace: true })
}
const message = sessionsError
? `Failed to load sessions. ${sessionsError}`
: historyError
? `Failed to load history. ${historyError}`
: gatewayStatusError
? `Gateway unavailable. ${gatewayStatusError}`
: null
if (message) setError(message)
}, [
error,
gatewayStatusError,
historyError,
isRedirecting,
navigate,
sessionsError,
])
const shouldRedirectToNew =
!isNewChat &&
@@ -233,125 +158,68 @@ export function ChatScreen({
!historyQuery.isFetching &&
!historyQuery.isSuccess
useEffect(() => {
if (!isRedirecting) return
if (isNewChat) {
setIsRedirecting(false)
return
}
if (!shouldRedirectToNew && sessionsQuery.isSuccess) {
setIsRedirecting(false)
}
}, [isNewChat, isRedirecting, sessionsQuery.isSuccess, shouldRedirectToNew])
useEffect(() => {
if (isNewChat) return
if (!sessionsQuery.isSuccess) return
if (sessions.length === 0) return
if (!shouldRedirectToNew) return
resetPendingSend()
clearHistoryMessages(queryClient, activeFriendlyId, sessionKeyForHistory)
navigate({ to: '/new', replace: true })
}, [
activeFriendlyId,
historyQuery.isFetching,
historyQuery.isSuccess,
isNewChat,
navigate,
queryClient,
sessionKeyForHistory,
sessions,
sessionsQuery.isSuccess,
shouldRedirectToNew,
])
const refreshHistory = useCallback(() => {
void historyQuery.refetch()
}, [historyQuery])
const hideUi = shouldRedirectToNew || isRedirecting
useEffect(() => {
if (historyMessages.length === 0) return
const latestMessage = historyMessages[historyMessages.length - 1]
if (latestMessage.role !== 'assistant') return
const signature = `${historyMessages.length}:${textFromMessage(latestMessage).slice(-64)}`
if (signature !== lastAssistantSignature.current) {
lastAssistantSignature.current = signature
if (streamIdleTimer.current) {
window.clearTimeout(streamIdleTimer.current)
const finishRun = useCallback(
(runId: string) => {
if (!runId) return
const timer = pendingRunTimersRef.current.get(runId)
if (typeof timer === 'number') {
window.clearTimeout(timer)
}
streamIdleTimer.current = window.setTimeout(() => {
streamFinish()
}, 4000)
}
}, [historyMessages, streamFinish])
pendingRunTimersRef.current.delete(runId)
pendingRunIdsRef.current.delete(runId)
if (pendingRunIdsRef.current.size === 0) {
setPendingGeneration(false)
setWaitingForResponse(false)
}
},
[setWaitingForResponse],
)
useEffect(() => {
if (pendingStartRef.current) {
pendingStartRef.current = false
return
}
if (hasPendingSend() || hasPendingGeneration()) {
const startRun = useCallback(
(runId: string) => {
if (!runId) return
pendingRunIdsRef.current.add(runId)
const existingTimer = pendingRunTimersRef.current.get(runId)
if (typeof existingTimer === 'number') {
window.clearTimeout(existingTimer)
}
const timeout = window.setTimeout(() => {
pendingRunTimersRef.current.delete(runId)
pendingRunIdsRef.current.delete(runId)
refreshHistory()
if (pendingRunIdsRef.current.size === 0) {
setPendingGeneration(false)
setWaitingForResponse(false)
}
}, 120000)
pendingRunTimersRef.current.set(runId, timeout)
setPendingGeneration(true)
setWaitingForResponse(true)
setPinToTop(true)
return
}
streamStop()
lastAssistantSignature.current = ''
setWaitingForResponse(false)
setPinToTop(false)
}, [activeFriendlyId, isNewChat, streamStop])
},
[refreshHistory],
)
useLayoutEffect(() => {
if (isNewChat) return
const pending = consumePendingSend(
forcedSessionKey || resolvedSessionKey || activeSessionKey,
activeFriendlyId,
)
if (!pending) return
pendingStartRef.current = true
const historyKey = chatQueryKeys.history(
pending.friendlyId,
pending.sessionKey,
)
const cached = queryClient.getQueryData<HistoryResponse>(historyKey)
const cachedMessages = Array.isArray(cached?.messages)
? cached.messages
: []
const alreadyHasOptimistic = cachedMessages.some((message) => {
if (pending.optimisticMessage.clientId) {
if (message.clientId === pending.optimisticMessage.clientId) return true
if (message.__optimisticId === pending.optimisticMessage.clientId)
return true
}
if (pending.optimisticMessage.__optimisticId) {
if (message.__optimisticId === pending.optimisticMessage.__optimisticId)
return true
}
return false
})
if (!alreadyHasOptimistic) {
appendHistoryMessage(
queryClient,
pending.friendlyId,
pending.sessionKey,
pending.optimisticMessage,
)
const finishAllRuns = useCallback(() => {
for (const [, timer] of pendingRunTimersRef.current) {
window.clearTimeout(timer)
}
setWaitingForResponse(true)
setPinToTop(true)
sendMessage(
pending.sessionKey,
pending.friendlyId,
pending.message,
true,
pending.attachments,
)
}, [
activeFriendlyId,
activeSessionKey,
forcedSessionKey,
isNewChat,
queryClient,
resolvedSessionKey,
])
pendingRunTimersRef.current.clear()
pendingRunIdsRef.current.clear()
setPendingGeneration(false)
setWaitingForResponse(false)
}, [])
useEffect(() => {
return () => {
finishAllRuns()
}
}, [finishAllRuns])
function sendMessage(
sessionKey: string,
@@ -406,7 +274,16 @@ export function ChatScreen({
})
.then(async (res) => {
if (!res.ok) throw new Error(await readError(res))
streamStart()
const payload = (await res.json().catch(() => ({}))) as {
runId?: string
}
if (
typeof payload.runId === 'string' &&
payload.runId.trim().length > 0
) {
startRun(payload.runId.trim())
}
refreshHistory()
})
.catch((err) => {
const messageText = err instanceof Error ? err.message : String(err)
@@ -590,6 +467,88 @@ export function ChatScreen({
)
}, [gatewayError, handleGatewayRefetch, showGatewayNotice])
const { stopStream } = useChatStream({
activeFriendlyId,
isNewChat,
isRedirecting,
resolvedSessionKey,
sessionKeyForHistory,
queryClient,
refreshHistory,
onChatEvent(payload) {
const payloadSessionKey =
typeof payload.sessionKey === 'string' ? payload.sessionKey : ''
if (
payloadSessionKey &&
resolvedSessionKey &&
payloadSessionKey !== resolvedSessionKey &&
payloadSessionKey !== sessionKeyForHistory
) {
return
}
const runId = typeof payload.runId === 'string' ? payload.runId : ''
const state = typeof payload.state === 'string' ? payload.state : ''
if (runId && state === 'delta') {
startRun(runId)
}
if (
runId &&
(state === 'final' || state === 'error' || state === 'aborted')
) {
finishRun(runId)
}
if (
!runId &&
(state === 'final' || state === 'error' || state === 'aborted')
) {
finishAllRuns()
}
},
})
useChatErrorState({
error,
setError,
isRedirecting,
shouldRedirectToNew,
sessionsReady: sessionsQuery.isSuccess,
activeExists,
sessionsError,
historyError,
gatewayStatusError,
})
useChatRedirect({
activeFriendlyId,
isNewChat,
isRedirecting,
shouldRedirectToNew,
sessionsReady: sessionsQuery.isSuccess,
sessionsCount: sessions.length,
sessionKeyForHistory,
queryClient,
setIsRedirecting,
})
useChatGenerationGuard({
waitingForResponse,
refreshHistory,
setWaitingForResponse,
})
useChatPendingSend({
activeFriendlyId,
activeSessionKey,
forcedSessionKey,
isNewChat,
queryClient,
resolvedSessionKey,
setWaitingForResponse,
setPinToTop,
streamStop: stopStream,
sendMessage,
})
const sidebar = (
<ChatSidebar
sessions={sessions}
@@ -146,16 +146,18 @@ type ImagePart = {
* @param msg - The gateway message to extract images from
* @returns Array of image parts with base64 data
*/
function imagesFromMessage(msg: GatewayMessage): ImagePart[] {
function imagesFromMessage(msg: GatewayMessage): Array<ImagePart> {
const parts = Array.isArray(msg.content) ? msg.content : []
const images: ImagePart[] = []
const images: Array<ImagePart> = []
for (const part of parts) {
const partType = (part as { type?: string }).type
const imagePart = part as unknown as ImagePart
if (
part.type === 'image' &&
partType === 'image' &&
'source' in part &&
typeof (part as ImagePart).source?.data === 'string'
typeof imagePart.source.data === 'string'
) {
images.push(part as ImagePart)
images.push(imagePart)
}
}
return images
@@ -6,6 +6,7 @@ import {
Sun01Icon,
} from '@hugeicons/core-free-icons'
import type { PathsPayload } from '../types'
import type { ThemeMode } from '@/hooks/use-chat-settings'
import {
DialogClose,
DialogContent,
@@ -16,7 +17,6 @@ import {
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsList, TabsTab } from '@/components/ui/tabs'
import { useChatSettings } from '@/hooks/use-chat-settings'
import type { ThemeMode } from '@/hooks/use-chat-settings'
import { Button } from '@/components/ui/button'
type SettingsSectionProps = {
@@ -0,0 +1,74 @@
import { useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { isMissingGatewayAuth } from '../utils'
type UseChatErrorStateInput = {
error: string | null
setError: (value: string | null) => void
isRedirecting: boolean
shouldRedirectToNew: boolean
sessionsReady: boolean
activeExists: boolean
sessionsError: string | null
historyError: string | null
gatewayStatusError: string | null
}
export function useChatErrorState({
error,
setError,
isRedirecting,
shouldRedirectToNew,
sessionsReady,
activeExists,
sessionsError,
historyError,
gatewayStatusError,
}: UseChatErrorStateInput) {
const navigate = useNavigate()
useEffect(() => {
if (isRedirecting) {
if (error) setError(null)
return
}
if (shouldRedirectToNew) {
if (error) setError(null)
return
}
if (sessionsReady && !activeExists) {
if (error) setError(null)
return
}
const messageText = sessionsError ?? historyError ?? gatewayStatusError
if (!messageText) {
if (error?.startsWith('Failed to load')) {
setError(null)
}
return
}
if (isMissingGatewayAuth(messageText)) {
navigate({ to: '/connect', replace: true })
}
const message = sessionsError
? `Failed to load sessions. ${sessionsError}`
: historyError
? `Failed to load history. ${historyError}`
: gatewayStatusError
? `Gateway unavailable. ${gatewayStatusError}`
: null
if (message) setError(message)
}, [
activeExists,
error,
gatewayStatusError,
historyError,
isRedirecting,
navigate,
sessionsError,
sessionsReady,
setError,
shouldRedirectToNew,
])
}
@@ -0,0 +1,51 @@
import { useEffect, useRef } from 'react'
import { setPendingGeneration } from '../pending-send'
type UseChatGenerationGuardInput = {
waitingForResponse: boolean
refreshHistory: () => void
setWaitingForResponse: (value: boolean) => void
}
export function useChatGenerationGuard({
waitingForResponse,
refreshHistory,
setWaitingForResponse,
}: UseChatGenerationGuardInput) {
const timeoutTimer = useRef<number | null>(null)
const waitingRef = useRef(waitingForResponse)
function finish() {
setPendingGeneration(false)
setWaitingForResponse(false)
}
useEffect(() => {
waitingRef.current = waitingForResponse
}, [waitingForResponse])
useEffect(() => {
if (!waitingForResponse) {
if (timeoutTimer.current) {
window.clearTimeout(timeoutTimer.current)
timeoutTimer.current = null
}
return
}
if (!timeoutTimer.current) {
timeoutTimer.current = window.setTimeout(() => {
timeoutTimer.current = null
if (!waitingRef.current) return
refreshHistory()
finish()
}, 120000)
}
}, [
refreshHistory,
setWaitingForResponse,
waitingForResponse,
])
}
@@ -1,8 +1,9 @@
import { useMemo, useRef } from 'react'
import { useQuery, type QueryClient } from '@tanstack/react-query'
import { useQuery } from '@tanstack/react-query'
import { chatQueryKeys, fetchHistory } from '../chat-queries'
import { getMessageTimestamp, textFromMessage } from '../utils'
import type { QueryClient } from '@tanstack/react-query'
import type { GatewayMessage, HistoryResponse } from '../types'
type UseChatHistoryInput = {
@@ -35,27 +36,36 @@ export function useChatHistory({
const historyQuery = useQuery({
queryKey: historyKey,
queryFn: async function fetchHistoryForSession() {
const cached = queryClient.getQueryData(historyKey) as
| HistoryResponse
| undefined
const optimisticMessages = Array.isArray(cached?.messages)
? cached.messages.filter((message) => {
if (message.status === 'sending') return true
if (message.__optimisticId) return true
return Boolean(message.clientId)
})
const cached = queryClient.getQueryData<HistoryResponse>(historyKey)
const cachedMessages = Array.isArray(cached?.messages)
? cached.messages
: []
const optimisticMessages = cachedMessages.filter((message) => {
if (message.status === 'sending') return true
if (message.__optimisticId) return true
return Boolean(message.clientId)
})
const streamingMessages = cachedMessages.filter((message) => {
const runId = (message as { __streamRunId?: unknown }).__streamRunId
return typeof runId === 'string' && runId.trim().length > 0
})
const serverData = await fetchHistory({
sessionKey: sessionKeyForHistory,
friendlyId: activeFriendlyId,
})
if (!optimisticMessages.length) return serverData
if (!optimisticMessages.length && !streamingMessages.length) {
return serverData
}
const merged = mergeOptimisticHistoryMessages(
const mergedWithOptimistic = mergeOptimisticHistoryMessages(
serverData.messages,
optimisticMessages,
)
const merged = mergeStreamingHistoryMessages(
mergedWithOptimistic,
streamingMessages,
)
return {
...serverData,
@@ -79,12 +89,11 @@ export function useChatHistory({
const messages = Array.isArray(historyQuery.data?.messages)
? historyQuery.data.messages
: []
const last = messages[messages.length - 1]
const lastId =
last && typeof (last as { id?: string }).id === 'string'
? (last as { id?: string }).id
: ''
const signature = `${messages.length}:${last?.role ?? ''}:${lastId}:${textFromMessage(last ?? { role: 'user', content: [] }).slice(-32)}`
const last = messages.at(-1)
const lastId = typeof last?.id === 'string' ? last.id : ''
const lastRole = typeof last?.role === 'string' ? last.role : ''
const lastText = last ? textFromMessage(last) : ''
const signature = `${messages.length}:${lastRole}:${lastId}:${lastText.slice(-32)}`
if (signature === stableHistorySignatureRef.current) {
return stableHistoryMessagesRef.current
}
@@ -116,6 +125,44 @@ export function useChatHistory({
}
}
function mergeStreamingHistoryMessages(
serverMessages: Array<GatewayMessage>,
streamingMessages: Array<GatewayMessage>,
): Array<GatewayMessage> {
if (!streamingMessages.length) return serverMessages
const merged = [...serverMessages]
for (const streamingMessage of streamingMessages) {
const runId = (streamingMessage as { __streamRunId?: unknown }).__streamRunId
if (typeof runId !== 'string' || runId.trim().length === 0) continue
const hasMatch = merged.some((serverMessage) => {
const serverRunId = (serverMessage as { __streamRunId?: unknown })
.__streamRunId
if (
typeof serverRunId === 'string' &&
serverRunId.trim().length > 0 &&
serverRunId === runId
) {
return true
}
if (serverMessage.role !== streamingMessage.role) return false
const streamingText = textFromMessage(streamingMessage)
if (!streamingText) return false
if (streamingText !== textFromMessage(serverMessage)) return false
const streamingTime = getMessageTimestamp(streamingMessage)
const serverTime = getMessageTimestamp(serverMessage)
return Math.abs(streamingTime - serverTime) <= 15000
})
if (!hasMatch) {
merged.push(streamingMessage)
}
}
return merged
}
function mergeOptimisticHistoryMessages(
serverMessages: Array<GatewayMessage>,
optimisticMessages: Array<GatewayMessage>,
@@ -0,0 +1,44 @@
import { useEffect, useRef } from 'react'
import { textFromMessage } from '../utils'
import { setPendingGeneration } from '../pending-send'
import type { GatewayMessage } from '../types'
type UseChatIdleFinishInput = {
historyMessages: Array<GatewayMessage>
streamStop: () => void
setWaitingForResponse: (value: boolean) => void
}
export function useChatIdleFinish({
historyMessages,
streamStop,
setWaitingForResponse,
}: UseChatIdleFinishInput) {
const lastAssistantSignature = useRef('')
const streamIdleTimer = useRef<number | null>(null)
useEffect(() => {
if (historyMessages.length === 0) return
const latestMessage = historyMessages[historyMessages.length - 1]
if (latestMessage.role !== 'assistant') return
const signature = `${historyMessages.length}:${textFromMessage(latestMessage).slice(-64)}`
if (signature !== lastAssistantSignature.current) {
lastAssistantSignature.current = signature
if (streamIdleTimer.current) {
window.clearTimeout(streamIdleTimer.current)
}
streamIdleTimer.current = window.setTimeout(() => {
streamStop()
setPendingGeneration(false)
setWaitingForResponse(false)
}, 12000)
}
return () => {
if (streamIdleTimer.current) {
window.clearTimeout(streamIdleTimer.current)
streamIdleTimer.current = null
}
}
}, [historyMessages, setWaitingForResponse, streamStop])
}
@@ -1,7 +1,7 @@
import { useLayoutEffect, useState } from 'react'
import { setChatUiState } from '../chat-ui'
import type { QueryClient } from '@tanstack/react-query'
import { setChatUiState } from '../chat-ui'
export function useChatMobile(queryClient: QueryClient) {
const [isMobile, setIsMobile] = useState(false)
@@ -0,0 +1,116 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
import { appendHistoryMessage, chatQueryKeys } from '../chat-queries'
import {
consumePendingSend,
hasPendingGeneration,
hasPendingSend,
} from '../pending-send'
import type { QueryClient } from '@tanstack/react-query'
import type { AttachmentFile } from '@/components/attachment-button'
import type { HistoryResponse } from '../types'
type UseChatPendingSendInput = {
activeFriendlyId: string
activeSessionKey: string
forcedSessionKey?: string
isNewChat: boolean
queryClient: QueryClient
resolvedSessionKey: string
setWaitingForResponse: (value: boolean) => void
setPinToTop: (value: boolean) => void
streamStop: () => void
sendMessage: (
sessionKey: string,
friendlyId: string,
body: string,
skipOptimistic: boolean,
attachments?: Array<AttachmentFile>,
) => void
}
export function useChatPendingSend({
activeFriendlyId,
activeSessionKey,
forcedSessionKey,
isNewChat,
queryClient,
resolvedSessionKey,
setWaitingForResponse,
setPinToTop,
streamStop,
sendMessage,
}: UseChatPendingSendInput) {
const pendingStartRef = useRef(false)
useEffect(() => {
if (pendingStartRef.current) {
pendingStartRef.current = false
return
}
if (hasPendingSend() || hasPendingGeneration()) {
setWaitingForResponse(true)
setPinToTop(true)
return
}
streamStop()
setWaitingForResponse(false)
}, [activeFriendlyId, isNewChat, setPinToTop, setWaitingForResponse, streamStop])
useLayoutEffect(() => {
if (isNewChat) return
const pending = consumePendingSend(
forcedSessionKey || resolvedSessionKey || activeSessionKey,
activeFriendlyId,
)
if (!pending) return
pendingStartRef.current = true
const historyKey = chatQueryKeys.history(
pending.friendlyId,
pending.sessionKey,
)
const cached = queryClient.getQueryData<HistoryResponse>(historyKey)
const cachedMessages = Array.isArray(cached?.messages)
? cached.messages
: []
const alreadyHasOptimistic = cachedMessages.some((message) => {
if (pending.optimisticMessage.clientId) {
if (message.clientId === pending.optimisticMessage.clientId) return true
if (message.__optimisticId === pending.optimisticMessage.clientId)
return true
}
if (pending.optimisticMessage.__optimisticId) {
if (message.__optimisticId === pending.optimisticMessage.__optimisticId)
return true
}
return false
})
if (!alreadyHasOptimistic) {
appendHistoryMessage(
queryClient,
pending.friendlyId,
pending.sessionKey,
pending.optimisticMessage,
)
}
setWaitingForResponse(true)
setPinToTop(true)
sendMessage(
pending.sessionKey,
pending.friendlyId,
pending.message,
true,
pending.attachments,
)
}, [
activeFriendlyId,
activeSessionKey,
forcedSessionKey,
isNewChat,
queryClient,
resolvedSessionKey,
sendMessage,
setPinToTop,
setWaitingForResponse,
])
}
@@ -0,0 +1,62 @@
import { useEffect } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { clearHistoryMessages } from '../chat-queries'
import { resetPendingSend } from '../pending-send'
import type { QueryClient } from '@tanstack/react-query'
type UseChatRedirectInput = {
activeFriendlyId: string
isNewChat: boolean
isRedirecting: boolean
shouldRedirectToNew: boolean
sessionsReady: boolean
sessionsCount: number
sessionKeyForHistory: string
queryClient: QueryClient
setIsRedirecting: (value: boolean) => void
}
export function useChatRedirect({
activeFriendlyId,
isNewChat,
isRedirecting,
shouldRedirectToNew,
sessionsReady,
sessionsCount,
sessionKeyForHistory,
queryClient,
setIsRedirecting,
}: UseChatRedirectInput) {
const navigate = useNavigate()
useEffect(() => {
if (!isRedirecting) return
if (isNewChat) {
setIsRedirecting(false)
return
}
if (!shouldRedirectToNew && sessionsReady) {
setIsRedirecting(false)
}
}, [isNewChat, isRedirecting, sessionsReady, setIsRedirecting, shouldRedirectToNew])
useEffect(() => {
if (isNewChat) return
if (!sessionsReady) return
if (sessionsCount === 0) return
if (!shouldRedirectToNew) return
resetPendingSend()
clearHistoryMessages(queryClient, activeFriendlyId, sessionKeyForHistory)
navigate({ to: '/new', replace: true })
}, [
activeFriendlyId,
isNewChat,
navigate,
queryClient,
sessionKeyForHistory,
sessionsCount,
sessionsReady,
shouldRedirectToNew,
])
}
@@ -0,0 +1,283 @@
import { useCallback, useEffect, useRef } from 'react'
import { getMessageTimestamp, textFromMessage } from '../utils'
import {
chatQueryKeys,
updateHistoryMessages,
updateSessionLastMessage,
} from '../chat-queries'
import type { QueryClient } from '@tanstack/react-query'
import type { GatewayMessage } from '../types'
type UseChatStreamInput = {
activeFriendlyId: string
isNewChat: boolean
isRedirecting: boolean
resolvedSessionKey: string
sessionKeyForHistory: string
queryClient: QueryClient
refreshHistory: () => void
onChatEvent?: (payload: {
runId?: string
sessionKey?: string
state?: string
message?: GatewayMessage
}) => void
}
export function useChatStream({
activeFriendlyId,
isNewChat,
isRedirecting,
resolvedSessionKey,
sessionKeyForHistory,
queryClient,
refreshHistory,
onChatEvent,
}: UseChatStreamInput) {
const streamSourceRef = useRef<EventSource | null>(null)
const streamReconnectTimer = useRef<number | null>(null)
const streamReconnectAttempt = useRef(0)
const streamRunTextRef = useRef(new Map<string, string>())
const refreshHistoryRef = useRef(refreshHistory)
useEffect(() => {
refreshHistoryRef.current = refreshHistory
}, [refreshHistory])
const stopStream = useCallback(() => {
if (streamReconnectTimer.current) {
window.clearTimeout(streamReconnectTimer.current)
streamReconnectTimer.current = null
}
if (streamSourceRef.current) {
streamSourceRef.current.close()
streamSourceRef.current = null
}
streamRunTextRef.current.clear()
}, [])
useEffect(() => {
if (!activeFriendlyId || isNewChat || isRedirecting) return
let cancelled = false
function startStream() {
if (cancelled) return
if (streamSourceRef.current) {
streamSourceRef.current.close()
streamSourceRef.current = null
}
const params = new URLSearchParams()
const streamSessionKey = resolvedSessionKey || sessionKeyForHistory
if (streamSessionKey) params.set('sessionKey', streamSessionKey)
if (activeFriendlyId) params.set('friendlyId', activeFriendlyId)
const source = new EventSource(`/api/stream?${params.toString()}`)
streamSourceRef.current = source
function handleStreamEvent(event: MessageEvent) {
try {
const parsed = JSON.parse(String(event.data || '{}')) as {
event?: string
payload?: unknown
}
if (parsed.event === 'chat.history') {
const payload = parsed.payload as { messages?: Array<unknown> } | null
if (payload && Array.isArray(payload.messages)) {
queryClient.setQueryData(
chatQueryKeys.history(activeFriendlyId, sessionKeyForHistory),
{
sessionKey: sessionKeyForHistory,
messages: payload.messages,
},
)
return
}
return
}
if (!parsed.event) return
if (parsed.event === 'chat') {
const payload = parsed.payload as
| {
runId?: string
sessionKey?: string
state?: string
message?: GatewayMessage
}
| null
if (payload) {
onChatEvent?.(payload)
}
if (payload?.message && typeof payload.message === 'object') {
const payloadSessionKey = payload.sessionKey
if (
payloadSessionKey &&
resolvedSessionKey &&
payloadSessionKey !== resolvedSessionKey &&
payloadSessionKey !== sessionKeyForHistory
) {
return
}
const streamRunId =
typeof payload.runId === 'string' ? payload.runId : ''
const state = typeof payload.state === 'string' ? payload.state : ''
let nextMessage: GatewayMessage = {
...payload.message,
__streamRunId: streamRunId || undefined,
}
if (streamRunId && state === 'delta') {
const deltaText = textFromMessage(nextMessage)
const previousText = streamRunTextRef.current.get(streamRunId) ?? ''
const cumulativeText = `${previousText}${deltaText}`
if (cumulativeText.length > 0) {
streamRunTextRef.current.set(streamRunId, cumulativeText)
nextMessage = {
...nextMessage,
content: [{ type: 'text', text: cumulativeText }],
}
}
}
if (streamRunId && state === 'final') {
const finalText = textFromMessage(nextMessage)
if (!finalText) {
const bufferedText = streamRunTextRef.current.get(streamRunId)
if (bufferedText) {
nextMessage = {
...nextMessage,
content: [{ type: 'text', text: bufferedText }],
}
}
}
streamRunTextRef.current.delete(streamRunId)
}
if (
streamRunId &&
(state === 'error' || state === 'aborted')
) {
streamRunTextRef.current.delete(streamRunId)
}
function upsert(messages: Array<GatewayMessage>) {
const lastUserIndex = [...messages]
.reverse()
.findIndex((message) => message.role === 'user')
const resolvedLastUserIndex =
lastUserIndex >= 0 ? messages.length - 1 - lastUserIndex : -1
if (streamRunId) {
const index = messages.findIndex(
(message) =>
(message as { __streamRunId?: string }).__streamRunId ===
streamRunId,
)
if (index >= 0) {
if (index > resolvedLastUserIndex) {
const next = [...messages]
next[index] = nextMessage
return next
}
return [...messages, nextMessage]
}
}
if (nextMessage.role === 'assistant') {
const nextTime = getMessageTimestamp(nextMessage)
const index = [...messages]
.reverse()
.findIndex((message) => message.role === 'assistant')
if (index >= 0) {
const target = messages.length - 1 - index
if (target > resolvedLastUserIndex) {
const targetTime = getMessageTimestamp(messages[target])
if (Math.abs(nextTime - targetTime) <= 15000) {
const next = [...messages]
next[target] = nextMessage
return next
}
}
if (resolvedLastUserIndex >= 0 && target <= resolvedLastUserIndex) {
const next = [...messages]
next.push(nextMessage)
return next
}
}
}
return [...messages, nextMessage]
}
updateHistoryMessages(
queryClient,
activeFriendlyId,
sessionKeyForHistory,
upsert,
)
if (payloadSessionKey && payloadSessionKey !== sessionKeyForHistory) {
updateHistoryMessages(
queryClient,
activeFriendlyId,
payloadSessionKey,
upsert,
)
}
if (payloadSessionKey) {
updateSessionLastMessage(
queryClient,
payloadSessionKey,
activeFriendlyId,
nextMessage,
)
}
}
return
}
if (!parsed.event.startsWith('chat.')) {
return
}
} catch {
// ignore parse errors
}
}
function handleStreamOpen() {
streamReconnectAttempt.current = 0
refreshHistoryRef.current()
}
function handleStreamError() {
if (cancelled) return
if (streamReconnectTimer.current) return
if (streamSourceRef.current) {
streamSourceRef.current.close()
streamSourceRef.current = null
}
streamReconnectAttempt.current += 1
const backoff = Math.min(8000, 1000 * streamReconnectAttempt.current)
streamReconnectTimer.current = window.setTimeout(() => {
streamReconnectTimer.current = null
startStream()
}, backoff)
}
source.addEventListener('message', handleStreamEvent)
source.addEventListener('open', handleStreamOpen)
source.addEventListener('error', handleStreamError)
}
startStream()
return () => {
cancelled = true
stopStream()
}
}, [
activeFriendlyId,
isNewChat,
isRedirecting,
resolvedSessionKey,
sessionKeyForHistory,
stopStream,
])
return { stopStream }
}
@@ -6,7 +6,7 @@ export type PendingSendPayload = {
friendlyId: string
message: string
optimisticMessage: GatewayMessage
attachments?: AttachmentFile[]
attachments?: Array<AttachmentFile>
}
let pendingSend: PendingSendPayload | null = null
@@ -21,7 +21,6 @@ export function filterSessionsWithTombstones<
>(sessions: Array<T>) {
if (tombstones.size === 0) return sessions
const now = Date.now()
let changed = false
const next = sessions.filter((session) => {
const keyTombstone = tombstones.get(session.key)
const friendlyTombstone = tombstones.get(session.friendlyId)
@@ -37,11 +36,8 @@ export function filterSessionsWithTombstones<
}
return true
}
if (keyTombstone || friendlyTombstone) {
changed = true
return false
}
if (keyTombstone || friendlyTombstone) return false
return true
})
return changed ? next : sessions
return next
}
+332 -1
View File
@@ -10,7 +10,13 @@ type GatewayFrame =
payload?: unknown
error?: { code: string; message: string; details?: unknown }
}
| { type: 'event'; event: string; payload?: unknown; seq?: number }
| {
type: 'event'
event: string
payload?: unknown
seq?: number
stateVersion?: number
}
type ConnectParams = {
minProtocol: number
@@ -33,6 +39,44 @@ type GatewayWaiter = {
handleMessage: (evt: MessageEvent) => void
}
type GatewayEventFrame = {
type: 'event'
event: string
payload?: unknown
seq?: number
stateVersion?: number
}
type GatewayEventStreamOptions = {
sessionKey?: string
friendlyId?: string
signal?: AbortSignal
onEvent: (event: GatewayEventFrame) => void
onError?: (error: Error) => void
}
type GatewayClient = {
connect: () => Promise<void>
sendReq: <TPayload = unknown>(method: string, params?: unknown) => Promise<TPayload>
close: () => void
setOnEvent: (handler?: (event: GatewayEventFrame) => void) => void
setOnError: (handler?: (error: Error) => void) => void
isClosed: () => boolean
}
type GatewayClientEntry = {
key: string
refs: number
client: GatewayClient
}
type GatewayClientHandle = {
client: GatewayClient
release: () => void
}
const sharedGatewayClients = new Map<string, GatewayClientEntry>()
function getGatewayConfig() {
const url = process.env.CLAWDBOT_GATEWAY_URL?.trim() || 'ws://127.0.0.1:18789'
const token = process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || ''
@@ -69,6 +113,293 @@ function buildConnectParams(token: string, password: string): ConnectParams {
}
}
async function connectGateway(ws: WebSocket): Promise<void> {
const { token, password } = getGatewayConfig()
await wsOpen(ws)
const connectId = randomUUID()
const connectParams = buildConnectParams(token, password)
const connectReq: GatewayFrame = {
type: 'req',
id: connectId,
method: 'connect',
params: connectParams,
}
const waiter = createGatewayWaiter()
ws.addEventListener('message', waiter.handleMessage)
ws.send(JSON.stringify(connectReq))
await waiter.waitForRes(connectId)
ws.removeEventListener('message', waiter.handleMessage)
}
function createGatewayClient(): GatewayClient {
const { url, token, password } = getGatewayConfig()
const ws = new WebSocket(url)
let closed = false
let connected = false
let onEvent: ((event: GatewayEventFrame) => void) | undefined
let onError: ((error: Error) => void) | undefined
const waiters = new Map<
string,
{
resolve: (v: unknown) => void
reject: (e: Error) => void
}
>()
function rejectAll(error: Error) {
for (const [, waiter] of waiters) {
waiter.reject(error)
}
waiters.clear()
}
function handleMessage(evt: MessageEvent) {
try {
const data = typeof evt.data === 'string' ? evt.data : ''
const parsed = JSON.parse(data) as GatewayFrame
if (parsed.type === 'event') {
if (onEvent) onEvent(parsed)
return
}
if (parsed.type !== 'res') return
const waiter = waiters.get(parsed.id)
if (!waiter) return
waiters.delete(parsed.id)
if (parsed.ok) waiter.resolve(parsed.payload)
else waiter.reject(new Error(parsed.error?.message ?? 'gateway error'))
} catch {
// ignore parse errors
}
}
function handleError(err: Event) {
if (onError) {
onError(
new Error(`Gateway client error: ${String((err as any)?.message ?? err)}`),
)
}
}
function handleClose() {
if (closed) return
closed = true
rejectAll(new Error('Gateway client closed'))
}
ws.addEventListener('message', handleMessage)
ws.addEventListener('error', handleError)
ws.addEventListener('close', handleClose)
async function connect() {
if (connected || closed) return
await wsOpen(ws)
const connectId = randomUUID()
const connectParams = buildConnectParams(token, password)
const connectReq: GatewayFrame = {
type: 'req',
id: connectId,
method: 'connect',
params: connectParams,
}
const waitForRes = new Promise<unknown>((resolve, reject) => {
waiters.set(connectId, { resolve, reject })
})
ws.send(JSON.stringify(connectReq))
await waitForRes
connected = true
}
function sendReq<TPayload = unknown>(method: string, params?: unknown) {
if (closed) {
return Promise.reject(new Error('Gateway client closed'))
}
const id = randomUUID()
const req: GatewayFrame = {
type: 'req',
id,
method,
params,
}
const waitForRes = new Promise<unknown>((resolve, reject) => {
waiters.set(id, { resolve, reject })
})
ws.send(JSON.stringify(req))
return waitForRes as Promise<TPayload>
}
function close() {
if (closed) return
closed = true
ws.removeEventListener('message', handleMessage)
ws.removeEventListener('error', handleError)
ws.removeEventListener('close', handleClose)
rejectAll(new Error('Gateway client closed'))
void wsClose(ws)
}
function setOnEvent(handler?: (event: GatewayEventFrame) => void) {
onEvent = handler
}
function setOnError(handler?: (error: Error) => void) {
onError = handler
}
function isClosed() {
return closed
}
return { connect, sendReq, close, setOnEvent, setOnError, isClosed }
}
export async function acquireGatewayClient(
key: string,
options?: {
onEvent?: (event: GatewayEventFrame) => void
onError?: (error: Error) => void
},
): Promise<GatewayClientHandle> {
const existing = sharedGatewayClients.get(key)
if (existing && !existing.client.isClosed()) {
existing.refs += 1
if (options?.onEvent) existing.client.setOnEvent(options.onEvent)
if (options?.onError) existing.client.setOnError(options.onError)
return {
client: existing.client,
release: function release() {
releaseGatewayClient(key)
},
}
}
const client = createGatewayClient()
if (options?.onEvent) client.setOnEvent(options.onEvent)
if (options?.onError) client.setOnError(options.onError)
await client.connect()
sharedGatewayClients.set(key, { key, refs: 1, client })
return {
client,
release: function release() {
releaseGatewayClient(key)
},
}
}
function releaseGatewayClient(key: string) {
const entry = sharedGatewayClients.get(key)
if (!entry) return
entry.refs -= 1
if (entry.refs > 0) return
entry.client.close()
sharedGatewayClients.delete(key)
}
export async function gatewayRpcShared<TPayload = unknown>(
method: string,
params: unknown,
key?: string,
): Promise<TPayload> {
if (key) {
const entry = sharedGatewayClients.get(key)
if (entry && !entry.client.isClosed()) {
await entry.client.connect()
return entry.client.sendReq<TPayload>(method, params)
}
}
return gatewayRpc<TPayload>(method, params)
}
export function gatewayEventStream({
sessionKey,
friendlyId,
signal,
onEvent,
onError,
}: GatewayEventStreamOptions) {
const { url } = getGatewayConfig()
const ws = new WebSocket(url)
let closed = false
function handleMessage(evt: MessageEvent) {
try {
const data = typeof evt.data === 'string' ? evt.data : ''
const parsed = JSON.parse(data) as GatewayFrame
if (parsed.type !== 'event') return
onEvent(parsed)
} catch {
// ignore parse errors
}
}
function handleError(err: Event) {
if (onError) {
onError(
new Error(`Gateway event stream error: ${String((err as any)?.message ?? err)}`),
)
}
}
function handleClose() {
if (closed) return
closed = true
}
ws.addEventListener('message', handleMessage)
ws.addEventListener('error', handleError)
ws.addEventListener('close', handleClose)
void connectGateway(ws)
.then(async () => {
if (!sessionKey && !friendlyId) return
const subscribeReq: GatewayFrame = {
type: 'req',
id: randomUUID(),
method: 'chat.subscribe',
params: {
sessionKey: sessionKey || undefined,
friendlyId: friendlyId || undefined,
},
}
const waiter = createGatewayWaiter()
ws.addEventListener('message', waiter.handleMessage)
try {
ws.send(JSON.stringify(subscribeReq))
await waiter.waitForRes(subscribeReq.id)
} catch (err) {
if (onError) {
onError(err instanceof Error ? err : new Error(String(err)))
}
close()
} finally {
ws.removeEventListener('message', waiter.handleMessage)
}
})
.catch((err) => {
if (onError) onError(err instanceof Error ? err : new Error(String(err)))
})
if (signal) {
signal.addEventListener(
'abort',
() => {
close()
},
{ once: true },
)
}
function close() {
if (closed) return
closed = true
ws.removeEventListener('message', handleMessage)
ws.removeEventListener('error', handleError)
ws.removeEventListener('close', handleClose)
void wsClose(ws)
}
return close
}
function createGatewayWaiter(): GatewayWaiter {
const waiters = new Map<
string,
+1 -1
View File
@@ -3,5 +3,5 @@ pre-commit:
commands:
eslint-webclaw:
glob: "apps/webclaw/**/*.{js,jsx,ts,tsx}"
run: "pnpm -C apps/webclaw eslint --fix {staged_files}"
run: "pnpm -C apps/webclaw exec eslint --fix ."
stage_fixed: true