From 9d4b1965b18dd6b6139dfc3aa587b3e1af71db6f Mon Sep 17 00:00:00 2001 From: Eri Date: Fri, 18 Jul 2025 12:53:00 +0200 Subject: [PATCH] initial commit --- .gitignore | 3 + README.md | 11 ++ config.example.toml | 8 ++ index.js | 258 ++++++++++++++++++++++++++++++++++++++++++++ package.json | 12 +++ 5 files changed, 292 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config.example.toml create mode 100644 index.js create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fc334a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +package-lock.json +config.toml diff --git a/README.md b/README.md new file mode 100644 index 0000000..1136b1f --- /dev/null +++ b/README.md @@ -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 diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..da831b0 --- /dev/null +++ b/config.example.toml @@ -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" diff --git a/index.js b/index.js new file mode 100644 index 0000000..7c29b45 --- /dev/null +++ b/index.js @@ -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 - 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); diff --git a/package.json b/package.json new file mode 100644 index 0000000..80a88c0 --- /dev/null +++ b/package.json @@ -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" + } +}