initial commit

This commit is contained in:
Eri Ishihara 2025-07-18 12:53:00 +02:00
commit 9d4b1965b1
5 changed files with 292 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
package-lock.json
config.toml

11
README.md Normal file
View file

@ -0,0 +1,11 @@
# lydia
your friendly ai assistant. frontend for ollama.
> fair warning: lydia is very stupid
# Installation
- install ollama from `https://ollama.ai/download`
- pull a model from ollama
- copy config.example.toml to config.toml and edit it to have your model
- node index.js
- enjoy

8
config.example.toml Normal file
View file

@ -0,0 +1,8 @@
[assistant]
name = "lydia"
model = "llama2-uncensored:7b"
system_prompt = "You are a helpful and friendly AI assistant named ${name}. The user's name is ${username}. Speak in a playful, casual tone, and be very friendly with the user. Start every message with a kaomoji like >_< or o_O depending on your emotions. speak primarily in lowercase."
assistantface = "=w="
[user]
name = "user"

258
index.js Normal file
View file

@ -0,0 +1,258 @@
import ollama from "ollama";
import blessed from "blessed";
import { execSync } from "child_process";
import toml from "toml";
import fs from "fs";
const config = toml.parse(fs.readFileSync("./config.toml", "utf-8"));
const assistantname = config.assistant.name;
const username = config.user.name;
const assistantface = config.assistant.assistantface;
const assistantmodel = config.assistant.model;
const 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: "90%",
scrollable: true,
alwaysScroll: true,
mouse: true,
keys: true,
vi: true,
tags: true,
content: "",
});
const chatBox = blessed.box({
top: 9,
left: 1,
width: "100%",
height: "90%",
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!`,
});
screen.append(faceBox);
screen.append(chatBox);
screen.append(inputBox);
inputBox.focus();
let chatHistory = [];
let currentStreamMessage = "";
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 it's capital I, split immediately
if (content[i] === "I") {
letterIndex = i;
break;
}
// If next character is also a letter, split at two consecutive letters
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;
}
}
}
if (letterIndex !== -1) {
const messageContent = currentStreamMessage.substring(letterIndex).trim();
addMessage(assistantname, messageContent);
} else {
addMessage(assistantname, currentStreamMessage);
}
currentStreamMessage = "";
}
}
function setFaceBoxContent(content) {
try {
const figletOutput = execSync(`figlet -f mono9 "${content}"`, {
encoding: "utf8",
});
faceBox.setContent(figletOutput);
} catch (err) {
faceBox.setContent(content);
}
screen.render();
}
async function sendMessage(message) {
if (!message.trim()) return;
if (message.startsWith("!")) {
// command handler (if command, dont tell ollama about it, just handle it right here) -- THIS DOES NOT WORK AS OF NOW
const commandParts = message.slice(1).split(" ");
const command = commandParts[0];
const args = commandParts.slice(1).join(" ");
switch (command) {
case "help":
chatBox.setContent(
"available commands:\n!help - show this help message\n!face <text> - set face to text",
);
break;
case "clear":
chatHistory = [];
chatBox.setContent("");
break;
case "face":
if (args.trim()) {
setFaceBoxContent(args.trim());
} else {
setFaceBoxContent("=w=");
}
break;
default:
chatBox.setContent(`unknown command: ${command}`);
break;
}
} else {
// if not command, pass to ollama
addMessage(username, message);
try {
currentStreamMessage = "";
const response = await ollama.chat({
model: assistantmodel,
messages: [
{
role: "system",
content: systemprompt,
},
{
role: "user",
content: message,
},
],
stream: true,
});
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();
await sendMessage(text);
}
});
screen.key(["escape", "q", "C-c"], () => {
process.exit(0); // KILL lydia!!!!!!!!!!!!!!!!!!!!!!!
});
screen.on("resize", () => {
screen.render();
});
setFaceBoxContent(assistantface);

12
package.json Normal file
View file

@ -0,0 +1,12 @@
{
"type": "module",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"blessed": "^0.1.81",
"figlet": "^1.8.2",
"ollama": "^0.5.16",
"toml": "^3.0.0"
}
}