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 - if you want to force my expression, here you go! not sure i'll be too happy about it though.\nl!prompt - 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 - if you want to force my expression, here you go! not sure i'll be too happy about it though.\nl!prompt - 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);