docuservix/widgets/chat: добавлен компонент чата

This commit is contained in:
2026-06-17 19:47:26 +03:00
parent cfce250d07
commit 14feeb06f0
13 changed files with 421 additions and 1 deletions
+19 -1
View File
@@ -1,10 +1,28 @@
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 { 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 {
return ( return (
<Layout title="Чат"> <Layout title="Чат">
<main className="container margin-vert--lg">Hello world!</main> <main className="container margin-vert--lg">
<Chat dialog={dialog} />
</main>
</Layout> </Layout>
); );
} }
@@ -0,0 +1,9 @@
.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;
}
+35
View File
@@ -0,0 +1,35 @@
import block from 'bem-css-modules';
import React, { ReactNode } from 'react';
import { IChat, IChatMessage } 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;
loading?: boolean;
onSend?: (text: string) => void;
}
export function Chat({ dialog, loading, onSend }: ChatProps): ReactNode {
const { messages } = dialog;
return (
<div className={b()}>
<Header />
<Messages
messages={messages}
loading={loading}
/>
<Input
loading={loading}
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,55 @@
.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 {
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[];
loading?: boolean;
}
export function Messages({ messages, loading }: MessagesProps): ReactNode {
return (
<div className={b()}>
{messages.map((msg, i) => (
<Message
key={i}
role={msg.role}
content={msg.content}
/>
))}
{loading && (
<div className={b('typing')}>
<div className={b('typingIndicator')}>
<span></span>
<span></span>
<span></span>
</div>
</div>
)}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
export function RobotIcon(props: React.SVGProps<SVGSVGElement>): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 640 512"
fill="currentColor"
width="1em"
height="1em"
{...props}
>
<path d="M320 0c17.7 0 32 14.3 32 32v64h120c48.6 0 88 39.4 88 88v272c0 48.6-39.4 88-88 88H168c-48.6 0-88-39.4-88-88V184c0-48.6 39.4-88 88-88h120V32c0-17.7 14.3-32 32-32zM208 384c-8.8 0-16 7.2-16 16s7.2 16 16 16h224c8.8 0 16-7.2 16-16s-7.2-16-16-16H208zm-48-96a32 32 0 1 0 0-64 32 32 0 1 0 0 64zm288-32a32 32 0 1 0 -64 0 32 32 0 1 0 64 0zM64 224c-17.7 0-32 14.3-32 32v64c0 17.7 14.3 32 32 32s32-14.3 32-32V256c0-17.7-14.3-32-32-32zm512 0c-17.7 0-32 14.3-32 32v64c0 17.7 14.3 32 32 32s32-14.3 32-32V256c0-17.7-14.3-32-32-32z" />
</svg>
);
}
export function PaperPlaneIcon(props: React.SVGProps<SVGSVGElement>): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
fill="currentColor"
width="1em"
height="1em"
{...props}
>
<path d="M498.1 5.6c10.1 7 15.4 19.1 13.5 31.2l-64 416c-1.5 9.7-7.4 18.2-16 23s-18.9 5.4-28 1.6L284 427.7l-68.5 74.1c-8.9 9.7-22.9 12.9-35.2 8.1S160 googl492.3 160 480v-83.6c0-4 1.5-7.8 4.2-10.7l167.6-182.8c5.8-6.3 5.4-16.1-.9-21.9s-16.1-5.4-21.9 .9L106.2 350.8 13.7 313.4C3.2 309 -2 297.3 .5 286.4s11.3-18.9 22.4-21L490.5 .6c10.1-2 20.6 1.7 27.6 8.7L498.1 5.6z" />
</svg>
);
}
+1
View File
@@ -0,0 +1 @@
export { Chat } from './Chat';