// Name: AI Magisterium Chat // Description: Chat with AI Magisterium and save conversations // Author: AI Script Generator import "@johnlindquist/kit"; import { PromptConfig } from "@johnlindquist/kit"; import Magisterium from "magisterium"; // Assuming this is the correct package name import { ChatCompletionMessageParam } from "openai/resources/chat"; // Type for message objects import markdown from "@wcj/markdown-to-html"; // --- Constants and Type Definitions --- const CONVERSATIONS_DIR = kenvPath("ai-magisterium-chats"); const CHAT_FILE_EXTENSION = "json"; interface ConversationMessage { role: "user" | "assistant"; content: string; } interface ConversationData { title: string; messages: ConversationMessage[]; } // --- Utility Functions --- /** * Generates a filename for a new conversation based on the current date and time. * @returns {string} The filename. */ const generateConversationFilename = (): string => { const date = new Date(); const dateString = formatDate(date, "yyyy-MM-dd"); const timeString = formatDate(date, "HH-mm-ss"); return `${dateString}-${timeString}.${CHAT_FILE_EXTENSION}`; }; /** * Generates a title for a conversation from the first few messages. * @param {ConversationMessage[]} messages - The conversation messages. * @returns {string} The conversation title. */ const generateConversationTitle = (messages: ConversationMessage[]): string => { return ( messages .slice(0, 3) .map((msg) => msg.content) .join(" ") .substring(0, 50) + "..." ); }; /** * Saves the current conversation to a JSON file. */ const saveConversation = async ( currentConversation: ConversationMessage[], chatTitle: string ) => { if (currentConversation.length === 0) return; // No messages to save const fileName = generateConversationFilename(); const filePath = path.join(CONVERSATIONS_DIR, fileName); const conversationData: ConversationData = { title: chatTitle || `Conversation at ${formatDate(new Date(), "HH-mm-ss")}`, messages: currentConversation, }; await writeFile(filePath, JSON.stringify(conversationData, null, 2)); return { name: conversationData.title, value: filePath }; // Return for updating history }; let t: PromptConfig; /** * Loads a conversation from a JSON file. * @param {string} filePath - The path to the conversation file. * @returns {Promise<ConversationData>} The loaded conversation data. */ const loadConversation = async ( filePath: string ): Promise<ConversationData> => { const fileContent = await readFile(filePath, "utf8"); return JSON.parse(fileContent); }; // // --- Markdown and Footnote Handling --- // const formatMagisteriumMarkdown = (markdown: string): string => { // // Convert footnotes to details/summary // console.log(markdown); // const footnoteRegex = /\[\^(\d+)\]:\s(.*?)(?=(\[\^\d+\]:|(?:\n\n)|$))/gs; // const replaced = markdown.replaceAll( // footnoteRegex, // (match, footnoteIndex, footnoteContent) => { // return `<details><summary>Citation [^${footnoteIndex}]</summary>${markdown( // footnoteContent.trim() // )}</details>`; // } // ); // console.log(replaced); // return replaced; // }; function parseFootnotes(input: string): string { // Split the input into main content and footnotes using the <hr> tag as separator. const [mainContent, ...footnoteParts] = input.split("<hr>"); const footnotesSection = footnoteParts.join("<hr>"); // rejoin in case of multiple <hr> // Replace inline footnote markers (e.g., [^1]) with anchor links. const processedMain = mainContent.replaceAll( /\[\^(\d+)\]/g, (match, footnoteNum) => { return `<a id="footnote-${footnoteNum}-ref" href="#footnote-${footnoteNum}"><sup style="color: #1c95bd;">${footnoteNum}</sup></a>`; } ); // Update footnote paragraphs by removing the markdown syntax and prefixing with the number and a period. const processedFootnotes = footnotesSection.replaceAll( /<p>\[\^(\d+)\]\s*(.*?)<\/p>/g, (match, footnoteNum, text) => { return `<p id="footnote-${footnoteNum}"><span style="color: #1c95bd;">${footnoteNum}.</span> ${text}</p>`; } ); // Return the concatenated processed content with the <hr> separator. return processedMain + "<hr>" + processedFootnotes; } // --- Main Script Logic --- await ensureDir(CONVERSATIONS_DIR); // Initialize Magisterium API client with API key from environment variables const magisteriumApiKey = await env("MAGISTERIUM_API_KEY"); const magisterium = new Magisterium({ apiKey: magisteriumApiKey }); let conversationHistory: { name: string; value: string }[] = []; let currentConversation: ConversationMessage[] = []; let chatTitle: string = "New Conversation"; onTab("Chat", async (input: string = "") => { await chat( { css: ` .loading { font-style: italic; color: #888; }`, placeholder: "Type your message to AI Magisterium", onInit: async () => { // Load conversation history on initialization const files = await readdir(CONVERSATIONS_DIR); conversationHistory = await Promise.all( files.map(async (file) => { const filePath = path.join(CONVERSATIONS_DIR, file); const convoData = await loadConversation(filePath); // Load to get title return { name: convoData.title || file, // Use title from file or filename if title is missing value: filePath, }; }) ); conversationHistory.sort((a, b) => b.name.localeCompare(a.name)); // Sort alphabetically, newest first }, onInput: async (input) => { // Display previous conversations in the panel setPanel( md(` ## Previous Conversations ${conversationHistory .map((convo) => `- [${convo.name}](load-convo:${convo.value})`) .join("\n")} `).toString() ); }, onSelected: async (choice) => { // Handle loading a previous conversation if (choice.startsWith("load-convo:")) { const filePath = choice.substring("load-convo:".length); const loadedData = await loadConversation(filePath); currentConversation = loadedData.messages; chatTitle = loadedData.title; setPanel(""); // Clear panel after loading // Re-render chat UI with loaded conversation (needs implementation in `chat` helper if needed for display) } }, onSubmit: async (input) => { if (!input) return; // Add user message to current conversation and chat UI const userMessage: ConversationMessage = { role: "user", content: input, }; console.log({ currentConversation }); currentConversation.push(userMessage); chat.addMessage({ ...userMessage, text: userMessage.content, position: "right", status: "sent", }); // Add a loading message with spinner let current = currentConversation.length; chat.setMessage(current, { text: "Thinking...", // Simple markdown for loading dots position: "left", className: "loading", }); try { // Send message to Magisterium API const apiResponse = await magisterium.chat.completions.create({ model: "magisterium-1", // Or another model if needed messages: currentConversation as ChatCompletionMessageParam[], // Cast to OpenAI message type }); // Extract assistant's reply and citations from API response const assistantReply = apiResponse.choices?.[0]?.message?.content || "No response from AI."; // Format assistant reply with markdown and handle footnotes const formattedReply = parseFootnotes( markdown(assistantReply).toString() ); // Update the loading message with the formatted reply chat.setMessage(current, { text: formattedReply, position: "left", status: "received", copiableDate: true, }); // Add assistant message to current conversation const assistantMessage: ConversationMessage = { role: "assistant", content: assistantReply, }; currentConversation.push(assistantMessage); // Save the updated conversation const newConvoHistoryItem = await saveConversation( currentConversation, chatTitle ); if (newConvoHistoryItem) { conversationHistory.unshift(newConvoHistoryItem); // Add new convo to history conversationHistory.sort((a, b) => b.name.localeCompare(a.name)); // Keep sorted } } catch (error: any) { // Handle API errors gracefully console.error("AI Magisterium Error:", error); const errorMessage = error.response?.data?.error?.message || "Error communicating with AI Magisterium."; // Update the loading message with the error message chat.setMessage(current, { text: md( `Error communicating with AI Magisterium: ${errorMessage}` ), position: "left", }); } }, }, [ { name: "Copy", shortcut: "CmdOrCtrl+C", onAction: (input) => { copy(input.replaceAll(/\n\n\n/gi, "\n")); }, }, ] ); });