Skip to content

feat: add chat ai first version #2500

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Apr 1, 2025
2 changes: 1 addition & 1 deletion docs/smart-contracts/anatomy/reduce-size.md
Original file line number Diff line number Diff line change
@@ -171,7 +171,7 @@ For a `no_std` approach to minimal contracts, observe the following examples:
<details>
<summary>Expand to see what's available from <code>sys.rs</code></summary>

<Github language="rust" start="" end="" url="https://github.com/near/near-sdk-rs/blob/master/near-sdk/src/environment/sys.rs" />
<Github language="rust" url="https://github.com/near/near-sdk-rs/blob/master/near-sdk/src/environment/sys.rs" />

</details>

3 changes: 3 additions & 0 deletions website/package.json
Original file line number Diff line number Diff line change
@@ -45,6 +45,7 @@
"gleap": "^13.7.3",
"https-browserify": "^1.0.0",
"lodash": "^4.17.21",
"lucide-react": "^0.482.0",
"monaco-editor": "^0.44.0",
"near-api-js": "^2.1.4",
"near-social-vm": "github:NearSocial/VM#2.5.5",
@@ -54,7 +55,9 @@
"react-bootstrap-typeahead": "^6.3.2",
"react-dom": "^18.2.0",
"react-is": "^18.2.0",
"react-markdown": "^10.1.0",
"react-monaco-editor": "^0.54.0",
"react-syntax-highlighter": "^15.6.1",
"sass": "^1.69.5",
"url": "^0.11.3"
}
192 changes: 192 additions & 0 deletions website/src/components/AIChat/Chat.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import '@generated/client-modules';
import React, { useState, useRef, useEffect } from 'react';
import { Button, Card, Form, InputGroup } from 'react-bootstrap';
import axios from 'axios';
import { useColorMode } from '@docusaurus/theme-common';
import MarkdownRenderer from './MarkdownRenderer';
import { Send, X } from 'lucide-react';
import posthog from 'posthog-js';
import Feedback from './feedback';

function splitTextIntoParts(text) {
if (!text) return [];
const regex = /(```[\s\S]*?```)/g;
return text.split(regex).filter((part) => part !== '');
}

export const Chat = ({ toggleChat }) => {
const { colorMode } = useColorMode();
const [messages, setMessages] = useState([]);
const [inputMessage, setInputMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [threadId, setThreadId] = useState(null);
const [seconds, setSeconds] = useState(1);
const messagesEndRef = useRef(null);
const chatRef = useRef(null);
const inputRef = useRef(null);

const isDarkTheme = colorMode === 'dark';

useEffect(() => {
document.documentElement.setAttribute('data-theme', colorMode);
}, [colorMode]);

useEffect(() => {
let interval;
if (isLoading) {
interval = setInterval(() => {
setSeconds((seconds) => seconds + 1);
}, 1000);
} else {
setSeconds(1);
}

return () => clearInterval(interval);
}, [isLoading]);

useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);

useEffect(() => {
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
toggleChat();
}
};

document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [toggleChat]);

useEffect(() => {
const handleClickOutside = (event) => {
if (chatRef.current && !chatRef.current.contains(event.target)) {
toggleChat();
}
};

document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [toggleChat]);

const getAIResponse = async (userMessage) => {
const response = await axios.post(
'https://tmp-docs-ai-service.onrender.com/api/chat',
{
messages: userMessage,
threadId: threadId,
},
{
headers: {
'Content-Type': 'application/json',
},
},
);
return response.data;
};

const handleSendMessage = async (e) => {
e.preventDefault();

if (!inputMessage.trim()) return;
const userMessage = { id: Date.now(), text: inputMessage, sender: 'user' };
setMessages([...messages, userMessage]);
setInputMessage('');

setIsLoading(true);

try {
const aiResponseText = await getAIResponse(inputMessage);
setThreadId(aiResponseText.threadId);

const aiMessage = { id: Date.now() + 1, text: aiResponseText.message, sender: 'ai' };
setMessages((prevMessages) => [...prevMessages, aiMessage]);
} catch (error) {
const aiMessage = {
id: Date.now() + 1,
text: 'I was not able to process your request, please try again',
sender: 'ai',
};
setMessages((prevMessages) => [...prevMessages, aiMessage]);
}
setIsLoading(false);
};

const handleFeedback = (choice) => {
posthog.capture('ai_chat_feedback', {
helpful: choice,
user_question: messages[messages.length - 2].text,
ai_answer: messages[messages.length - 1].text,
});
};

useEffect(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [messages]);

return (
<div className="floating-chat-container">
<Card className="chat-card" ref={chatRef}>
<Card.Header className="chat-header">
<div className="chat-title">
<i className="bi bi-robot me-2"></i>
Docs AI (Beta)
</div>
<X className="close-button" onClick={toggleChat} />
</Card.Header>

<Card.Body className="chat-body">
<div className="messages-container">
{messages.length === 0 ? (
<div className="welcome-message">How can I help you today?</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={`message ${msg.sender === 'user' ? 'user-message' : 'ai-message'}`}
>
{splitTextIntoParts(msg.text).map((part, index) => {
return <MarkdownRenderer part={part} isDarkTheme={isDarkTheme} key={index} />;
})}
{msg.sender === 'ai' && (
<Feedback id={msg.id} handler={handleFeedback} />
)}
</div>
))
)}
{isLoading && (
<div className="message ai-message loading">Thinking... ({seconds}s)</div>
)}
<div ref={messagesEndRef} />
</div>
</Card.Body>

