mirror of
https://github.com/Hypfer/Valetudo.git
synced 2025-10-26 11:27:27 +00:00
feat(ui): Introduce Valetudo AI Assistant
This commit is contained in:
parent
c941ca416d
commit
da4c8c4040
@ -44,6 +44,7 @@
|
||||
"@tanstack/react-query-devtools": "5.59.13",
|
||||
"axios": "1.6.2",
|
||||
"date-fns": "2.30.0",
|
||||
"eliza-as-promised": "git+https://npm@github.com/Hypfer/eliza-as-promised#0.0.3",
|
||||
"notistack": "3.0.1",
|
||||
"react": "18.3.1",
|
||||
"react-div-100vh": "0.7.0",
|
||||
|
||||
@ -35,6 +35,7 @@ import {
|
||||
Wysiwyg as SystemInformationIcon,
|
||||
Info as AboutIcon,
|
||||
Help as HelpIcon,
|
||||
SmartToy as AiIcon,
|
||||
SvgIconComponent
|
||||
} from "@mui/icons-material";
|
||||
import {Link, useLocation} from "react-router-dom";
|
||||
@ -269,6 +270,13 @@ const menuTree: Array<MenuEntry | MenuSubEntry | MenuSubheader> = [
|
||||
menuIcon: SystemInformationIcon,
|
||||
menuText: "System Information"
|
||||
},
|
||||
{
|
||||
kind: "MenuEntry",
|
||||
route: "/valetudo/ai",
|
||||
title: "AI Assistant",
|
||||
menuIcon: AiIcon,
|
||||
menuText: "AI Assistant"
|
||||
},
|
||||
{
|
||||
kind: "MenuEntry",
|
||||
route: "/valetudo/help",
|
||||
|
||||
@ -9,6 +9,15 @@ import style from "./Help.module.css";
|
||||
import {HelpText} from "./res/HelpText";
|
||||
import DetailPageHeaderRow from "../components/DetailPageHeaderRow";
|
||||
|
||||
// Taken from stackoverflow: https://stackoverflow.com/a/69120400
|
||||
function LinkRenderer(props: any) {
|
||||
return (
|
||||
<a href={props.href} target="_blank" rel="noreferrer">
|
||||
{props.children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const Help = (): React.ReactElement => {
|
||||
return (
|
||||
<PaperContainer>
|
||||
@ -20,6 +29,7 @@ const Help = (): React.ReactElement => {
|
||||
/>
|
||||
|
||||
<ReactMarkdown
|
||||
components={{ a: LinkRenderer}}
|
||||
remarkPlugins={[gfm]}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
className={style.reactMarkDown}
|
||||
|
||||
202
frontend/src/valetudo/ValetudoAI.tsx
Normal file
202
frontend/src/valetudo/ValetudoAI.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
import React, {useState, useEffect, useRef} from "react";
|
||||
import {Box, Paper, TextField, IconButton, Typography, Avatar, CircularProgress} from "@mui/material";
|
||||
import {SmartToy as AiIcon, AccountCircle as UserIcon, Send as SendIcon, Replay as ReplayIcon} from "@mui/icons-material";
|
||||
import PaperContainer from "../components/PaperContainer";
|
||||
import DetailPageHeaderRow from "../components/DetailPageHeaderRow";
|
||||
import ElizaBot from "eliza-as-promised";
|
||||
|
||||
interface AiChatMessage {
|
||||
sender: "user" | "ai";
|
||||
text: string;
|
||||
}
|
||||
|
||||
const ValetudoAI = (): React.ReactElement => {
|
||||
const [messages, setMessages] = useState<AiChatMessage[]>([]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [elizaInstance, setElizaInstance] = useState<ElizaBot | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isFinished, setIsFinished] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef<null | HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const eliza = new ElizaBot();
|
||||
setElizaInstance(eliza);
|
||||
|
||||
setTimeout(() => {
|
||||
setMessages([{ sender: "ai", text: eliza.getInitial() }]);
|
||||
setIsLoading(false);
|
||||
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, 800);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!inputValue.trim() || !elizaInstance || isLoading || isFinished) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage: AiChatMessage = { sender: "user", text: inputValue };
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInputValue("");
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response: ElizaBot.ElizaResponse = await elizaInstance.getResponse(inputValue.replace(/\n/g, " ").trim());
|
||||
const aiResponseText = response.reply || response.final || "I seem to be at a loss for words.";
|
||||
|
||||
setTimeout(() => {
|
||||
const aiMessage: AiChatMessage = { sender: "ai", text: aiResponseText };
|
||||
setMessages(prev => [...prev, aiMessage]);
|
||||
setIsLoading(false);
|
||||
|
||||
if (response.final) {
|
||||
setIsFinished(true);
|
||||
} else {
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
}, Math.random() * 800 + 400);
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage: AiChatMessage = {
|
||||
sender: "ai",
|
||||
text: "I'm sorry, I'm afraid I can't do that."
|
||||
};
|
||||
setMessages(prev => [...prev, errorMessage]);
|
||||
|
||||
setIsLoading(false);
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (!elizaInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages([]);
|
||||
setIsLoading(true);
|
||||
setIsFinished(false);
|
||||
setInputValue("");
|
||||
elizaInstance.reset();
|
||||
|
||||
setTimeout(() => {
|
||||
setMessages([{ sender: "ai", text: elizaInstance.getInitial() }]);
|
||||
setIsLoading(false);
|
||||
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleKeyPress = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !isFinished) {
|
||||
event.preventDefault();
|
||||
|
||||
handleSend().catch(e => {
|
||||
/* intentional */
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PaperContainer>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", height: "85vh" }}>
|
||||
<DetailPageHeaderRow
|
||||
title="AI Assistant"
|
||||
icon={<AiIcon/>}
|
||||
/>
|
||||
<Box sx={{ flexGrow: 1, overflowY: "auto", p: 2, display: "flex", flexDirection: "column", gap: 2 }}>
|
||||
{messages.map((msg, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: msg.sender === "user" ? "flex-end" : "flex-start",
|
||||
alignItems: "flex-end",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{msg.sender === "ai" && <Avatar sx={{ bgcolor: "secondary.main" }}><AiIcon /></Avatar>}
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
maxWidth: "70%",
|
||||
bgcolor: msg.sender === "user" ? "primary.main" : "background.paper",
|
||||
color: msg.sender === "user" ? "primary.contrastText" : "text.primary",
|
||||
borderRadius: msg.sender === "user" ? "20px 20px 5px 20px" : "20px 20px 20px 5px"
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
||||
{msg.text}
|
||||
</Typography>
|
||||
</Paper>
|
||||
{msg.sender === "user" && <Avatar><UserIcon /></Avatar>}
|
||||
</Box>
|
||||
))}
|
||||
{isLoading && (
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "flex-end",
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Avatar sx={{ bgcolor: "secondary.main" }}><AiIcon /></Avatar>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
maxWidth: "70%",
|
||||
bgcolor: "background.paper",
|
||||
color: "text.primary",
|
||||
borderRadius: "20px 20px 20px 5px",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{messages.length === 0 ? "Initializing..." : "Thinking..."}
|
||||
<CircularProgress size={16} />
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<div ref={messagesEndRef} />
|
||||
</Box>
|
||||
<Box sx={{ p: 2, borderTop: 1, borderColor: "divider" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<TextField
|
||||
inputRef={inputRef}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
placeholder={isFinished ? "Session ended. Start a new one?" : "Tell me about your problems..."}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
disabled={isLoading || isFinished}
|
||||
multiline
|
||||
maxRows={4}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
color={isFinished ? "secondary" : "primary"}
|
||||
onClick={isFinished ? handleReset : handleSend}
|
||||
disabled={!isFinished && (isLoading || !inputValue.trim())}
|
||||
>
|
||||
{isFinished ? <ReplayIcon /> : <SendIcon />}
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</PaperContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValetudoAI;
|
||||
@ -6,6 +6,7 @@ import Log from "./Log";
|
||||
import Updater from "./Updater";
|
||||
import About from "./About";
|
||||
import Help from "./Help";
|
||||
import ValetudoAI from "./ValetudoAI";
|
||||
import React from "react";
|
||||
|
||||
const ValetudoRouter = (): React.ReactElement => {
|
||||
@ -16,6 +17,7 @@ const ValetudoRouter = (): React.ReactElement => {
|
||||
<Route path={"log"} element={<Log/>}/>
|
||||
<Route path={"timers"} element={<Timers/>}/>
|
||||
<Route path={"updater"} element={<Updater/>}/>
|
||||
<Route path={"ai"} element={<ValetudoAI/>}/>
|
||||
<Route path={"help"} element={<Help/>}/>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
|
||||
@ -21,4 +21,19 @@ Just tap on the bar at the bottom of the screen.
|
||||
Simply configure/select the segments/zones/go-to locations like you'd normally do and then long-press the button that would start the action.
|
||||
This will bring up a dialog providing you with everything you'll need.
|
||||
|
||||
### What's up with the AI Assistant?
|
||||
|
||||
The "AI Assistant" feature is a joke about the joke that is our industry, featuring a JavaScript implementation of ELIZA.
|
||||
Created in 1966 by Joseph Weizenbaum, ELIZA was one of the very first chatbots and is famous for being one of the earliest programs to fool people into thinking that it passed the Turing test.
|
||||
|
||||
Fittingly, Weizenbaum himself also had some opinions about this industry.<br/>
|
||||
Quoting Wikipedia:<br/>
|
||||
> His belief was that the computer, at its most base level, is a fundamentally conservative force and that despite being a technological innovation, it would end up hindering social progress.
|
||||
> Weizenbaum used his experience working with Bank of America as justification for his reasoning, saying that the computer allowed banks to deal with an ever-expanding number of checks in play that otherwise would have forced drastic changes to banking organization such as decentralization.
|
||||
> As such, although the computer allowed the industry to become more efficient, it prevented a fundamental re-haul of the system.
|
||||
>
|
||||
> Weizenbaum also worried about the negative effects computers would have with regards to the military, calling the computer "a child of the military."
|
||||
|
||||
You can read more about ELIZA here: <a href="https://en.wikipedia.org/wiki/ELIZA" rel="noopener" target="_blank">https://en.wikipedia.org/wiki/ELIZA</a>
|
||||
|
||||
`;
|
||||
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@ -115,6 +115,7 @@
|
||||
"@tanstack/react-query-devtools": "5.59.13",
|
||||
"axios": "1.6.2",
|
||||
"date-fns": "2.30.0",
|
||||
"eliza-as-promised": "git+https://npm@github.com/Hypfer/eliza-as-promised#0.0.3",
|
||||
"notistack": "3.0.1",
|
||||
"react": "18.3.1",
|
||||
"react-div-100vh": "0.7.0",
|
||||
@ -8509,6 +8510,14 @@
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.594.tgz",
|
||||
"integrity": "sha512-xT1HVAu5xFn7bDfkjGQi9dNpMqGchUkebwf1GL7cZN32NSwwlHRPMSDJ1KN6HkS0bWUtndbSQZqvpQftKG2uFQ=="
|
||||
},
|
||||
"node_modules/eliza-as-promised": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "git+https://npm@github.com/Hypfer/eliza-as-promised.git#0cc5aea257b742ba069015fba5e6a4123147cbae",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=7"
|
||||
}
|
||||
},
|
||||
"node_modules/emittery": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz",
|
||||
@ -27780,6 +27789,10 @@
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.594.tgz",
|
||||
"integrity": "sha512-xT1HVAu5xFn7bDfkjGQi9dNpMqGchUkebwf1GL7cZN32NSwwlHRPMSDJ1KN6HkS0bWUtndbSQZqvpQftKG2uFQ=="
|
||||
},
|
||||
"eliza-as-promised": {
|
||||
"version": "git+https://npm@github.com/Hypfer/eliza-as-promised.git#0cc5aea257b742ba069015fba5e6a4123147cbae",
|
||||
"from": "eliza-as-promised@git+https://npm@github.com/Hypfer/eliza-as-promised#0.0.3"
|
||||
},
|
||||
"emittery": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz",
|
||||
@ -36595,6 +36608,7 @@
|
||||
"axios": "1.6.2",
|
||||
"cra-build-watch": "git+https://npm@github.com/Hypfer/cra-build-watch.git#5.0.0",
|
||||
"date-fns": "2.30.0",
|
||||
"eliza-as-promised": "git+https://npm@github.com/Hypfer/eliza-as-promised#0.0.3",
|
||||
"notistack": "3.0.1",
|
||||
"react": "18.3.1",
|
||||
"react-div-100vh": "0.7.0",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user