fix: surface gateway connectivity failures in chat flow
This commit is contained in:
@@ -1,11 +1,23 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { json } from '@tanstack/react-start'
|
||||
import { gatewayConnectCheck } from '../../server/gateway'
|
||||
|
||||
export const Route = createFileRoute('/api/ping')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async () => {
|
||||
return json({ ok: true })
|
||||
try {
|
||||
await gatewayConnectCheck()
|
||||
return json({ ok: true })
|
||||
} catch (err) {
|
||||
return json(
|
||||
{
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,6 +7,11 @@ import type {
|
||||
SessionMeta,
|
||||
} from './types'
|
||||
|
||||
type GatewayStatusResponse = {
|
||||
ok: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const chatQueryKeys = {
|
||||
sessions: ['chat', 'sessions'] as const,
|
||||
history: function history(friendlyId: string, sessionKey: string) {
|
||||
@@ -33,6 +38,24 @@ export async function fetchHistory(payload: {
|
||||
return (await res.json()) as HistoryResponse
|
||||
}
|
||||
|
||||
export async function fetchGatewayStatus(): Promise<GatewayStatusResponse> {
|
||||
const controller = new AbortController()
|
||||
const timeout = window.setTimeout(() => controller.abort(), 2500)
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/ping', { signal: controller.signal })
|
||||
if (!res.ok) throw new Error(await readError(res))
|
||||
return (await res.json()) as GatewayStatusResponse
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new Error('Gateway check timed out')
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
window.clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
export function updateHistoryMessages(
|
||||
queryClient: QueryClient,
|
||||
friendlyId: string,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
chatQueryKeys,
|
||||
appendHistoryMessage,
|
||||
clearHistoryMessages,
|
||||
fetchGatewayStatus,
|
||||
removeHistoryMessageByClientId,
|
||||
updateHistoryMessageByClientId,
|
||||
updateSessionLastMessage,
|
||||
@@ -29,6 +30,7 @@ import { ChatSidebar } from './components/chat-sidebar'
|
||||
import { ChatHeader } from './components/chat-header'
|
||||
import { ChatMessageList } from './components/chat-message-list'
|
||||
import { ChatComposer } from './components/chat-composer'
|
||||
import { GatewayStatusMessage } from './components/gateway-status-message'
|
||||
import {
|
||||
consumePendingSend,
|
||||
hasPendingGeneration,
|
||||
@@ -120,6 +122,25 @@ export function ChatScreen({
|
||||
},
|
||||
staleTime: Infinity,
|
||||
})
|
||||
const gatewayStatusQuery = useQuery({
|
||||
queryKey: ['gateway', 'status'],
|
||||
queryFn: fetchGatewayStatus,
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: 'always',
|
||||
})
|
||||
const gatewayStatusMountRef = useRef(Date.now())
|
||||
const gatewayStatusError =
|
||||
gatewayStatusQuery.error instanceof Error
|
||||
? gatewayStatusQuery.error.message
|
||||
: gatewayStatusQuery.data && !gatewayStatusQuery.data.ok
|
||||
? gatewayStatusQuery.data.error || 'Gateway unavailable'
|
||||
: null
|
||||
const gatewayError = gatewayStatusError ?? sessionsError ?? historyError
|
||||
const handleGatewayRefetch = useCallback(() => {
|
||||
void gatewayStatusQuery.refetch()
|
||||
}, [gatewayStatusQuery])
|
||||
const isSidebarCollapsed = uiQuery.data?.isSidebarCollapsed ?? false
|
||||
const handleActiveSessionDelete = useCallback(() => {
|
||||
setError(null)
|
||||
@@ -166,7 +187,7 @@ export function ChatScreen({
|
||||
if (error) setError(null)
|
||||
return
|
||||
}
|
||||
const messageText = sessionsError ?? historyError
|
||||
const messageText = sessionsError ?? historyError ?? gatewayStatusError
|
||||
if (!messageText) {
|
||||
if (error?.startsWith('Failed to load')) {
|
||||
setError(null)
|
||||
@@ -180,9 +201,18 @@ export function ChatScreen({
|
||||
? `Failed to load sessions. ${sessionsError}`
|
||||
: historyError
|
||||
? `Failed to load history. ${historyError}`
|
||||
: null
|
||||
: gatewayStatusError
|
||||
? `Gateway unavailable. ${gatewayStatusError}`
|
||||
: null
|
||||
if (message) setError(message)
|
||||
}, [error, historyError, isRedirecting, navigate, sessionsError])
|
||||
}, [
|
||||
error,
|
||||
gatewayStatusError,
|
||||
historyError,
|
||||
isRedirecting,
|
||||
navigate,
|
||||
sessionsError,
|
||||
])
|
||||
|
||||
const shouldRedirectToNew =
|
||||
!isNewChat &&
|
||||
@@ -519,7 +549,22 @@ export function ChatScreen({
|
||||
|
||||
const historyLoading =
|
||||
(historyQuery.isLoading && !historyQuery.data) || isRedirecting
|
||||
const showGatewayDown = Boolean(gatewayStatusError)
|
||||
const showGatewayNotice =
|
||||
showGatewayDown &&
|
||||
gatewayStatusQuery.errorUpdatedAt > gatewayStatusMountRef.current
|
||||
const historyEmpty = !historyLoading && displayMessages.length === 0
|
||||
const gatewayNotice = useMemo(() => {
|
||||
if (!showGatewayNotice) return null
|
||||
if (!gatewayError) return null
|
||||
return (
|
||||
<GatewayStatusMessage
|
||||
state="error"
|
||||
error={gatewayError}
|
||||
onRetry={handleGatewayRefetch}
|
||||
/>
|
||||
)
|
||||
}, [gatewayError, handleGatewayRefetch, showGatewayNotice])
|
||||
|
||||
const sidebar = (
|
||||
<ChatSidebar
|
||||
@@ -565,25 +610,14 @@ export function ChatScreen({
|
||||
onOpenSidebar={handleOpenSidebar}
|
||||
/>
|
||||
|
||||
{error && !hideUi ? (
|
||||
<div className="border-b border-primary-200 bg-primary-100 px-4 py-3 text-sm text-primary-800">
|
||||
<div className="font-medium">{error}</div>
|
||||
<div className="text-xs text-primary-700 mt-1">
|
||||
Check that the dashboard server has access to the Clawdbot
|
||||
Gateway and that{' '}
|
||||
<code className="inline-code">CLAWDBOT_GATEWAY_TOKEN</code> (or{' '}
|
||||
<code className="inline-code">CLAWDBOT_GATEWAY_PASSWORD</code>)
|
||||
is set in your server environment.
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hideUi ? null : (
|
||||
<>
|
||||
<ChatMessageList
|
||||
messages={displayMessages}
|
||||
loading={historyLoading}
|
||||
empty={historyEmpty}
|
||||
notice={gatewayNotice}
|
||||
noticePosition="end"
|
||||
waitingForResponse={waitingForResponse}
|
||||
sessionKey={activeCanonicalKey}
|
||||
pinToTop={pinToTop}
|
||||
|
||||
@@ -13,6 +13,9 @@ type ChatMessageListProps = {
|
||||
messages: Array<GatewayMessage>
|
||||
loading: boolean
|
||||
empty: boolean
|
||||
emptyState?: React.ReactNode
|
||||
notice?: React.ReactNode
|
||||
noticePosition?: 'start' | 'end'
|
||||
waitingForResponse: boolean
|
||||
sessionKey?: string
|
||||
pinToTop: boolean
|
||||
@@ -25,6 +28,9 @@ function ChatMessageListComponent({
|
||||
messages,
|
||||
loading,
|
||||
empty,
|
||||
emptyState,
|
||||
notice,
|
||||
noticePosition = 'start',
|
||||
waitingForResponse,
|
||||
sessionKey,
|
||||
pinToTop,
|
||||
@@ -106,8 +112,9 @@ function ChatMessageListComponent({
|
||||
// mt-2 is to fix the prompt-input cut off
|
||||
<ChatContainerRoot className="flex-1 min-h-0 -mb-4">
|
||||
<ChatContainerContent className="pt-6" style={contentStyle}>
|
||||
{empty ? (
|
||||
<div aria-hidden></div>
|
||||
{notice && noticePosition === 'start' ? notice : null}
|
||||
{empty && !notice ? (
|
||||
(emptyState ?? <div aria-hidden></div>)
|
||||
) : hasGroup ? (
|
||||
<>
|
||||
{displayMessages
|
||||
@@ -201,6 +208,7 @@ function ChatMessageListComponent({
|
||||
)
|
||||
})
|
||||
)}
|
||||
{notice && noticePosition === 'end' ? notice : null}
|
||||
<ChatContainerScrollAnchor
|
||||
ref={anchorRef as React.RefObject<HTMLDivElement>}
|
||||
/>
|
||||
@@ -217,6 +225,9 @@ function areChatMessageListEqual(
|
||||
prev.messages === next.messages &&
|
||||
prev.loading === next.loading &&
|
||||
prev.empty === next.empty &&
|
||||
prev.emptyState === next.emptyState &&
|
||||
prev.notice === next.notice &&
|
||||
prev.noticePosition === next.noticePosition &&
|
||||
prev.waitingForResponse === next.waitingForResponse &&
|
||||
prev.sessionKey === next.sessionKey &&
|
||||
prev.pinToTop === next.pinToTop &&
|
||||
|
||||
45
src/screens/chat/components/gateway-status-message.tsx
Normal file
45
src/screens/chat/components/gateway-status-message.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { MessageStatus } from './message-status'
|
||||
|
||||
type GatewayStatusMessageProps = {
|
||||
state: 'checking' | 'error'
|
||||
error?: string | null
|
||||
onRetry?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GatewayStatusMessage({
|
||||
state,
|
||||
error,
|
||||
onRetry,
|
||||
className,
|
||||
}: GatewayStatusMessageProps) {
|
||||
const isChecking = state === 'checking'
|
||||
const title = isChecking
|
||||
? 'Checking gateway connection...'
|
||||
: 'OpenClaw gateway is unreachable'
|
||||
const description = isChecking
|
||||
? 'This dashboard needs access to the OpenClaw gateway configured by your server environment variables.'
|
||||
: ''
|
||||
return (
|
||||
<MessageStatus
|
||||
title={title}
|
||||
description={
|
||||
isChecking ? (
|
||||
description
|
||||
) : (
|
||||
<>
|
||||
We could not reach the gateway from the dashboard server. Start the
|
||||
gateway and confirm your server environment has{' '}
|
||||
<span className="font-mono">CLAWDBOT_GATEWAY_URL</span> plus{' '}
|
||||
<span className="font-mono">CLAWDBOT_GATEWAY_TOKEN</span> (or{' '}
|
||||
<span className="font-mono">CLAWDBOT_GATEWAY_PASSWORD</span>).
|
||||
</>
|
||||
)
|
||||
}
|
||||
detail={isChecking ? null : error}
|
||||
actionLabel={isChecking ? undefined : 'Retry'}
|
||||
onAction={isChecking ? undefined : onRetry}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
}
|
||||
42
src/screens/chat/components/message-status.tsx
Normal file
42
src/screens/chat/components/message-status.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Message } from '@/components/prompt-kit/message'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type MessageStatusProps = {
|
||||
title: string
|
||||
description: React.ReactNode
|
||||
detail?: string | null
|
||||
actionLabel?: string
|
||||
onAction?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MessageStatus({
|
||||
title,
|
||||
description,
|
||||
detail,
|
||||
actionLabel,
|
||||
onAction,
|
||||
className,
|
||||
}: MessageStatusProps) {
|
||||
return (
|
||||
<div className={cn('w-full max-w-[900px]', className)}>
|
||||
<Message>
|
||||
<div className="w-full rounded-xl border border-primary-200 bg-primary-50 p-4 text-primary-900">
|
||||
<div className="text-balance font-medium">{title}</div>
|
||||
<div className="mt-2 text-pretty text-primary-700">{description}</div>
|
||||
{detail ? (
|
||||
<div className="mt-2 text-xs text-primary-600">{detail}</div>
|
||||
) : null}
|
||||
{actionLabel && onAction ? (
|
||||
<div className="mt-3">
|
||||
<Button size="sm" variant="outline" onClick={onAction}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Message>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,11 @@ type ConnectParams = {
|
||||
scopes?: Array<string>
|
||||
}
|
||||
|
||||
type GatewayWaiter = {
|
||||
waitForRes: (id: string) => Promise<unknown>
|
||||
handleMessage: (evt: MessageEvent) => void
|
||||
}
|
||||
|
||||
function getGatewayConfig() {
|
||||
const url = process.env.CLAWDBOT_GATEWAY_URL?.trim() || 'ws://127.0.0.1:18789'
|
||||
const token = process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || ''
|
||||
@@ -43,6 +48,60 @@ function getGatewayConfig() {
|
||||
return { url, token, password }
|
||||
}
|
||||
|
||||
function buildConnectParams(token: string, password: string): ConnectParams {
|
||||
return {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: 'gateway-client',
|
||||
displayName: 'webclaw',
|
||||
version: 'dev',
|
||||
platform: process.platform,
|
||||
mode: 'ui',
|
||||
instanceId: randomUUID(),
|
||||
},
|
||||
auth: {
|
||||
token: token || undefined,
|
||||
password: password || undefined,
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: ['operator.admin'],
|
||||
}
|
||||
}
|
||||
|
||||
function createGatewayWaiter(): GatewayWaiter {
|
||||
const waiters = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (v: unknown) => void
|
||||
reject: (e: Error) => void
|
||||
}
|
||||
>()
|
||||
|
||||
function waitForRes(id: string) {
|
||||
return new Promise<unknown>((resolve, reject) => {
|
||||
waiters.set(id, { resolve, reject })
|
||||
})
|
||||
}
|
||||
|
||||
function handleMessage(evt: MessageEvent) {
|
||||
try {
|
||||
const data = typeof evt.data === 'string' ? evt.data : ''
|
||||
const parsed = JSON.parse(data) as GatewayFrame
|
||||
if (parsed.type !== 'res') return
|
||||
const w = waiters.get(parsed.id)
|
||||
if (!w) return
|
||||
waiters.delete(parsed.id)
|
||||
if (parsed.ok) w.resolve(parsed.payload)
|
||||
else w.reject(new Error(parsed.error?.message ?? 'gateway error'))
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
return { waitForRes, handleMessage }
|
||||
}
|
||||
|
||||
async function wsOpen(ws: WebSocket): Promise<void> {
|
||||
if (ws.readyState === ws.OPEN) return
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -83,24 +142,7 @@ export async function gatewayRpc<TPayload = unknown>(
|
||||
|
||||
// 1) connect handshake (must be first request)
|
||||
const connectId = randomUUID()
|
||||
const connectParams: ConnectParams = {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: 'gateway-client',
|
||||
displayName: 'webclaw',
|
||||
version: 'dev',
|
||||
platform: process.platform,
|
||||
mode: 'ui',
|
||||
instanceId: randomUUID(),
|
||||
},
|
||||
auth: {
|
||||
token: token || undefined,
|
||||
password: password || undefined,
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: ['operator.admin'],
|
||||
}
|
||||
const connectParams = buildConnectParams(token, password)
|
||||
|
||||
const connectReq: GatewayFrame = {
|
||||
type: 'req',
|
||||
@@ -117,44 +159,17 @@ export async function gatewayRpc<TPayload = unknown>(
|
||||
params,
|
||||
}
|
||||
|
||||
// Response waiters keyed by id
|
||||
const waiters = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: (v: any) => void
|
||||
reject: (e: Error) => void
|
||||
}
|
||||
>()
|
||||
const waiter = createGatewayWaiter()
|
||||
|
||||
const waitForRes = (id: string) =>
|
||||
new Promise<any>((resolve, reject) => {
|
||||
waiters.set(id, { resolve, reject })
|
||||
})
|
||||
|
||||
const onMessage = (evt: MessageEvent) => {
|
||||
try {
|
||||
const data = typeof evt.data === 'string' ? evt.data : ''
|
||||
const parsed = JSON.parse(data) as GatewayFrame
|
||||
if (parsed.type !== 'res') return
|
||||
const w = waiters.get(parsed.id)
|
||||
if (!w) return
|
||||
waiters.delete(parsed.id)
|
||||
if (parsed.ok) w.resolve(parsed.payload)
|
||||
else w.reject(new Error(parsed.error?.message ?? 'gateway error'))
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
ws.addEventListener('message', onMessage)
|
||||
ws.addEventListener('message', waiter.handleMessage)
|
||||
|
||||
ws.send(JSON.stringify(connectReq))
|
||||
await waitForRes(connectId)
|
||||
await waiter.waitForRes(connectId)
|
||||
|
||||
ws.send(JSON.stringify(req))
|
||||
const payload = await waitForRes(requestId)
|
||||
const payload = await waiter.waitForRes(requestId)
|
||||
|
||||
ws.removeEventListener('message', onMessage)
|
||||
ws.removeEventListener('message', waiter.handleMessage)
|
||||
return payload as TPayload
|
||||
} finally {
|
||||
try {
|
||||
@@ -164,3 +179,33 @@ export async function gatewayRpc<TPayload = unknown>(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function gatewayConnectCheck(): Promise<void> {
|
||||
const { url, token, password } = getGatewayConfig()
|
||||
|
||||
const ws = new WebSocket(url)
|
||||
try {
|
||||
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)
|
||||
} finally {
|
||||
try {
|
||||
await wsClose(ws)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user