<Card.Footer className="chat-footer">
<Form onSubmit={handleSendMessage}>
<InputGroup>
<Form.Control
className="input-message"
placeholder="Type a message..."
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
ref={inputRef}
/>
<Button variant="primary" type="submit" disabled={!inputMessage.trim() || isLoading}>
<Send size={16} />
</Button>
</InputGroup>
</Form>
</Card.Footer>
</Card>
</div>
);
};
63 changes: 63 additions & 0 deletions website/src/components/AIChat/MarkdownRenderer.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import React, { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark, oneLight } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { ClipboardCopy, Check } from 'lucide-react';

const CodeBlock = ({ node, inline, className, children, isDarkTheme, ...props }) => {
const match = /language-(\w+)/.exec(className || '');
const codeContent = String(children).replace(/\n$/, '');
const [isCopied, setIsCopied] = useState(false);

const copyToClipboard = () => {
navigator.clipboard.writeText(codeContent);
setIsCopied(true);
setTimeout(() => {
setIsCopied(false);
}, 2000);
};

return !inline && match ? (
<div className="code-block-container">
<button
onClick={copyToClipboard}
className={`code-copy-button ${isCopied ? 'copied' : ''}`}
>
{isCopied ? <Check size={16} /> : <ClipboardCopy size={16} />}
</button>
<SyntaxHighlighter
style={isDarkTheme ? oneDark : oneLight}
language={match[1]}
showLineNumbers={true}
// PreTag={ ({ children }) => <div>{children}</div> }
{...props}
>
{codeContent}
</SyntaxHighlighter>
</div>
) : (
<code className={className} {...props}>
{children}
</code>
);
};

const MarkdownRenderer = ({ part, isDarkTheme }) => {
return (
<ReactMarkdown
components={{
code: (props) => <CodeBlock {...props} isDarkTheme={isDarkTheme} />,
a: ({ node, ...props }) => (
<a {...props} target="_blank" rel="noopener noreferrer">
{props.children}
</a>
)
}}

>
{part}
</ReactMarkdown>
);
};

export default MarkdownRenderer;
33 changes: 33 additions & 0 deletions website/src/components/AIChat/feedback.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// A simple feedback handler with a thumbs up and down
// when one is selected, we mark it somehow so the user knows

import { useState } from "react";

const Feedback = ({ handler }) => {
const [selected, setSelected] = useState(null);

return (
<div className="feedback-container">
<button
className={`feedback-button ${selected === 'up' ? 'thumbs-up' : ''}`}
onClick={() => {
setSelected('up');
handler('yes');
}}
>
👍
</button>
<button
className={`feedback-button ${selected === 'down' ? 'thumbs-down' : ''}`}
onClick={() => {
setSelected('down');
handler('no');
}}
>
👎
</button>
</div>
)
};

export default Feedback;
22 changes: 22 additions & 0 deletions website/src/components/AIChat/index.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import '@generated/client-modules';
import React, { useState, useRef, useEffect } from 'react';
import { Button, } from 'react-bootstrap';
import { Chat } from './Chat';
import { BotMessageSquare } from 'lucide-react';
const AIChat = () => {
const [isOpen, setIsOpen] = useState(false);
const toggleChat = () => { setIsOpen(!isOpen); };

return isOpen ?
<Chat toggleChat={toggleChat} /> : (
<Button
className="chat-toggle-button animated-border-box"
variant="primary"
onClick={toggleChat}
>
<BotMessageSquare size={36} />
</Button>
)
};

export default AIChat;
Loading