diff --git a/plugins/docuservix/hooks/useChatStatus.ts b/plugins/docuservix/hooks/useChatStatus.ts new file mode 100644 index 0000000..9113a49 --- /dev/null +++ b/plugins/docuservix/hooks/useChatStatus.ts @@ -0,0 +1,119 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useOptions } from '@docuservix/hooks/useOptions'; +import { ChatStatus } from '@docuservix/models/chat'; + +interface UseChatStatusResult { + status: ChatStatus; + apiUrl: string; + recheck: () => void; +} + +const POLL_INTERVAL_MS = 30_000; +const REQUEST_TIMEOUT_MS = 5_000; + +function sameStatus(a: ChatStatus, b: ChatStatus): boolean { + if (a.kind !== b.kind) return false; + if (a.kind === 'server' && b.kind === 'server') return a.label === b.label; + return true; +} + +export function useChatStatus(): UseChatStatusResult { + const apiUrl = useOptions().api ?? ''; + const statusApiUrl = apiUrl + '/v1/status'; + + const [status, setStatus] = useState({ kind: 'connecting' }); + + const abortRef = useRef(null); + const timerRef = useRef | null>(null); + + const updateStatus = useCallback((next: ChatStatus): void => { + setStatus((prev) => (sameStatus(prev, next) ? prev : next)); + }, []); + + const check = useCallback(async (): Promise => { + abortRef.current?.abort(); + + const controller = new AbortController(); + + abortRef.current = controller; + + let timedOut = false; + const timeoutId = setTimeout(() => { + timedOut = true; + controller.abort(); + }, REQUEST_TIMEOUT_MS); + + try { + const res = await fetch(statusApiUrl, { signal: controller.signal }); + + if (!res.ok) { + updateStatus({ kind: 'offline' }); + return; + } + + const parsed = (await res.json()) as { status?: unknown } | null | undefined; + const raw = typeof parsed?.status === 'string' ? parsed.status : null; + + if (raw === null || raw.length === 0) { + updateStatus({ kind: 'offline' }); + return; + } + + updateStatus({ + kind: 'server', + label: raw.charAt(0).toUpperCase() + raw.slice(1), + }); + } catch { + if (!controller.signal.aborted || timedOut) { + updateStatus({ kind: 'offline' }); + } + } finally { + clearTimeout(timeoutId); + } + }, [statusApiUrl, updateStatus]); + + const scheduleNext = useCallback((): void => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + } + + timerRef.current = setTimeout(async () => { + if (document.visibilityState === 'visible') { + await check(); + } + scheduleNext(); + }, POLL_INTERVAL_MS); + }, [check]); + + const recheck = useCallback((): void => { + updateStatus({ kind: 'connecting' }); + void check(); + scheduleNext(); + }, [check, scheduleNext, updateStatus]); + + useEffect(() => { + void check(); + scheduleNext(); + + const onVisibilityChange = (): void => { + if (document.visibilityState === 'visible') { + void check(); + scheduleNext(); + } + }; + + document.addEventListener('visibilitychange', onVisibilityChange); + + return (): void => { + document.removeEventListener('visibilitychange', onVisibilityChange); + abortRef.current?.abort(); + + if (timerRef.current !== null) { + clearTimeout(timerRef.current); + } + }; + }, [check, scheduleNext]); + + return { status, apiUrl, recheck }; +} diff --git a/plugins/docuservix/models/chat.ts b/plugins/docuservix/models/chat.ts index e1d7afd..c6d9ed1 100644 --- a/plugins/docuservix/models/chat.ts +++ b/plugins/docuservix/models/chat.ts @@ -1,3 +1,8 @@ +export type ChatStatus = + | { kind: 'connecting' } + | { kind: 'offline' } + | { kind: 'server'; label: string }; + export interface IChat { messages: IChatMessage[]; } diff --git a/plugins/docuservix/widgets/chat/Header.module.css b/plugins/docuservix/widgets/chat/Header.module.css index e48bbde..97355a7 100644 --- a/plugins/docuservix/widgets/chat/Header.module.css +++ b/plugins/docuservix/widgets/chat/Header.module.css @@ -24,9 +24,3 @@ color: var(--ifm-font-color-base); font-size: 1.125rem; } - -.Header__info p { - margin: 0; - color: var(--ifm-color-emphasis-600); - font-size: 0.85rem; -} diff --git a/plugins/docuservix/widgets/chat/Header.tsx b/plugins/docuservix/widgets/chat/Header.tsx index b438758..3d4ab36 100644 --- a/plugins/docuservix/widgets/chat/Header.tsx +++ b/plugins/docuservix/widgets/chat/Header.tsx @@ -3,6 +3,7 @@ import React, { ReactNode } from 'react'; import styles from './Header.module.css'; import { RobotIcon } from './icons'; +import { Status } from './Status'; const b = block(styles, 'Header'); @@ -14,7 +15,7 @@ export function Header(): ReactNode {

AI Assistant

-

Ready to help

+
); diff --git a/plugins/docuservix/widgets/chat/Status.module.css b/plugins/docuservix/widgets/chat/Status.module.css new file mode 100644 index 0000000..e7348e1 --- /dev/null +++ b/plugins/docuservix/widgets/chat/Status.module.css @@ -0,0 +1,53 @@ +.Status { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0; + background: none; + border: none; + cursor: pointer; + color: var(--ifm-color-emphasis-600); + font-size: 0.85rem; + line-height: 1; +} + +.Status:hover .Status__label { + color: var(--ifm-color-emphasis-800); +} + +.Status:focus-visible { + outline: 2px solid var(--ifm-color-primary); + outline-offset: 2px; + border-radius: 4px; +} + +.Status__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--ifm-color-emphasis-400); + flex-shrink: 0; +} + +.Status_kind_server .Status__dot { + background: #22c55e; +} + +.Status_kind_offline .Status__dot { + background: #ef4444; +} + +.Status_kind_connecting .Status__dot { + background: #eab308; + animation: Status__pulse 1s ease-in-out infinite; +} + +@keyframes Status__pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} diff --git a/plugins/docuservix/widgets/chat/Status.tsx b/plugins/docuservix/widgets/chat/Status.tsx new file mode 100644 index 0000000..3c77fc9 --- /dev/null +++ b/plugins/docuservix/widgets/chat/Status.tsx @@ -0,0 +1,38 @@ +import block from 'bem-css-modules'; +import React, { ReactNode } from 'react'; + +import { useChatStatus } from '@docuservix/hooks/useChatStatus'; +import { ChatStatus } from '@docuservix/models/chat'; + +import styles from './Status.module.css'; + +const b = block(styles, 'Status'); + +function getLabel(status: ChatStatus): string { + switch (status.kind) { + case 'connecting': + return 'Подключение…'; + case 'offline': + return 'Offline'; + case 'server': + return status.label; + } +} + +export function Status(): ReactNode { + const { status, apiUrl, recheck } = useChatStatus(); + const label = getLabel(status); + + return ( + + ); +}