3 Commits
Author SHA1 Message Date
arswarogandClaude Opus 4.7 18eb34f541 chat/widget: отображение статуса подключения бота
Заменяет захардкоженное "Ready to help" на живой индикатор.
Статус берётся из поля status ответа GET /v1/status, капитализируется
и показывается справа от имени бота. Polling каждые 30с, пауза при
скрытой вкладке, recheck по клику, tooltip с API URL.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-09 11:59:23 +03:00
arswarog 8ff4ba6c94 chat/widget: отображение использованых источников 2026-07-08 11:12:26 +03:00
arswarog 33236cf6a9 chat: подключение к бекенду 2026-07-08 11:11:53 +03:00
7 changed files with 218 additions and 8 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ function useQuery(): string {
}
export function useChat(): UseChatResult {
const chatEndpoint = useOptions().api + '/chat';
const chatEndpoint = useOptions().api + '/v1/chat';
const urlQuery = useQuery();
const [messages, setMessages] = useState<IChatMessage[]>([]);
+119
View File
@@ -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<ChatStatus>({ kind: 'connecting' });
const abortRef = useRef<AbortController | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const updateStatus = useCallback((next: ChatStatus): void => {
setStatus((prev) => (sameStatus(prev, next) ? prev : next));
}, []);
const check = useCallback(async (): Promise<void> => {
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 };
}
+5
View File
@@ -1,3 +1,8 @@
export type ChatStatus =
| { kind: 'connecting' }
| { kind: 'offline' }
| { kind: 'server'; label: string };
export interface IChat {
messages: IChatMessage[];
}
@@ -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;
}
+2 -1
View File
@@ -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 {
</div>
<div className={b('info')}>
<h3>AI Assistant</h3>
<p>Ready to help</p>
<Status />
</div>
</div>
);
@@ -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;
}
}
@@ -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 (
<button
type="button"
className={b({ kind: status.kind })}
onClick={recheck}
title={apiUrl}
aria-label={`Статус: ${label}. Нажмите для проверки`}
>
<span className={b('dot')} />
<span className={b('label')}>{label}</span>
</button>
);
}