Compare commits

6 Commits
Author SHA1 Message Date
arswarogandClaude Opus 4.7 d7bec8a5a3 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-08-08 07:49:03 +03:00
arswarog 06efc69250 chat/widget: отображение использованых источников 2026-08-08 07:49:03 +03:00
arswarog d04e3bfb1e chat: подключение к бекенду 2026-08-08 07:49:03 +03:00
arswarog 17b8a8e823 feat(theme): Nord-тема для Mermaid диаграмм в тёмном режиме
feat(theme): Nord mermaid только в тёмном режиме через swizzle

fix(theme): useMemo для config mermaid — убрать бесконечный ре-рендер
2026-08-08 07:39:32 +03:00
arswarog 1f71a0d815 feat(theme): тёмная тема на палитре Nord 2026-08-08 05:56:22 +03:00
arswarog 4d862b58be demo: добавление на главную "рыбы" 2026-08-08 05:55:44 +03:00
19 changed files with 605 additions and 35 deletions
+70
View File
@@ -1,3 +1,73 @@
# Добро пожаловать в Docuservix! # Добро пожаловать в Docuservix!
Вам надо настроить публикацию документации по инструкции в https://git.jt4d.ru/jt4d/docuservix Вам надо настроить публикацию документации по инструкции в https://git.jt4d.ru/jt4d/docuservix
---
```mermaid
classDiagram
class IApi
class ProductService {
+IProduct[] getProducts
}
ProductService --> IApi
Animal <|-- Duck
Animal <|-- Fish
Animal <|-- Zebra
Animal : +int age
Animal : +String gender
Animal: +isMammal()
Animal: +mate()
class Duck{
+String beakColor
+swim()
+quack()
}
class Fish{
-int sizeInFeet
-canEat()
}
class Zebra{
+bool is_wild
+run()
}
```
Для современного мира высококачественный прототип будущего проекта предполагает независимые способы
реализации существующих финансовых и административных условий. Идейные соображения высшего порядка,
а также начало повседневной работы по формированию позиции обеспечивает широкому кругу
(специалистов) участие в формировании существующих финансовых и административных условий.
Безусловно, курс на социально-ориентированный национальный проект прекрасно подходит для реализации
глубокомысленных рассуждений. Каждый из нас понимает очевидную вещь: консультация с широким активом
играет определяющее значение для модели развития. Не следует, однако, забывать, что выбранный нами
инновационный путь создаёт предпосылки для форм воздействия.
Есть над чем задуматься: реплицированные с зарубежных источников, современные исследования призывают
нас к новым свершениям, которые, в свою очередь, должны быть подвергнуты целой серии независимых
исследований. Приятно, граждане, наблюдать, как многие известные личности будут обнародованы.
Следует отметить, что повышение уровня гражданского сознания играет определяющее значение для
экспериментов, поражающих по своей масштабности и грандиозности.
Безусловно, внедрение современных методик создаёт необходимость включения в производственный план
целого ряда внеочередных мероприятий с учётом комплекса позиций, занимаемых участниками в отношении
поставленных задач. Кстати, акционеры крупнейших компаний разоблачены. В своём стремлении улучшить
пользовательский опыт мы упускаем, что диаграммы связей, превозмогая сложившуюся непростую
экономическую ситуацию, разоблачены.
```ts
/**
* Очень важный класс
*/
export class Foo {
bar(value: number): number {
console.log('Bar: ' + value);
return value;
}
}
```
+6
View File
@@ -127,6 +127,12 @@ const config: Config = {
theme: prismThemes.github, theme: prismThemes.github,
darkTheme: prismThemes.dracula, darkTheme: prismThemes.dracula,
}, },
mermaid: {
theme: {
light: 'default',
dark: 'base',
},
},
} satisfies Preset.ThemeConfig, } satisfies Preset.ThemeConfig,
}; };
+88
View File
@@ -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<IChatMessage[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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,
};
}
+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 };
}
+7
View File
@@ -0,0 +1,7 @@
import { usePluginData } from '@docusaurus/useGlobalData';
import { DocuservixOptions } from '@docuservix/models/docuservix';
export function useOptions(): DocuservixOptions {
return usePluginData('docuservix') as DocuservixOptions;
}
+10 -2
View File
@@ -2,7 +2,11 @@ import path from 'path';
import type { LoadContext, Plugin } from '@docusaurus/types'; import type { LoadContext, Plugin } from '@docusaurus/types';
export default function docuservix() { import { DocuservixOptions } from '@docuservix/models/docuservix';
export default function docuservix(options: Partial<DocuservixOptions> = {}) {
const api = process.env.DOCUSERVIX_API || options.api || '/api';
return function pluginDocuservix(_context: LoadContext): Plugin { return function pluginDocuservix(_context: LoadContext): Plugin {
return { return {
name: 'docuservix', name: 'docuservix',
@@ -18,7 +22,11 @@ export default function docuservix() {
}, },
async contentLoaded({ actions }) { async contentLoaded({ actions }) {
const { addRoute } = actions; const { addRoute, setGlobalData } = actions;
setGlobalData({
api,
});
addRoute({ addRoute({
path: '/chat', path: '/chat',
+34
View File
@@ -1,8 +1,42 @@
export type ChatStatus =
| { kind: 'connecting' }
| { kind: 'offline' }
| { kind: 'server'; label: string };
export interface IChat { export interface IChat {
messages: IChatMessage[]; messages: IChatMessage[];
} }
export interface IChatSource {
file: string;
heading: string;
anchor: string;
score: number;
}
export interface IChatMessage { export interface IChatMessage {
role: 'user' | 'assistant'; role: 'user' | 'assistant';
content: string; 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);
} }
+3
View File
@@ -0,0 +1,3 @@
export interface DocuservixOptions {
api?: string;
}
+6 -16
View File
@@ -1,30 +1,20 @@
import Layout from '@theme/Layout'; import Layout from '@theme/Layout';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { IChat } from '@docuservix/models/chat'; import { useChat } from '@docuservix/hooks/useChat';
import { Chat } from '@docuservix/widgets/chat'; 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 { export function ChatPage(): ReactNode {
const { dialog, typing, statusMessage, sendMessage } = useChat();
return ( return (
<Layout title="Чат"> <Layout title="Чат">
<main className="container margin-vert--lg"> <main className="container margin-vert--lg">
<Chat <Chat
dialog={dialog} dialog={dialog}
statusMessage="Unable to connect to the server" typing={typing}
typing statusMessage={statusMessage}
onSend={sendMessage}
/> />
</main> </main>
</Layout> </Layout>
@@ -24,9 +24,3 @@
color: var(--ifm-font-color-base); color: var(--ifm-font-color-base);
font-size: 1.125rem; 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 styles from './Header.module.css';
import { RobotIcon } from './icons'; import { RobotIcon } from './icons';
import { Status } from './Status';
const b = block(styles, 'Header'); const b = block(styles, 'Header');
@@ -14,7 +15,7 @@ export function Header(): ReactNode {
</div> </div>
<div className={b('info')}> <div className={b('info')}>
<h3>AI Assistant</h3> <h3>AI Assistant</h3>
<p>Ready to help</p> <Status />
</div> </div>
</div> </div>
); );
@@ -34,6 +34,35 @@
border-bottom-right-radius: 0.25rem; 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) { @media (max-width: 576px) {
.Message { .Message {
max-width: 100%; max-width: 100%;
+20 -1
View File
@@ -1,8 +1,11 @@
import Link from '@docusaurus/Link';
import block from 'bem-css-modules'; import block from 'bem-css-modules';
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { MD } from '@docuservix/entities/markdown'; import { MD } from '@docuservix/entities/markdown';
import { IChatSource, sourceToPath, sourceToUrl } from '@docuservix/models/chat';
import styles from './Message.module.css'; import styles from './Message.module.css';
const b = block(styles, 'Message'); const b = block(styles, 'Message');
@@ -10,14 +13,30 @@ const b = block(styles, 'Message');
interface MessageProps { interface MessageProps {
role: 'user' | 'assistant'; role: 'user' | 'assistant';
content: string; content: string;
sources?: IChatSource[];
} }
export function Message({ role, content }: MessageProps): ReactNode { export function Message({ role, content, sources }: MessageProps): ReactNode {
return ( return (
<div className={b({ role })}> <div className={b({ role })}>
<div className={b('content')}> <div className={b('content')}>
<MD>{content}</MD> <MD>{content}</MD>
</div> </div>
{sources && sources.length > 0 && (
<div className={b('sources')}>
<div className={b('sourcesLabel')}>Источники:</div>
{sources.map((src, j) => (
<Link
key={j}
to={sourceToUrl(src.file, src.anchor)}
className={b('sourceLink')}
>
{src.heading || sourceToPath(src.file)}
</Link>
))}
</div>
)}
</div> </div>
); );
} }
@@ -21,6 +21,7 @@ export function Messages({ messages, typing }: MessagesProps): ReactNode {
key={i} key={i}
role={msg.role} role={msg.role}
content={msg.content} content={msg.content}
sources={msg.sources}
/> />
))} ))}
@@ -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>
);
}
+30 -9
View File
@@ -17,14 +17,35 @@
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1); --docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
} }
/* For readability concerns, you should choose a lighter palette in dark mode. */ /* Nord dark theme — https://www.nordtheme.com */
[data-theme='dark'] { [data-theme='dark'] {
--ifm-color-primary: #25c2a0; /* Frost — основной акцент (nord8) */
--ifm-color-primary-dark: #21af90; --ifm-color-primary: #88c0d0;
--ifm-color-primary-darker: #1fa588; --ifm-color-primary-dark: #76b5c7;
--ifm-color-primary-darkest: #1a8870; --ifm-color-primary-darker: #6dafc3;
--ifm-color-primary-light: #29d5b0; --ifm-color-primary-darkest: #5e81ac;
--ifm-color-primary-lighter: #32d8b4; --ifm-color-primary-light: #95c8d8;
--ifm-color-primary-lightest: #4fddbf; --ifm-color-primary-lighter: #9ecfdd;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3); --ifm-color-primary-lightest: #b4dce8;
/* Polar Night — фоны */
--ifm-background-color: #2e3440;
--ifm-background-surface-color: #3b4252;
--ifm-navbar-background-color: #2e3440;
--ifm-footer-background-color: #2e3440;
/* Snow Storm — текст */
--ifm-font-color-base: #eceff4;
--ifm-font-color-secondary: #d8dee9;
/* Emphasis scale: Polar Night → Snow Storm */
--ifm-color-emphasis-100: #3b4252;
--ifm-color-emphasis-200: #434c5e;
--ifm-color-emphasis-300: #4c566a;
--ifm-color-emphasis-400: #7b8ba0;
--ifm-color-emphasis-500: #d8dee9;
--ifm-color-emphasis-600: #e5e9f0;
--ifm-color-emphasis-700: #eceff4;
--docusaurus-highlighted-code-line-bg: rgba(136, 192, 208, 0.15);
} }
+82
View File
@@ -0,0 +1,82 @@
import ErrorBoundary from '@docusaurus/ErrorBoundary';
import { ErrorBoundaryErrorMessageFallback } from '@docusaurus/theme-common';
import { useColorMode } from '@docusaurus/theme-common';
import {
MermaidContainerClassName,
useMermaidConfig,
useMermaidRenderResult,
} from '@docusaurus/theme-mermaid/client';
import type { Props } from '@theme/Mermaid';
import type { RenderResult } from 'mermaid';
import React, { useRef, useEffect, useMemo, type ReactNode } from 'react';
import styles from './styles.module.css';
const NORD_THEME_VARIABLES = {
background: '#2e3440',
mainBkg: '#3b4252',
primaryColor: '#5e81ac',
primaryTextColor: '#eceff4',
primaryBorderColor: '#88c0d0',
secondaryColor: '#434c5e',
secondaryTextColor: '#d8dee9',
secondaryBorderColor: '#4c566a',
tertiaryColor: '#3b4252',
tertiaryTextColor: '#d8dee9',
tertiaryBorderColor: '#4c566a',
lineColor: '#88c0d0',
edgeLabelBackground: '#434c5e',
textColor: '#eceff4',
titleColor: '#88c0d0',
nodeBorder: '#4c566a',
clusterBkg: '#434c5e',
clusterBorder: '#4c566a',
};
function MermaidRenderResult({ renderResult }: { renderResult: RenderResult }): ReactNode {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const div = ref.current!;
renderResult.bindFunctions?.(div);
}, [renderResult]);
return (
<div
ref={ref}
className={`${MermaidContainerClassName} ${styles.container}`}
// eslint-disable-next-line react/no-danger -- mermaid renders trusted SVG
dangerouslySetInnerHTML={{ __html: renderResult.svg }}
/>
);
}
function MermaidRenderer({ value }: Props): ReactNode {
const { colorMode } = useColorMode();
const baseConfig = useMermaidConfig();
const config = useMemo(
() =>
colorMode === 'dark'
? { ...baseConfig, themeVariables: NORD_THEME_VARIABLES }
: baseConfig,
[colorMode, baseConfig],
);
const renderResult = useMermaidRenderResult({ text: value, config });
if (renderResult === null) {
return null;
}
return <MermaidRenderResult renderResult={renderResult} />;
}
export default function Mermaid(props: Props): ReactNode {
return (
<ErrorBoundary fallback={(params) => <ErrorBoundaryErrorMessageFallback {...params} />}>
<MermaidRenderer {...props} />
</ErrorBoundary>
);
}
+7
View File
@@ -0,0 +1,7 @@
.container {
max-width: 100%;
}
.container > svg {
max-width: 100%;
}