mirror of
https://github.com/ibelick/webclaw.git
synced 2026-08-14 00:57:51 +00:00
Merge pull request #26 from ibelick/feat/improve-chat-streaming
Feat/improve chat streaming
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user