From 33236cf6a9d4dc4564a8d9d35bb5f1f3b136eb71 Mon Sep 17 00:00:00 2001 From: Arswarog Date: Wed, 8 Jul 2026 11:11:53 +0300 Subject: [PATCH 1/3] =?UTF-8?q?chat:=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=BA=D0=B5=D0=BD=D0=B4=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/docuservix/hooks/useChat.ts | 88 ++++++++++++++++++++++ plugins/docuservix/hooks/useOptions.ts | 7 ++ plugins/docuservix/index.ts | 12 ++- plugins/docuservix/models/chat.ts | 29 +++++++ plugins/docuservix/models/docuservix.ts | 3 + plugins/docuservix/pages/chat/ChatPage.tsx | 22 ++---- 6 files changed, 143 insertions(+), 18 deletions(-) create mode 100644 plugins/docuservix/hooks/useChat.ts create mode 100644 plugins/docuservix/hooks/useOptions.ts create mode 100644 plugins/docuservix/models/docuservix.ts diff --git a/plugins/docuservix/hooks/useChat.ts b/plugins/docuservix/hooks/useChat.ts new file mode 100644 index 0000000..191654d --- /dev/null +++ b/plugins/docuservix/hooks/useChat.ts @@ -0,0 +1,88 @@ +import { useLocation } from '@docusaurus/router'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useOptions } from '@docuservix/hooks/useOptions'; +import { IChat, IChatMessage, IChatSource } from '@docuservix/models/chat'; + +interface UseChatResult { + dialog: IChat; + typing: boolean; + statusMessage?: string; + sendMessage: (text: string) => void; +} + +function useQuery(): string { + const location = useLocation(); + const params = new URLSearchParams(location.search); + + return params.get('q') ?? ''; +} + +export function useChat(): UseChatResult { + const chatEndpoint = useOptions().api + '/v1/chat'; + const urlQuery = useQuery(); + + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const initialSentRef = useRef(false); + const messagesEndRef = useRef(messages); + + messagesEndRef.current = messages; + + const sendMessage = useCallback( + async (text: string) => { + const content = text.trim(); + + if (!content) { + return; + } + + const userMessage: IChatMessage = { role: 'user', content }; + const newHistory = [...messagesEndRef.current, userMessage]; + + setMessages(newHistory); + setLoading(true); + setError(null); + + try { + const res = await fetch(chatEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ messages: newHistory }), + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + const data: { answer: string; sources?: IChatSource[] } = await res.json(); + + setMessages((prev) => [ + ...prev, + { role: 'assistant', content: data.answer, sources: data.sources }, + ]); + } catch (err) { + setError(err instanceof Error ? err.message : 'Ошибка при обращении к серверу'); + } finally { + setLoading(false); + } + }, + [chatEndpoint], + ); + + useEffect(() => { + if (urlQuery && !initialSentRef.current) { + initialSentRef.current = true; + sendMessage(urlQuery); + } + }, [urlQuery, sendMessage]); + + return { + dialog: { messages }, + typing: loading, + statusMessage: error ?? undefined, + sendMessage, + }; +} diff --git a/plugins/docuservix/hooks/useOptions.ts b/plugins/docuservix/hooks/useOptions.ts new file mode 100644 index 0000000..50c9cee --- /dev/null +++ b/plugins/docuservix/hooks/useOptions.ts @@ -0,0 +1,7 @@ +import { usePluginData } from '@docusaurus/useGlobalData'; + +import { DocuservixOptions } from '@docuservix/models/docuservix'; + +export function useOptions(): DocuservixOptions { + return usePluginData('docuservix') as DocuservixOptions; +} diff --git a/plugins/docuservix/index.ts b/plugins/docuservix/index.ts index 50dbf9c..d9eedbf 100644 --- a/plugins/docuservix/index.ts +++ b/plugins/docuservix/index.ts @@ -2,7 +2,11 @@ import path from 'path'; import type { LoadContext, Plugin } from '@docusaurus/types'; -export default function docuservix() { +import { DocuservixOptions } from '@docuservix/models/docuservix'; + +export default function docuservix(options: Partial = {}) { + const api = process.env.DOCUSERVIX_API || options.api || '/api'; + return function pluginDocuservix(_context: LoadContext): Plugin { return { name: 'docuservix', @@ -18,7 +22,11 @@ export default function docuservix() { }, async contentLoaded({ actions }) { - const { addRoute } = actions; + const { addRoute, setGlobalData } = actions; + + setGlobalData({ + api, + }); addRoute({ path: '/chat', diff --git a/plugins/docuservix/models/chat.ts b/plugins/docuservix/models/chat.ts index bdc8330..e1d7afd 100644 --- a/plugins/docuservix/models/chat.ts +++ b/plugins/docuservix/models/chat.ts @@ -2,7 +2,36 @@ export interface IChat { messages: IChatMessage[]; } +export interface IChatSource { + file: string; + heading: string; + anchor: string; + score: number; +} + export interface IChatMessage { role: 'user' | 'assistant'; content: string; + sources?: IChatSource[]; +} + +function stripNumericPrefixes(p: string): string { + return p + .split('/') + .map((seg) => seg.replace(/^\d+-/, '')) + .join('/'); +} + +export function sourceToUrl(file: string, anchor: string): string { + let p = file.replace(/^docs\//, '').replace(/\.md$/, ''); + + p = stripNumericPrefixes(p); + + return `/docs/${p}${anchor ? `#${anchor}` : ''}`; +} + +export function sourceToPath(file: string): string { + const p = file.replace(/^docs\//, '').replace(/\.md$/, ''); + + return stripNumericPrefixes(p); } diff --git a/plugins/docuservix/models/docuservix.ts b/plugins/docuservix/models/docuservix.ts new file mode 100644 index 0000000..040b9b3 --- /dev/null +++ b/plugins/docuservix/models/docuservix.ts @@ -0,0 +1,3 @@ +export interface DocuservixOptions { + api?: string; +} diff --git a/plugins/docuservix/pages/chat/ChatPage.tsx b/plugins/docuservix/pages/chat/ChatPage.tsx index 8783705..df6a316 100644 --- a/plugins/docuservix/pages/chat/ChatPage.tsx +++ b/plugins/docuservix/pages/chat/ChatPage.tsx @@ -1,30 +1,20 @@ import Layout from '@theme/Layout'; import { ReactNode } from 'react'; -import { IChat } from '@docuservix/models/chat'; +import { useChat } from '@docuservix/hooks/useChat'; import { Chat } from '@docuservix/widgets/chat'; -const dialog: IChat = { - messages: [ - { - role: 'user', - content: 'Can you show me some CSS animations? It can be simple tools like chatbots...', - }, - { - role: 'assistant', - content: "Hello! I'm your **AI assistant**. How can I help you today?", - }, - ], -}; - export function ChatPage(): ReactNode { + const { dialog, typing, statusMessage, sendMessage } = useChat(); + return (
-- 2.54.0 From 8ff4ba6c94f35574fc36320c6a702c3c477ebd8d Mon Sep 17 00:00:00 2001 From: Arswarog Date: Wed, 8 Jul 2026 11:12:26 +0300 Subject: [PATCH 2/3] =?UTF-8?q?chat/widget:=20=D0=BE=D1=82=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8=D1=81=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/chat/Message.module.css | 29 +++++++++++++++++++ plugins/docuservix/widgets/chat/Message.tsx | 21 +++++++++++++- plugins/docuservix/widgets/chat/Messages.tsx | 1 + 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/plugins/docuservix/widgets/chat/Message.module.css b/plugins/docuservix/widgets/chat/Message.module.css index 4e3d362..0484719 100644 --- a/plugins/docuservix/widgets/chat/Message.module.css +++ b/plugins/docuservix/widgets/chat/Message.module.css @@ -34,6 +34,35 @@ border-bottom-right-radius: 0.25rem; } +.Message__sources { + margin-top: 8px; + padding: 8px 1rem 0; + border-top: 1px solid var(--ifm-color-emphasis-200); +} + +.Message__sourcesLabel { + margin-bottom: 4px; + color: var(--ifm-color-emphasis-600); + font-weight: var(--ifm-font-weight-semibold); + font-size: 0.75rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.Message__sourceLink { + display: block; + overflow: hidden; + color: var(--ifm-color-primary); + font-size: 0.8rem; + white-space: nowrap; + text-decoration: none; + text-overflow: ellipsis; +} + +.Message__sourceLink:hover { + text-decoration: underline; +} + @media (max-width: 576px) { .Message { max-width: 100%; diff --git a/plugins/docuservix/widgets/chat/Message.tsx b/plugins/docuservix/widgets/chat/Message.tsx index a09498f..fa120bd 100644 --- a/plugins/docuservix/widgets/chat/Message.tsx +++ b/plugins/docuservix/widgets/chat/Message.tsx @@ -1,8 +1,11 @@ +import Link from '@docusaurus/Link'; import block from 'bem-css-modules'; import React, { ReactNode } from 'react'; import { MD } from '@docuservix/entities/markdown'; +import { IChatSource, sourceToPath, sourceToUrl } from '@docuservix/models/chat'; + import styles from './Message.module.css'; const b = block(styles, 'Message'); @@ -10,14 +13,30 @@ const b = block(styles, 'Message'); interface MessageProps { role: 'user' | 'assistant'; content: string; + sources?: IChatSource[]; } -export function Message({ role, content }: MessageProps): ReactNode { +export function Message({ role, content, sources }: MessageProps): ReactNode { return (
{content}
+ + {sources && sources.length > 0 && ( +
+
Источники:
+ {sources.map((src, j) => ( + + {src.heading || sourceToPath(src.file)} + + ))} +
+ )}
); } diff --git a/plugins/docuservix/widgets/chat/Messages.tsx b/plugins/docuservix/widgets/chat/Messages.tsx index 776b728..878332f 100644 --- a/plugins/docuservix/widgets/chat/Messages.tsx +++ b/plugins/docuservix/widgets/chat/Messages.tsx @@ -21,6 +21,7 @@ export function Messages({ messages, typing }: MessagesProps): ReactNode { key={i} role={msg.role} content={msg.content} + sources={msg.sources} /> ))} -- 2.54.0 From 18eb34f5417b5b3ebcd8b83e6f32db1cd13edf49 Mon Sep 17 00:00:00 2001 From: Arswarog Date: Thu, 9 Jul 2026 11:59:23 +0300 Subject: [PATCH 3/3] =?UTF-8?q?chat/widget:=20=D0=BE=D1=82=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=83=D1=81=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B1=D0=BE=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Заменяет захардкоженное "Ready to help" на живой индикатор. Статус берётся из поля status ответа GET /v1/status, капитализируется и показывается справа от имени бота. Polling каждые 30с, пауза при скрытой вкладке, recheck по клику, tooltip с API URL. Co-Authored-By: Claude Opus 4.7 --- plugins/docuservix/hooks/useChatStatus.ts | 119 ++++++++++++++++++ plugins/docuservix/models/chat.ts | 5 + .../docuservix/widgets/chat/Header.module.css | 6 - plugins/docuservix/widgets/chat/Header.tsx | 3 +- .../docuservix/widgets/chat/Status.module.css | 53 ++++++++ plugins/docuservix/widgets/chat/Status.tsx | 38 ++++++ 6 files changed, 217 insertions(+), 7 deletions(-) create mode 100644 plugins/docuservix/hooks/useChatStatus.ts create mode 100644 plugins/docuservix/widgets/chat/Status.module.css create mode 100644 plugins/docuservix/widgets/chat/Status.tsx 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 ( + + ); +} -- 2.54.0