feat(ui): Introduce Valetudo AI Assistant

This commit is contained in:
Sören Beye 2025-08-18 17:41:16 +02:00
parent c941ca416d
commit da4c8c4040
7 changed files with 252 additions and 0 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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}

View 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;

View File

@ -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="/" />} />

View File

@ -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
View File

@ -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",