diff --git a/docusaurus.config.ts b/docusaurus.config.ts index cbc2920..4e46379 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -35,7 +35,7 @@ const config: Config = { markdown: { mermaid: true, }, - plugins: [docuservix], + plugins: [docuservix()], themes: ['@docusaurus/theme-mermaid'], // Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future diff --git a/plugins/docuservix/hooks/useChat.ts b/plugins/docuservix/hooks/useChat.ts new file mode 100644 index 0000000..83d36a2 --- /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 + '/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 4dce0c6..48df9f2 100644 --- a/plugins/docuservix/index.ts +++ b/plugins/docuservix/index.ts @@ -2,30 +2,38 @@ import path from 'path'; import type { LoadContext, Plugin } from '@docusaurus/types'; -function pluginDocuservix(_context: LoadContext): Plugin { - return { - name: 'docuservix', +import { DocuservixOptions } from '@docuservix/models/docuservix'; - configureWebpack() { - return { - resolve: { - alias: { - '@docuservix': path.resolve(__dirname), +export default function docuservix(options: Partial = {}) { + const { api = '/api' } = options; + + return function pluginDocuservix(_context: LoadContext): Plugin { + return { + name: 'docuservix', + + configureWebpack() { + return { + resolve: { + alias: { + '@docuservix': path.resolve(__dirname), + }, }, - }, - }; - }, + }; + }, - async contentLoaded({ actions }) { - const { addRoute } = actions; + async contentLoaded({ actions }) { + const { addRoute, setGlobalData } = actions; - addRoute({ - path: '/chat', - component: '@docuservix/pages/chat', - exact: true, - }); - }, + setGlobalData({ + api, + }); + + addRoute({ + path: '/chat', + component: '@docuservix/pages/chat', + exact: true, + }); + }, + }; }; } - -export default pluginDocuservix; 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 9f9131c..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 (
diff --git a/plugins/docuservix/widgets/chat/Message.module.css b/plugins/docuservix/widgets/chat/Message.module.css index 151a444..0b0bf1c 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 (min-width: 576px) { .Message { max-width: 75%; diff --git a/plugins/docuservix/widgets/chat/Message.tsx b/plugins/docuservix/widgets/chat/Message.tsx index 9bdcd1a..9a268fb 100644 --- a/plugins/docuservix/widgets/chat/Message.tsx +++ b/plugins/docuservix/widgets/chat/Message.tsx @@ -1,6 +1,9 @@ +import Link from '@docusaurus/Link'; import block from 'bem-css-modules'; import React, { ReactNode } from 'react'; +import { IChatSource, sourceToPath, sourceToUrl } from '@docuservix/models/chat'; + import styles from './Message.module.css'; const b = block(styles, 'Message'); @@ -8,12 +11,28 @@ 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} /> ))}