lydia/lydia.js
2025-08-02 21:44:32 +02:00

441 lines
9.9 KiB
JavaScript

import ollama from "ollama";
import blessed from "blessed";
import { execSync } from "child_process";
import toml from "toml";
import fs from "fs";
if (!fs.existsSync("./config.toml")) {
if (fs.existsSync("./config.example.toml")) {
fs.copyFileSync("./config.example.toml", "./config.toml");
} else {
console.error(
"error: config.example.toml not found. git pull from repository",
);
process.exit(1);
}
}
const config = toml.parse(fs.readFileSync("./config.toml", "utf-8"));
let assistantname = config.assistant.name;
let assistantface = config.assistant.assistantface;
let assistantmodel = config.assistant.model;
let maxtokens = config.advanced.max_tokens;
let temperature = config.advanced.temperature;
let username = config.user.name;
let facefont = config.appearance.facefont;
let systemprompt = config.assistant.system_prompt
.replace("${name}", assistantname)
.replace("${username}", username);
const screen = blessed.screen({
smartCSR: true,
title: assistantname,
});
const faceBox = blessed.box({
top: 0,
left: 1,
width: "100%",
height: 9,
scrollable: false,
mouse: true,
keys: true,
vi: true,
tags: true,
content: "",
});
const chatBox = blessed.box({
top: 9,
left: 1,
width: "100%",
bottom: 3,
scrollable: true,
alwaysScroll: true,
mouse: true,
keys: true,
vi: true,
tags: true,
content: `go on, tell ${assistantname} something! esc to tab out of chatbox.`,
});
const inputBox = blessed.textbox({
bottom: 0,
left: 0,
width: "100%",
height: 3,
border: {
type: "line",
},
style: {
border: {
fg: "cyan",
},
},
inputOnFocus: true,
keys: true,
mouse: true,
placeholder: `go on, tell ${assistantname} something!`,
});
const menuBox = blessed.list({
top: "center",
left: "center",
width: 60,
height: 16,
border: {
type: "line",
},
style: {
border: {
fg: "cyan",
},
bg: "black",
selected: {
bg: "cyan",
fg: "black",
},
item: {
fg: "white",
},
},
keys: true,
vi: true,
mouse: true,
hidden: true,
label: ` ${assistantname} menu `,
items: ["help & commands", "clear chat history", `exit ${assistantname}`],
});
const popup = blessed.box({
parent: screen,
top: "center",
left: "center",
width: 40,
height: 8,
border: {
type: "line",
},
style: {
border: {
fg: "cyan",
},
bg: "black",
},
tags: true,
hidden: true,
content: "",
});
const popupButton = blessed.button({
parent: popup,
bottom: 1,
left: "center",
width: 8,
height: 3,
content: "OK",
style: {
bg: "cyan",
fg: "black",
focus: {
bg: "white",
fg: "black",
},
},
mouse: true,
keys: true,
});
screen.append(faceBox);
screen.append(chatBox);
screen.append(inputBox);
screen.append(menuBox);
screen.append(popup);
inputBox.focus();
let chatHistory = [];
let conversationHistory = [];
let currentStreamMessage = "";
let menuVisible = false;
function addMessage(role, content) {
let message;
if (role === username) {
const padding = Math.max(0, chatBox.width - content.length - 5);
message = " ".repeat(padding) + content + " <\n";
} else {
message = `> ${assistantname}: ${content}\n`;
}
chatHistory.push(message);
chatBox.setContent(chatHistory.join(""));
chatBox.setScrollPerc(100);
screen.render();
}
function updateStreamMessage(content) {
currentStreamMessage = content;
// handle the kaomoji
let letterIndex = -1;
for (let i = 0; i < content.length - 1; i++) {
if (/[a-zA-Z]/.test(content[i])) {
if (content[i] === "I") {
letterIndex = i;
break;
}
if (/[a-zA-Z]/.test(content[i + 1])) {
letterIndex = i;
break;
}
}
}
if (letterIndex !== -1) {
const faceContent = content.substring(0, letterIndex).trim();
const messageContent = content.substring(letterIndex).trim();
// only update face if kaomoji is 3+ characters because sometimes the model wont listen
if (faceContent.length >= 3) {
setFaceBoxContent(faceContent);
}
const tempHistory = [...chatHistory];
tempHistory.push(`> ${assistantname}: ${messageContent}\n`);
chatBox.setContent(tempHistory.join(""));
chatBox.setScrollPerc(100);
screen.render();
} else {
// if no letters found yet, just show the entire thing
const tempHistory = [...chatHistory];
tempHistory.push(`> ${assistantname}: ${content}\n`);
chatBox.setContent(tempHistory.join(""));
chatBox.setScrollPerc(100);
screen.render();
}
}
function finalizeStreamMessage() {
if (currentStreamMessage) {
let letterIndex = -1;
for (let i = 0; i < currentStreamMessage.length - 1; i++) {
if (/[a-zA-Z]/.test(currentStreamMessage[i])) {
// complicated kaomoji splitting garboleum
if (currentStreamMessage[i] === "I") {
letterIndex = i;
break;
}
if (/[a-zA-Z]/.test(currentStreamMessage[i + 1])) {
letterIndex = i;
break;
}
}
}
let assistantMessage;
if (letterIndex !== -1) {
assistantMessage = currentStreamMessage.substring(letterIndex).trim();
addMessage(assistantname, assistantMessage);
} else {
assistantMessage = currentStreamMessage;
addMessage(assistantname, currentStreamMessage);
}
conversationHistory.push({
role: "assistant",
content: assistantMessage,
});
currentStreamMessage = "";
}
}
function setFaceBoxContent(content) {
try {
const figletOutput = execSync(`figlet -f ${facefont} "${content}"`, {
encoding: "utf8",
});
faceBox.setContent(figletOutput);
} catch (err) {
faceBox.setContent(content);
}
screen.render();
}
function clearChatHistory() {
chatHistory = [];
conversationHistory = [];
chatBox.setContent("");
screen.render();
}
function showMenu() {
menuVisible = true;
menuBox.show();
menuBox.focus();
screen.render();
}
function hideMenu() {
menuVisible = false;
menuBox.hide();
inputBox.focus();
screen.render();
}
function handleMenuSelection() {
const selectedIndex = menuBox.selected;
hideMenu();
switch (selectedIndex) {
case 0:
addMessage(
assistantname,
"available commands:\nl!help - if you wanna know what i can do, run this!\nl!clear - clear chat history, if you want me to forget everything, just run this!\nl!face <text> - if you want to force my expression, here you go! not sure i'll be too happy about it though.\nl!prompt <text> - if you want to change how i act, here you go! not sure i'll be too happy about that either.\n\nMenu shortcuts:\nESC - open/close menu\nUp/Down arrows - navigate menu\nEnter - select option\nCtrl+C or q - quit application\n",
);
break;
case 1:
clearChatHistory();
addMessage("system", "SYSTEM: Chat history cleared.");
break;
case 2:
process.exit(0);
}
}
async function sendMessage(message) {
if (!message.trim()) return;
addMessage(username, message);
conversationHistory.push({
role: "user",
content: message,
});
try {
currentStreamMessage = "";
const response = await ollama.chat({
model: assistantmodel,
messages: [
{
role: "system",
content: systemprompt,
},
...conversationHistory,
],
stream: true,
options: {
num_predict: maxtokens,
temperature: temperature,
},
});
for await (const part of response) {
if (part.message && part.message.content) {
currentStreamMessage += part.message.content;
updateStreamMessage(currentStreamMessage);
}
}
finalizeStreamMessage();
} catch (error) {
addMessage(assistantname, `Failed to get response: ${error.message}`);
}
}
inputBox.on("submit", async (text) => {
if (text.trim()) {
inputBox.clearValue();
inputBox.focus();
// command trash
if (text.trim().startsWith("l!")) {
const commandParts = text.trim().slice(2).split(" ");
const command = commandParts[0];
const args = commandParts.slice(1).join(" ");
switch (command) {
case "help":
addMessage(
assistantname,
"available commands:\nl!help - if you wanna know what i can do, run this!\nl!clear - clear chat history, if you want me to forget everything, just run this!\nl!face <text> - if you want to force my expression, here you go! not sure i'll be too happy about it though.\nl!prompt <text> - if you want to change how i act, here you go! not sure i'll be too happy about that either.\n",
);
break;
case "clear":
clearChatHistory();
break;
case "face":
if (args.trim()) {
setFaceBoxContent(args.trim());
} else {
setFaceBoxContent("=w=");
}
break;
case "prompt":
if (args.trim()) {
systemprompt = args.trim();
clearChatHistory();
addMessage("system", "SYSTEM: Prompt set.");
} else {
addMessage("system", "SYSTEM: You didn't provide a prompt.");
}
break;
default:
addMessage(assistantname, `unknown command: ${command}`);
break;
}
} else {
// message just send it
await sendMessage(text);
}
}
});
screen.key(["q", "C-c"], () => {
process.exit(0); // KILL lydia!!!!!!!!!!!!!!!!!!!!!!!
});
screen.key(["escape"], () => {
if (menuVisible) {
hideMenu();
} else {
showMenu();
}
});
menuBox.on("select", (item, selected) => {
if (menuVisible) {
handleMenuSelection();
}
});
popupButton.on("press", () => {
popup.hide();
inputBox.focus();
screen.render();
});
screen.key(["enter"], () => {
if (!popup.hidden) {
popup.hide();
inputBox.focus();
screen.render();
} else if (menuVisible) {
handleMenuSelection();
}
});
screen.on("resize", () => {
screen.render();
});
setFaceBoxContent(assistantface);