Compare commits

..

7 Commits

30 changed files with 1904 additions and 1 deletions
+45
View File
@@ -0,0 +1,45 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this
repository.
## Project Overview
Docuservix docs — шаблон документационного сайта на Docusaurus 3.10 (React 19, TypeScript 6).
Конфигурация сайта читается из `.docuservix.yml` (title, project.org, project.repo, dirs). Локаль —
русский (`ru`).
## Commands
- `npm start` — dev-сервер
- `npm run build` — production-сборка в `build/`
- `npm run typecheck` — проверка типов (tsc)
- `npm run prettier:check` — проверка форматирования
- `npm run prettier:fix` — автоформатирование
## Architecture
- `docusaurus.config.ts` — главный конфиг; читает `.docuservix.yml` через `js-yaml`
- `src/pages/` — кастомные страницы (index.tsx — главная)
- `src/css/custom.css` — глобальные CSS-переменные (`--ifm-*`)
- `docs/` — Markdown/MDX-документация
- `blog/` — блог (опционально, включается через `dirs.blog` в `.docuservix.yml`)
- Mermaid-диаграммы включены (`@docusaurus/theme-mermaid`)
- Docusaurus future v4 compatibility flag включён
## Code Style
- Prettier: 4 пробела, single quotes, trailing commas, `printWidth: 100`,
`singleAttributePerLine: true`
- JSON: `printWidth: 10` (каждое свойство на отдельной строке)
- Markdown/MDX: `proseWrap: always`
- Husky + lint-staged: prettier запускается автоматически на pre-commit
- CSS Modules (`*.module.css`) с camelCase именами классов
- **Без default export** в shared/UI компонентах; default export допустим только для Docusaurus
route-компонентов (page components)
## Environment
- Node >= 20
- Env vars: `DOCUSERVIX_URL` (production URL), `DOCUSERVIX_ON_BROKEN_LINKS` (override onBrokenLinks)
- Gitea instance: `git.jt4d.ru`
+13 -1
View File
@@ -6,6 +6,9 @@ import type { Config } from '@docusaurus/types';
import yaml from 'js-yaml';
import { themes as prismThemes } from 'prism-react-renderer';
import docuservix from './plugins/docuservix';
import path from 'path';
interface DocsConfig {
title: string;
project: { org: string; repo: string };
@@ -16,7 +19,7 @@ const docsConfig = yaml.load(fs.readFileSync('./.docuservix.yml', 'utf8')) as Do
const { title } = docsConfig;
const url = process.env.DOCUSERVIX_URL;
const url = process.env.DOCUSERVIX_URL || 'http://example.com';
const { org, repo } = docsConfig.project;
@@ -33,6 +36,15 @@ const config: Config = {
markdown: {
mermaid: true,
},
plugins: [
docuservix,
[
path.resolve(__dirname, './plugins/docuservix-search/index.ts'),
{
providersModule: require.resolve('./src/search-providers'),
},
],
],
themes: ['@docusaurus/theme-mermaid'],
// Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future
+1
View File
@@ -43,6 +43,7 @@
"@docusaurus/preset-classic": "3.10.1",
"@docusaurus/theme-mermaid": "3.10.1",
"@mdx-js/react": "^3.0.0",
"bem-css-modules": "^1.4.3",
"clsx": "^2.0.0",
"js-yaml": "^4.2.0",
"prism-react-renderer": "^2.3.0",
+46
View File
@@ -0,0 +1,46 @@
import path from 'path';
import type { LoadContext, Plugin } from '@docusaurus/types';
interface SearchPluginOptions {
providersModule: string;
}
export default function docuservixSearchPlugin(
_ctx: LoadContext,
options: SearchPluginOptions,
): Plugin {
return {
name: 'docuservix-search',
getThemePath() {
return path.resolve(__dirname, './theme');
},
getTypeScriptThemePath() {
return path.resolve(__dirname, './theme');
},
configureWebpack() {
return {
resolve: {
alias: {
'@docuservix-search/config': options.providersModule,
},
},
};
},
async contentLoaded({ actions }) {
actions.addRoute({
path: '/search',
component: '@theme/SearchPage',
exact: true,
});
actions.addRoute({
path: '/chat-x',
component: '@theme/ChatPage',
exact: true,
});
},
};
}
@@ -0,0 +1,210 @@
import { useLocation } from '@docusaurus/router';
import Link from '@docusaurus/Link';
import Layout from '@theme/Layout';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { chatUrl } from '@docuservix-search/config';
import styles from './styles.module.css';
interface Source {
file: string;
heading: string;
anchor: string;
score: number;
}
interface Message {
role: 'user' | 'assistant';
content: string;
sources?: Source[];
}
function stripNumericPrefixes(p: string): string {
return p
.split('/')
.map((seg) => seg.replace(/^\d+-/, ''))
.join('/');
}
function sourceToUrl(file: string, anchor: string): string {
let p = file.replace(/^docs\//, '').replace(/\.md$/, '');
p = stripNumericPrefixes(p);
return `/docs/${p}${anchor ? `#${anchor}` : ''}`;
}
function sourceToPath(file: string): string {
const p = file.replace(/^docs\//, '').replace(/\.md$/, '');
return stripNumericPrefixes(p);
}
function useQuery(): string {
const location = useLocation();
const params = new URLSearchParams(location.search);
return params.get('q') ?? '';
}
export default function ChatPage(): JSX.Element {
const urlQuery = useQuery();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const initialSentRef = useRef(false);
const sendMessage = useCallback(async (content: string, history: Message[]) => {
if (!content.trim()) return;
const userMessage: Message = { role: 'user', content };
const newHistory = [...history, userMessage];
setMessages(newHistory);
setInput('');
setLoading(true);
setError(null);
try {
const res = await fetch(chatUrl, {
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?: Source[] } = 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);
}
}, []);
useEffect(() => {
if (urlQuery && !initialSentRef.current) {
initialSentRef.current = true;
sendMessage(urlQuery, []);
}
}, [urlQuery, sendMessage]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, loading]);
const handleSend = () => {
if (!loading && input.trim()) {
sendMessage(input, messages);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<Layout title="Чат">
<div className={styles.page}>
{urlQuery && (
<div className={styles.header}>
<Link
to={`/search?q=${encodeURIComponent(urlQuery)}`}
className={styles.backLink}
>
Назад к поиску
</Link>
</div>
)}
<div className={styles.messages}>
{messages.length === 0 && !loading && (
<div className={styles.empty}>Задайте вопрос...</div>
)}
{messages.map((msg, i) => (
<div
key={i}
className={`${styles.messageRow} ${msg.role === 'user' ? styles.messageRowUser : styles.messageRowAssistant}`}
>
<div
className={`${styles.bubble} ${msg.role === 'user' ? styles.userBubble : styles.assistantBubble}`}
>
<div className={styles.bubbleContent}>{msg.content}</div>
{msg.sources && msg.sources.length > 0 && (
<div className={styles.sources}>
<div className={styles.sourcesLabel}>Источники:</div>
{msg.sources.map((src, j) => (
<Link
key={j}
to={sourceToUrl(src.file, src.anchor)}
className={styles.sourceLink}
>
{src.heading || sourceToPath(src.file)}
</Link>
))}
</div>
)}
</div>
</div>
))}
{loading && (
<div className={`${styles.messageRow} ${styles.messageRowAssistant}`}>
<div
className={`${styles.bubble} ${styles.assistantBubble} ${styles.loadingBubble}`}
>
<span className={styles.loadingDot} />
<span className={styles.loadingDot} />
<span className={styles.loadingDot} />
</div>
</div>
)}
{error && (
<div className={styles.errorRow}>
<div className={styles.errorText}>{error}</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className={styles.inputRow}>
<textarea
className={styles.input}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Введите сообщение... (Enter — отправить, Shift+Enter — перенос)"
rows={2}
disabled={loading}
/>
<button
className={styles.sendBtn}
onClick={handleSend}
disabled={loading || !input.trim()}
>
Отправить
</button>
</div>
</div>
</Layout>
);
}
@@ -0,0 +1,203 @@
.page {
max-width: 800px;
margin: 0 auto;
padding: 0 1rem 2rem;
}
.header {
padding: 0.75rem 0;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
.backLink {
color: var(--ifm-color-primary);
font-size: 0.875rem;
text-decoration: none;
}
.backLink:hover {
text-decoration: underline;
}
.messages {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 0;
}
.empty {
padding: 2rem 0;
color: var(--ifm-color-emphasis-500);
font-size: 0.95rem;
text-align: center;
}
.messageRow {
display: flex;
}
.messageRowUser {
justify-content: flex-end;
}
.messageRowAssistant {
justify-content: flex-start;
}
.bubble {
max-width: 75%;
padding: 10px 14px;
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
border-radius: var(--ifm-global-radius);
}
.userBubble {
color: var(--ifm-color-primary-contrast-foreground);
background: var(--ifm-color-primary);
}
.assistantBubble {
color: var(--ifm-font-color-base);
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-200);
}
.bubbleContent {
margin-bottom: 0;
}
.sources {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--ifm-color-emphasis-200);
}
.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;
}
.sourceLink {
display: block;
overflow: hidden;
color: var(--ifm-color-primary);
font-size: 0.8rem;
white-space: nowrap;
text-decoration: none;
text-overflow: ellipsis;
}
.sourceLink:hover {
text-decoration: underline;
}
.loadingBubble {
display: flex;
gap: 4px;
align-items: center;
padding: 12px 16px;
}
.loadingDot {
display: inline-block;
width: 7px;
height: 7px;
background: var(--ifm-color-emphasis-400);
border-radius: 50%;
animation: bounce 1.2s infinite;
}
.loadingDot:nth-child(2) {
animation-delay: 0.2s;
}
.loadingDot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes bounce {
0%,
60%,
100% {
transform: translateY(0);
}
30% {
transform: translateY(-5px);
}
}
.errorRow {
display: flex;
justify-content: center;
}
.errorText {
padding: 8px 14px;
color: var(--ifm-color-danger);
font-size: 0.85rem;
background: var(--ifm-color-danger-contrast-background);
border: 1px solid var(--ifm-color-danger);
border-radius: var(--ifm-global-radius);
}
.inputRow {
display: flex;
gap: 0.5rem;
align-items: flex-end;
padding: 0.75rem 0;
border-top: 1px solid var(--ifm-color-emphasis-200);
}
.input {
flex: 1;
padding: 8px 12px;
color: var(--ifm-font-color-base);
font-size: var(--ifm-font-size-base);
font-family: inherit;
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
outline: none;
transition: border-color 0.2s ease;
resize: none;
}
.input:focus {
border-color: var(--ifm-color-primary);
}
.input:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.sendBtn {
padding: 8px 18px;
color: var(--ifm-color-primary-contrast-foreground);
font-weight: var(--ifm-font-weight-semibold);
font-size: 0.875rem;
white-space: nowrap;
background: var(--ifm-color-primary);
border: none;
border-radius: var(--ifm-global-radius);
cursor: pointer;
transition: opacity 0.15s ease;
}
.sendBtn:hover:not(:disabled) {
opacity: 0.85;
}
.sendBtn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
@@ -0,0 +1,215 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useHistory } from '@docusaurus/router';
import searchConfig from '@docuservix-search/config';
import type { SearchResult } from '../../types';
import styles from './styles.module.css';
const MAX_DROPDOWN_RESULTS = 10;
const DEBOUNCE_MS = 300;
export default function SearchBar(): JSX.Element {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [notices, setNotices] = useState<string[]>([]);
const [hasErrors, setHasErrors] = useState(false);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const history = useHistory();
const runSearch = useCallback(async (q: string) => {
if (q.trim().length < 2) {
setResults([]);
setNotices([]);
setHasErrors(false);
setOpen(false);
return;
}
if (abortRef.current) {
abortRef.current.abort();
}
const controller = new AbortController();
abortRef.current = controller;
const timeout = searchConfig.timeout ?? 5000;
const timeoutId = setTimeout(() => controller.abort(), timeout);
setLoading(true);
setHasErrors(false);
try {
const settled = await Promise.allSettled(
searchConfig.providers.map((provider) => {
const signal = controller.signal;
return provider.search(q, signal);
}),
);
if (controller.signal.aborted) return;
const allResults: SearchResult[] = [];
const allNotices: string[] = [];
let anyError = false;
for (const outcome of settled) {
if (outcome.status === 'fulfilled') {
allResults.push(...outcome.value.results);
if (outcome.value.notice) {
allNotices.push(outcome.value.notice);
}
} else {
anyError = true;
}
}
allResults.sort((a, b) => b.relevance - a.relevance);
const top = allResults.slice(0, MAX_DROPDOWN_RESULTS);
setResults(top);
setNotices(allNotices);
setHasErrors(anyError);
setOpen(true);
setSelectedIndex(-1);
} finally {
clearTimeout(timeoutId);
setLoading(false);
}
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => runSearch(value), DEBOUNCE_MS);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open) {
if (e.key === 'Enter' && query.trim()) {
history.push(`/search?q=${encodeURIComponent(query.trim())}`);
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, -1));
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && results[selectedIndex]) {
window.location.href = results[selectedIndex].url;
} else if (query.trim()) {
history.push(`/search?q=${encodeURIComponent(query.trim())}`);
}
setOpen(false);
} else if (e.key === 'Escape') {
setOpen(false);
setSelectedIndex(-1);
}
};
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSelectedIndex(-1);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
if (abortRef.current) abortRef.current.abort();
};
}, []);
return (
<div
ref={containerRef}
className={styles.container}
>
<input
className={styles.input}
type="search"
placeholder="Поиск..."
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
aria-label="Поиск по документации"
aria-expanded={open}
aria-autocomplete="list"
role="combobox"
/>
{loading && (
<span
className={styles.spinner}
aria-label="Загрузка..."
/>
)}
{open && (
<div
className={styles.dropdown}
role="listbox"
onMouseDown={(e) => e.preventDefault()}
>
{hasErrors && (
<div className={styles.errorBadge}>Некоторые источники недоступны</div>
)}
{results.length === 0 && !loading && (
<div className={styles.empty}>Ничего не найдено</div>
)}
{results.map((result, i) => (
<a
key={`${result.url}-${i}`}
href={result.url}
role="option"
aria-selected={i === selectedIndex}
className={
i === selectedIndex
? `${styles.item} ${styles.selectedItem}`
: styles.item
}
onClick={() => setOpen(false)}
>
<div className={styles.itemHeading}>{result.title}</div>
<div className={styles.itemFile}>
<span
className={styles.badge}
data-type={result.type}
>
{result.type}
</span>
{result.path}
<span className={styles.itemScore}>
{Math.round(result.relevance * 100)}%
</span>
</div>
{result.content && (
<div className={styles.itemContent}>{result.content}</div>
)}
</a>
))}
{notices.map((notice, i) => (
<div
key={i}
className={styles.notice}
>
{notice}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,150 @@
.container {
position: relative;
display: flex;
align-items: center;
}
.input {
width: 200px;
height: 32px;
padding: 0 32px 0 12px;
color: var(--ifm-font-color-base);
font-size: var(--ifm-font-size-base);
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
outline: none;
transition:
width 0.2s ease,
border-color 0.2s ease;
}
.input:focus {
width: 280px;
border-color: var(--ifm-color-primary);
}
.spinner {
position: absolute;
right: 8px;
width: 14px;
height: 14px;
border: 2px solid var(--ifm-color-emphasis-300);
border-top-color: var(--ifm-color-primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
pointer-events: none;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 100;
min-width: 320px;
max-width: 480px;
max-height: 400px;
overflow-y: auto;
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
box-shadow: var(--ifm-global-shadow-md);
}
.item {
display: block;
padding: 10px 14px;
color: var(--ifm-font-color-base);
text-decoration: none;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
.item:last-child {
border-bottom: none;
}
.item:hover {
color: var(--ifm-color-primary);
text-decoration: none;
background: var(--ifm-color-emphasis-100);
}
.selectedItem {
color: var(--ifm-color-primary);
background: var(--ifm-color-emphasis-100);
outline: 2px solid var(--ifm-color-primary-light);
outline-offset: -2px;
}
.itemHeading {
margin-bottom: 2px;
font-weight: var(--ifm-font-weight-semibold);
font-size: 0.9rem;
}
.badge {
display: inline-block;
padding: 1px 6px;
color: var(--ifm-color-emphasis-700);
font-weight: normal;
font-size: 0.65rem;
letter-spacing: 0.04em;
white-space: nowrap;
text-transform: uppercase;
background: var(--ifm-color-emphasis-200);
border-radius: 999px;
}
.itemFile {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 4px;
color: var(--ifm-color-emphasis-600);
font-size: 0.75rem;
}
.itemScore {
margin-left: auto;
color: var(--ifm-color-emphasis-500);
font-size: 0.7rem;
white-space: nowrap;
}
.itemContent {
display: -webkit-box;
overflow: hidden;
color: var(--ifm-color-emphasis-700);
font-size: 0.8rem;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.empty {
padding: 12px 14px;
color: var(--ifm-color-emphasis-600);
font-size: 0.875rem;
text-align: center;
}
.notice {
padding: 8px 14px;
color: var(--ifm-color-info-dark);
font-size: 0.8rem;
background: var(--ifm-color-info-contrast-background);
border-top: 1px solid var(--ifm-color-emphasis-200);
}
.errorBadge {
padding: 8px 14px;
color: var(--ifm-color-warning-dark);
font-size: 0.8rem;
background: var(--ifm-color-warning-contrast-background);
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
@@ -0,0 +1,243 @@
import { useHistory, useLocation } from '@docusaurus/router';
import searchConfig from '@docuservix-search/config';
import Link from '@docusaurus/Link';
import Layout from '@theme/Layout';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { SearchResult } from '../../types';
import styles from './styles.module.css';
const MAX_RESULTS = 25;
function useQuery(): string {
const location = useLocation();
const params = new URLSearchParams(location.search);
return params.get('q') ?? '';
}
export default function SearchPage(): JSX.Element {
const urlQuery = useQuery();
const history = useHistory();
const [inputValue, setInputValue] = useState(urlQuery);
const [results, setResults] = useState<SearchResult[]>([]);
const [notices, setNotices] = useState<string[]>([]);
const [errors, setErrors] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [activeFilter, setActiveFilter] = useState<string>('all');
const abortRef = useRef<AbortController | null>(null);
const runSearch = useCallback(async (q: string) => {
if (!q.trim()) {
setResults([]);
setNotices([]);
setErrors([]);
return;
}
if (abortRef.current) {
abortRef.current.abort();
}
const controller = new AbortController();
abortRef.current = controller;
const timeout = searchConfig.timeout ?? 5000;
const timeoutId = setTimeout(() => controller.abort(), timeout);
setLoading(true);
setErrors([]);
try {
const settled = await Promise.allSettled(
searchConfig.providers.map((provider) => provider.search(q, controller.signal)),
);
if (controller.signal.aborted) {
return;
}
const allResults: SearchResult[] = [];
const allNotices: string[] = [];
const allErrors: string[] = [];
for (let i = 0; i < settled.length; i++) {
const outcome = settled[i];
const provider = searchConfig.providers[i];
if (outcome.status === 'fulfilled') {
allResults.push(...outcome.value.results);
if (outcome.value.notice) {
allNotices.push(outcome.value.notice);
}
} else {
allErrors.push(`${provider.name}: недоступен`);
}
}
allResults.sort((a, b) => b.relevance - a.relevance);
setResults(allResults.slice(0, MAX_RESULTS));
setNotices(allNotices);
setErrors(allErrors);
setActiveFilter('all');
} finally {
clearTimeout(timeoutId);
setLoading(false);
}
}, []);
useEffect(() => {
setInputValue(urlQuery);
runSearch(urlQuery);
}, [urlQuery, runSearch]);
useEffect(() => {
return () => {
if (abortRef.current) {
abortRef.current.abort();
}
};
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && inputValue.trim()) {
history.push(`/search?q=${encodeURIComponent(inputValue.trim())}`);
}
};
const sourceTypes = ['all', ...Array.from(new Set(results.map((r) => r.type)))];
const filteredResults =
activeFilter === 'all' ? results : results.filter((r) => r.type === activeFilter);
const typeLabel: Record<string, string> = {
all: 'Все',
docs: 'Docs',
};
return (
<Layout title={urlQuery ? `Поиск: ${urlQuery}` : 'Поиск'}>
<div className={styles.container}>
<h1 className={styles.heading}>Поиск</h1>
<input
className={styles.searchInput}
type="search"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
placeholder="Введите запрос и нажмите Enter..."
aria-label="Поисковый запрос"
/>
{urlQuery && (
<Link
to={`/chat?q=${encodeURIComponent(urlQuery)}`}
className={styles.chatLink}
>
Спросить ИИ
</Link>
)}
{errors.length > 0 && (
<div
className="alert alert--warning"
role="alert"
>
{errors.map((err, i) => (
<div key={i}>{err}</div>
))}
</div>
)}
{notices.length > 0 && (
<div
className="alert alert--info"
role="note"
>
{notices.map((notice, i) => (
<div key={i}>{notice}</div>
))}
</div>
)}
{loading && <div className={styles.loadingText}>Поиск...</div>}
{!loading && urlQuery && (
<>
{sourceTypes.length > 2 && (
<div
className={styles.filters}
role="tablist"
>
{sourceTypes.map((type) => (
<button
key={type}
role="tab"
aria-selected={activeFilter === type}
className={
activeFilter === type
? `${styles.filterBtn} ${styles.filterBtnActive}`
: styles.filterBtn
}
onClick={() => setActiveFilter(type)}
>
{typeLabel[type] ?? type}
</button>
))}
</div>
)}
{filteredResults.length === 0 ? (
<div className={styles.empty}>Ничего не найдено</div>
) : (
<div className={styles.resultList}>
{filteredResults.map((result, i) => (
<a
key={`${result.url}-${i}`}
href={result.url}
className={styles.resultItem}
>
<div className={styles.resultTitle}>{result.title}</div>
<div className={styles.resultMeta}>
<span
className={styles.badge}
data-type={result.type}
>
{typeLabel[result.type] ?? result.type}
</span>
<span className={styles.resultPath}>{result.path}</span>
{result.anchor && (
<span className={styles.resultAnchor}>
#{result.anchor}
</span>
)}
<span className={styles.resultScore}>
{Math.round(result.relevance * 100)}%
</span>
</div>
{result.content && (
<div className={styles.resultContent}>
{result.content}
</div>
)}
</a>
))}
</div>
)}
</>
)}
</div>
</Layout>
);
}
@@ -0,0 +1,170 @@
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.heading {
margin-bottom: 1rem;
font-size: 1.5rem;
}
.searchInput {
width: 100%;
height: 40px;
margin-bottom: 1.5rem;
padding: 0 16px;
color: var(--ifm-font-color-base);
font-size: var(--ifm-font-size-base);
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
outline: none;
transition: border-color 0.2s ease;
}
.searchInput:focus {
border-color: var(--ifm-color-primary);
}
.loadingText {
padding: 1rem 0;
color: var(--ifm-color-emphasis-600);
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.filterBtn {
padding: 4px 12px;
color: var(--ifm-font-color-base);
font-size: 0.875rem;
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 999px;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.filterBtn:hover {
border-color: var(--ifm-color-primary);
}
.filterBtnActive {
color: var(--ifm-color-primary-contrast-foreground);
background: var(--ifm-color-primary);
border-color: var(--ifm-color-primary);
}
.empty {
padding: 2rem 0;
color: var(--ifm-color-emphasis-600);
font-size: 1rem;
text-align: center;
}
.resultList {
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
}
.resultItem {
display: block;
padding: 12px 16px;
color: var(--ifm-font-color-base);
text-decoration: none;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
transition: background 0.1s ease;
}
.resultItem:last-child {
border-bottom: none;
}
.resultItem:hover {
color: var(--ifm-font-color-base);
text-decoration: none;
background: var(--ifm-color-emphasis-100);
}
.resultTitle {
margin-bottom: 4px;
font-weight: var(--ifm-font-weight-semibold);
font-size: 0.95rem;
}
.resultMeta {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 4px;
}
.resultPath {
color: var(--ifm-color-emphasis-600);
font-size: 0.75rem;
}
.resultAnchor {
color: var(--ifm-color-emphasis-500);
font-size: 0.75rem;
}
.resultScore {
margin-left: auto;
color: var(--ifm-color-emphasis-500);
font-size: 0.75rem;
white-space: nowrap;
}
.resultContent {
display: -webkit-box;
overflow: hidden;
color: var(--ifm-color-emphasis-700);
font-size: 0.85rem;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.chatLink {
display: inline-block;
margin-bottom: 1rem;
padding: 6px 14px;
color: var(--ifm-color-primary);
font-size: 0.875rem;
text-decoration: none;
border: 1px solid var(--ifm-color-primary);
border-radius: var(--ifm-global-radius);
transition:
background 0.15s ease,
color 0.15s ease;
}
.chatLink:hover {
color: var(--ifm-color-primary-contrast-foreground);
text-decoration: none;
background: var(--ifm-color-primary);
}
.badge {
display: inline-block;
padding: 1px 8px;
color: var(--ifm-color-emphasis-700);
font-weight: normal;
font-size: 0.7rem;
letter-spacing: 0.04em;
white-space: nowrap;
text-transform: uppercase;
background: var(--ifm-color-emphasis-200);
border-radius: 999px;
}
+26
View File
@@ -0,0 +1,26 @@
export interface SearchResult {
title: string;
content: string;
path: string;
anchor?: string;
type: string;
relevance: number; // 0–1
url: string;
}
export interface SearchProviderResponse {
results: SearchResult[];
notice?: string;
}
export interface SearchProvider {
id: string;
name: string;
timeout?: number;
search: (query: string, signal: AbortSignal) => Promise<SearchProviderResponse>;
}
export interface SearchConfig {
timeout: number;
providers: SearchProvider[];
}
+31
View File
@@ -0,0 +1,31 @@
import path from 'path';
import type { LoadContext, Plugin } from '@docusaurus/types';
function pluginDocuservix(_context: LoadContext): Plugin {
return {
name: 'docuservix',
configureWebpack() {
return {
resolve: {
alias: {
'@docuservix': path.resolve(__dirname),
},
},
};
},
async contentLoaded({ actions }) {
const { addRoute } = actions;
addRoute({
path: '/chat',
component: '@docuservix/pages/chat',
exact: true,
});
},
};
}
export default pluginDocuservix;
+8
View File
@@ -0,0 +1,8 @@
export interface IChat {
messages: IChatMessage[];
}
export interface IChatMessage {
role: 'user' | 'assistant';
content: string;
}
@@ -0,0 +1,32 @@
import Layout from '@theme/Layout';
import { ReactNode } from 'react';
import { IChat } from '@docuservix/models/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 {
return (
<Layout title="Чат">
<main className="container margin-vert--lg">
<Chat
dialog={dialog}
statusMessage="Unable to connect to the server"
typing
/>
</main>
</Layout>
);
}
+3
View File
@@ -0,0 +1,3 @@
import { ChatPage } from './ChatPage';
export default ChatPage;
@@ -0,0 +1,24 @@
.Chat {
display: flex;
flex-direction: column;
width: 100%;
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: var(--ifm-global-radius);
overflow: hidden;
}
.Chat__statusMessage {
font-size: .65rem;
color: #666;
bottom: 0;
left: 0;
right: 0;
padding: .75rem 2.5rem;
background: #eee;
}
.Chat__statusMessage i {
margin-right: .25rem;
color: #667eea;
}
+37
View File
@@ -0,0 +1,37 @@
import block from 'bem-css-modules';
import React, { ReactNode } from 'react';
import { IChat } from '@docuservix/models/chat';
import styles from './Chat.module.css';
import { Header } from './Header';
import { Input } from './Input';
import { Messages } from './Messages';
const b = block(styles, 'Chat');
interface ChatProps {
dialog: IChat;
typing?: boolean;
statusMessage?: string;
onSend?: (text: string) => void;
}
export function Chat({ dialog, typing, statusMessage, onSend }: ChatProps): ReactNode {
const { messages } = dialog;
return (
<div className={b()}>
<Header />
<Messages
messages={messages}
typing={typing}
/>
{statusMessage && <div className={b('statusMessage')}>{statusMessage}</div>}
<Input
loading={typing}
onSend={onSend}
/>
</div>
);
}
@@ -0,0 +1,32 @@
.Header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
.Header__avatar {
width: 50px;
height: 50px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.5rem;
flex-shrink: 0;
}
.Header__info h3 {
margin: 0 0 0.25rem;
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;
}
@@ -0,0 +1,21 @@
import block from 'bem-css-modules';
import React, { ReactNode } from 'react';
import styles from './Header.module.css';
import { RobotIcon } from './icons';
const b = block(styles, 'Header');
export function Header(): ReactNode {
return (
<div className={b()}>
<div className={b('avatar')}>
<RobotIcon />
</div>
<div className={b('info')}>
<h3>AI Assistant</h3>
<p>Ready to help</p>
</div>
</div>
);
}
@@ -0,0 +1,58 @@
.Input {
display: flex;
align-items: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--ifm-color-emphasis-200);
}
.Input__field {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid var(--ifm-color-emphasis-200);
border-radius: 1.5rem;
font-size: 1rem;
font-family: inherit;
color: var(--ifm-font-color-base);
background: var(--ifm-background-surface-color);
outline: none;
transition: border-color 0.3s;
resize: none;
min-height: 3.25rem;
max-height: 8rem;
field-sizing: content;
}
.Input__field:focus {
border-color: #667eea;
}
.Input__field:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.Input__send {
display: flex;
justify-content: center;
align-items: center;
width: 3.25rem;
height: 3.25rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 50%;
color: white;
font-size: 1.125rem;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.3s;
}
.Input__send:hover:not(:disabled) {
transform: scale(1.1);
}
.Input__send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
+55
View File
@@ -0,0 +1,55 @@
import block from 'bem-css-modules';
import React, { ReactNode, useState } from 'react';
import { PaperPlaneIcon } from './icons';
import styles from './Input.module.css';
const b = block(styles, 'Input');
interface InputProps {
loading?: boolean;
onSend?: (text: string) => void;
}
export function Input({ loading, onSend }: InputProps): ReactNode {
const [input, setInput] = useState('');
const handleSend = () => {
const text = input.trim();
if (!text || loading) {
return;
}
setInput('');
onSend?.(text);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className={b()}>
<textarea
className={b('field')}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message here..."
rows={1}
disabled={loading}
/>
<button
className={b('send')}
onClick={handleSend}
disabled={loading || !input.trim()}
>
<PaperPlaneIcon />
</button>
</div>
);
}
@@ -0,0 +1,41 @@
.Message {
max-width: 85%;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.Message_role_assistant {
align-self: flex-start;
}
.Message_role_user {
align-self: flex-end;
}
.Message__content {
padding: 0.75rem 1rem;
border-radius: 1.25rem;
line-height: 1.5;
}
.Message_role_assistant .Message__content {
background: var(--ifm-color-emphasis-100);
color: var(--ifm-font-color-base);
border-top-left-radius: 0.25rem;
}
.Message_role_user .Message__content {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border-bottom-right-radius: 0.25rem;
}
@media (min-width: 576px) {
.Message {
max-width: 75%;
}
}
@@ -0,0 +1,19 @@
import block from 'bem-css-modules';
import React, { ReactNode } from 'react';
import styles from './Message.module.css';
const b = block(styles, 'Message');
interface MessageProps {
role: 'user' | 'assistant';
content: string;
}
export function Message({ role, content }: MessageProps): ReactNode {
return (
<div className={b({ role })}>
<div className={b('content')}>{content}</div>
</div>
);
}
@@ -0,0 +1,65 @@
.Messages {
flex: 1;
overflow-y: auto;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Typing indicator */
.Messages__typing {
display: flex;
align-items: center;
align-self: flex-start;
}
.Messages__typingIndicator {
display: flex;
gap: 0.25rem;
padding: 0.75rem 1rem;
background: var(--ifm-color-emphasis-100);
border-radius: 1.25rem;
border-top-left-radius: 0.25rem;
}
.Messages__typingIndicator span {
width: 0.5rem;
height: 0.5rem;
background: var(--ifm-color-emphasis-500);
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out;
}
.Messages__typingIndicator span:nth-child(1) { animation-delay: -0.32s; }
.Messages__typingIndicator span:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
/* Scrollbar */
.Messages::-webkit-scrollbar {
width: 6px;
}
.Messages::-webkit-scrollbar-track {
background: transparent;
border-radius: 3px;
}
.Messages::-webkit-scrollbar-thumb {
background: var(--ifm-color-emphasis-300);
border-radius: 3px;
}
.Messages::-webkit-scrollbar-thumb:hover {
background: var(--ifm-color-emphasis-400);
}
@media (min-width: 576px) {
.Messages {
padding: 1.5rem;
}
}
@@ -0,0 +1,38 @@
import block from 'bem-css-modules';
import React, { ReactNode } from 'react';
import { IChatMessage } from '@docuservix/models/chat';
import { Message } from './Message';
import styles from './Messages.module.css';
const b = block(styles, 'Messages');
interface MessagesProps {
messages: IChatMessage[];
typing?: boolean;
}
export function Messages({ messages, typing }: MessagesProps): ReactNode {
return (
<div className={b()}>
{messages.map((msg, i) => (
<Message
key={i}
role={msg.role}
content={msg.content}
/>
))}
{typing && (
<div className={b('typing')}>
<div className={b('typingIndicator')}>
<span></span>
<span></span>
<span></span>
</div>
</div>
)}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import React, { ReactNode } from 'react';
export function RobotIcon(): ReactNode {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5M3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.6 26.6 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.93.93 0 0 1-.765.935c-.845.147-2.34.346-4.235.346s-3.39-.2-4.235-.346A.93.93 0 0 1 3 9.219zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a25 25 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25 25 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135" />
<path d="M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2zM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5" />
</svg>
);
}
export function PaperPlaneIcon(): ReactNode {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M15.964.686a.5.5 0 0 0-.65-.65L.767 5.855H.766l-.452.18a.5.5 0 0 0-.082.887l.41.26.001.002 4.995 3.178 3.178 4.995.002.002.26.41a.5.5 0 0 0 .886-.083zm-1.833 1.89L6.637 10.07l-.215-.338a.5.5 0 0 0-.154-.154l-.338-.215 7.494-7.494 1.178-.471z" />
</svg>
);
}
+1
View File
@@ -0,0 +1 @@
export { Chat } from './Chat';
+77
View File
@@ -0,0 +1,77 @@
import type {
SearchConfig,
SearchProvider,
SearchResult,
} from '../plugins/docuservix-search/types';
interface RawResult {
score: number;
file: string;
heading: string;
anchor?: string;
content: string;
}
function stripNumericPrefixes(p: string): string {
return p
.split('/')
.map((seg) => seg.replace(/^\d+-/, ''))
.join('/');
}
function resultToUrl(file: string, anchor: string): string {
let p = file.replace(/^docs\//, '').replace(/\.md$/, '');
p = stripNumericPrefixes(p);
return `/docs/${p}#${anchor}`;
}
function resultToDisplayPath(file: string): string {
const p = file.replace(/^docs\//, '').replace(/\.md$/, '');
return stripNumericPrefixes(p);
}
function rawToSearchResult(raw: RawResult): SearchResult {
return {
title: raw.heading,
content: raw.content,
path: resultToDisplayPath(raw.file),
anchor: raw.anchor,
type: 'docs',
relevance: raw.score,
url: resultToUrl(raw.file, raw.anchor),
};
}
const docsProvider: SearchProvider = {
id: 'docs',
name: 'Документация',
timeout: 5000,
async search(query, signal) {
const res = await fetch(
`http://localhost:8080/1vit/more/v1/search?q=${encodeURIComponent(query)}&limit=25`,
{ signal },
);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data: { results: RawResult[] } = await res.json();
return {
results: (data.results ?? []).map(rawToSearchResult),
};
},
};
const searchConfig: SearchConfig = {
timeout: 5000,
providers: [docsProvider],
};
export default searchConfig;
export const chatUrl = 'http://localhost:8080/1vit/more/v1/chat';
+5
View File
@@ -5,6 +5,11 @@
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@docuservix/*": [
"plugins/docuservix/*"
]
},
"ignoreDeprecations": "6.0",
"strict": true
},
+5
View File
@@ -4505,6 +4505,11 @@ batch@0.6.1:
resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16"
integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==
bem-css-modules@^1.4.3:
version "1.4.3"
resolved "https://registry.yarnpkg.com/bem-css-modules/-/bem-css-modules-1.4.3.tgz#b5a55bf5275c958b26691cea3d5afba77e888239"
integrity sha512-oB+hoRw5+M6Tt8N/DY5IpVYQEWbxd9UoGZeFJiMdpaO676zTuQhHJns9d+tfJw3yAIvg9oCf/7QlffT+vPGgEA==
big.js@^5.2.2:
version "5.2.2"
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"