Compare commits
	
		
			3 Commits
		
	
	
		
			ee367a0d9a
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| d40b51460e | |||
| 2a53b0a827 | |||
| 051a66ff26 | 
| @ -11,3 +11,4 @@ OLLAMA_MODEL="" # Ollama model for responses e.g dolphin-mistral:latest | ||||
| FETCH_INTERVAL="" # interval for fetching new notifications from the instance, in milliseconds, recommend at least 15000 | ||||
| RANDOM_POST_INTERVAL="" # interval for ad-hoc posts in milliseconds | ||||
| INSTANCE_BEARER_TOKEN="" # instance auth/bearer token (check the "verify_credentials" endpoint request headers in Chrome DevTools if on Soapbox) | ||||
| AD_HOC_POST_INSTRUCTIONS="" | ||||
							
								
								
									
										226
									
								
								src/main.ts
									
									
									
									
									
								
							
							
						
						
									
										226
									
								
								src/main.ts
									
									
									
									
									
								
							| @ -4,12 +4,10 @@ import { | ||||
|   OllamaConfigOptions, | ||||
|   OllamaChatRequest, | ||||
|   OllamaChatResponse, | ||||
|   PostAncestorsForModel, | ||||
|   // PostAncestorsForModel, | ||||
| } from "../types.js"; | ||||
| // import striptags from "striptags"; | ||||
| import { PrismaClient } from "../generated/prisma/client.js"; | ||||
| import { | ||||
|   // getInstanceEmojis, | ||||
|   deleteNotification, | ||||
|   getNotifications, | ||||
|   getStatusContext, | ||||
| @ -19,8 +17,6 @@ import { | ||||
|   isFromWhitelistedDomain, | ||||
|   alreadyRespondedTo, | ||||
|   recordPendingResponse, | ||||
|   // trimInputData, | ||||
|   // selectRandomEmoji, | ||||
|   shouldContinue, | ||||
| } from "./util.js"; | ||||
|  | ||||
| @ -34,7 +30,7 @@ export const envConfig = { | ||||
|     ? process.env.WHITELISTED_DOMAINS.split(",") | ||||
|     : [process.env.PLEROMA_INSTANCE_DOMAIN], | ||||
|   ollamaUrl: process.env.OLLAMA_URL || "", | ||||
|   ollamaSystemPrompt: process.env.OLLAMA_SYSTEM_PROMPT, | ||||
|   ollamaSystemPrompt: process.env.OLLAMA_SYSTEM_PROMPT || "", | ||||
|   ollamaModel: process.env.OLLAMA_MODEL || "", | ||||
|   fetchInterval: process.env.FETCH_INTERVAL | ||||
|     ? parseInt(process.env.FETCH_INTERVAL) | ||||
| @ -45,14 +41,18 @@ export const envConfig = { | ||||
|     : 3600000, | ||||
|   botAccountId: process.env.PLEROMA_ACCOUNT_ID, | ||||
|   replyWithContext: process.env.REPLY_WITH_CONTEXT === "true" ? true : false, | ||||
|   adHocPostInstructions: process.env.AD_HOC_POST_INSTRUCTIONS | ||||
|     ? process.env.AD_HOC_POST_INSTRUCTIONS | ||||
|     : "Say something.", | ||||
| }; | ||||
|  | ||||
| const ollamaConfig: OllamaConfigOptions = { | ||||
|   temperature: 0.9, | ||||
|   top_p: 0.85, | ||||
|   top_k: 60, | ||||
|   num_ctx: 16384, // maximum context window for Llama 3.1 | ||||
|   repeat_penalty: 1.1, | ||||
|   temperature: 0.85, // Increased from 0.6 - more creative and varied | ||||
|   top_p: 0.9, // Slightly increased for more diverse responses | ||||
|   top_k: 40, | ||||
|   num_ctx: 16384, | ||||
|   repeat_penalty: 1.1, // Reduced from 1.15 - less mechanical | ||||
|   // stop: ['<|im_end|>', '\n\n'] | ||||
| }; | ||||
|  | ||||
| // this could be helpful | ||||
| @ -68,75 +68,128 @@ const generateOllamaRequest = async ( | ||||
|     ollamaUrl, | ||||
|     replyWithContext, | ||||
|   } = envConfig; | ||||
|  | ||||
|   let shouldDeleteNotification = false; | ||||
|  | ||||
|   try { | ||||
|     if (shouldContinue(notification)) { | ||||
|     if (!shouldContinue(notification)) { | ||||
|       shouldDeleteNotification = true; | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (whitelistOnly && !isFromWhitelistedDomain(notification)) { | ||||
|         await deleteNotification(notification); | ||||
|       shouldDeleteNotification = true; | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (await alreadyRespondedTo(notification)) { | ||||
|       shouldDeleteNotification = true; | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     await recordPendingResponse(notification); | ||||
|     await storeUserData(notification); | ||||
|       let conversationHistory: PostAncestorsForModel[] = []; | ||||
|  | ||||
|     let conversationContext = ""; | ||||
|     if (replyWithContext) { | ||||
|       const contextPosts = await getStatusContext(notification.status.id); | ||||
|         if (!contextPosts?.ancestors || !contextPosts) { | ||||
|       if (!contextPosts?.ancestors) { | ||||
|         throw new Error(`Unable to obtain post context ancestors.`); | ||||
|       } | ||||
|         conversationHistory = contextPosts.ancestors.map((ancestor) => { | ||||
|           const mentions = ancestor.mentions.map((mention) => mention.acct); | ||||
|           return { | ||||
|             account_fqn: ancestor.account.fqn, | ||||
|             mentions, | ||||
|             plaintext_content: ancestor.pleroma.content["text/plain"], | ||||
|           }; | ||||
|         }); | ||||
|         // console.log(conversationHistory); | ||||
|  | ||||
|       // Build a human-readable conversation thread | ||||
|       const allPosts = [...contextPosts.ancestors]; | ||||
|  | ||||
|       // Include descendants (follow-up posts) if available | ||||
|       if (contextPosts.descendents && contextPosts.descendents.length > 0) { | ||||
|         allPosts.push(...contextPosts.descendents); | ||||
|       } | ||||
|  | ||||
|       // Simplified user message (remove [/INST] as it's not needed for Llama 3) | ||||
|       const userMessage = `${notification.status.account.fqn} says to you: \"${notification.status.pleroma.content["text/plain"]}\".`; | ||||
|       if (allPosts.length > 0) { | ||||
|         const conversationLines = allPosts.map((post) => { | ||||
|           const author = post.account.fqn; | ||||
|           const content = post.pleroma.content["text/plain"]; | ||||
|           const replyingTo = post.in_reply_to_account_id | ||||
|             ? ` (replying to another message)` | ||||
|             : ""; | ||||
|           return `[@${author}${replyingTo}]: ${content}`; | ||||
|         }); | ||||
|  | ||||
|         conversationContext = ` | ||||
| Previous conversation thread: | ||||
| ${conversationLines.join("\n\n")} | ||||
| --- | ||||
| `; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const userMessage = notification.status.pleroma.content["text/plain"]; | ||||
|     const originalAuthor = notification.account.fqn; | ||||
|  | ||||
|     let systemContent = ollamaSystemPrompt; | ||||
|       if (replyWithContext) { | ||||
|         // Simplified context instructions (avoid heavy JSON; summarize for clarity) | ||||
|         systemContent = `${ollamaSystemPrompt}\n\nPrevious conversation context:\n${conversationHistory | ||||
|           .map( | ||||
|             (post) => | ||||
|               `${post.account_fqn} (said to ${post.mentions.join(", ")}): ${ | ||||
|                 post.plaintext_content | ||||
|               }` | ||||
|           ) | ||||
|           .join( | ||||
|             "\n" | ||||
|           )}\nReply to the user who addressed you (you are Lexi, also known as nice-ai or nice-ai@nicecrew.digital). Examine the context of the entire conversation and make references to topics or information where appropriate. Prefix usernames with '@' when addressing them. Assume if there is no domain in the username, the domain is @nicecrew.digital (for example @matty would be @matty@nicecrew.digital)`; | ||||
|     if (replyWithContext && conversationContext) { | ||||
|       systemContent = `${ollamaSystemPrompt} | ||||
|  | ||||
| ${conversationContext} | ||||
| Current message from @${originalAuthor}: | ||||
| "${userMessage}" | ||||
|  | ||||
| Instructions: | ||||
| - You are replying to @${originalAuthor} | ||||
| - Address them directly if appropriate | ||||
| - Use markdown formatting and emojis sparingly`; | ||||
|     } | ||||
|  | ||||
|       // Switch to chat request format (messages array auto-handles Llama 3 template) | ||||
|     const ollamaRequestBody: OllamaChatRequest = { | ||||
|       model: ollamaModel, | ||||
|       messages: [ | ||||
|           { role: "system", content: systemContent as string }, | ||||
|         { role: "system", content: systemContent }, | ||||
|         { role: "user", content: userMessage }, | ||||
|       ], | ||||
|       stream: false, | ||||
|         options: ollamaConfig, | ||||
|       options: { | ||||
|         ...ollamaConfig, | ||||
|         stop: ["</s>", "[INST]"], // Mistral 0.3 stop tokens | ||||
|       }, | ||||
|     }; | ||||
|  | ||||
|     console.log( | ||||
|       `Generating response for notification ${notification.id} from @${originalAuthor}` | ||||
|     ); | ||||
|  | ||||
|     // Change endpoint to /api/chat | ||||
|     const response = await fetch(`${ollamaUrl}/api/chat`, { | ||||
|       method: "POST", | ||||
|       body: JSON.stringify(ollamaRequestBody), | ||||
|     }); | ||||
|  | ||||
|     if (!response.ok) { | ||||
|       throw new Error(`Ollama API request failed: ${response.statusText}`); | ||||
|     } | ||||
|  | ||||
|     const ollamaResponse: OllamaChatResponse = await response.json(); | ||||
|  | ||||
|     await storePromptData(notification, ollamaResponse); | ||||
|     return ollamaResponse; | ||||
|     } | ||||
|   } catch (error: any) { | ||||
|     throw new Error(error.message); | ||||
|     console.error( | ||||
|       `Error in generateOllamaRequest for notification ${notification.id}:`, | ||||
|       error.message | ||||
|     ); | ||||
|     // Delete notification on error to prevent retry loops | ||||
|     shouldDeleteNotification = true; | ||||
|     throw error; | ||||
|   } finally { | ||||
|     if (shouldDeleteNotification) { | ||||
|       try { | ||||
|         await deleteNotification(notification); | ||||
|       } catch (deleteError: any) { | ||||
|         console.error( | ||||
|           `Failed to delete notification ${notification.id}:`, | ||||
|           deleteError.message | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| }; | ||||
|  | ||||
| @ -145,27 +198,28 @@ const postReplyToStatus = async ( | ||||
|   ollamaResponseBody: OllamaChatResponse | ||||
| ) => { | ||||
|   const { pleromaInstanceUrl, bearerToken } = envConfig; | ||||
|   // const emojiList = await getInstanceEmojis(); | ||||
|   // let randomEmoji; | ||||
|   // if (emojiList) { | ||||
|   //   randomEmoji = selectRandomEmoji(emojiList); | ||||
|   // } | ||||
|  | ||||
|   try { | ||||
|     let mentions: string[]; | ||||
|     // Only mention the original author who triggered the bot | ||||
|     const originalAuthor = notification.account.acct; | ||||
|     console.log( | ||||
|       `Replying to: @${originalAuthor} (status ID: ${notification.status.id})` | ||||
|     ); | ||||
|  | ||||
|     // Sanitize LLM output - remove any stray Mistral special tokens | ||||
|     let sanitizedContent = ollamaResponseBody.message.content | ||||
|       .replace(/<\/s>/g, "") // Remove EOS token if it appears | ||||
|       .replace(/\[INST\]/g, "") // Remove instruction start token | ||||
|       .replace(/\[\/INST\]/g, "") // Remove instruction end token | ||||
|       .replace(/<s>/g, "") // Remove BOS token if it appears | ||||
|       .trim(); | ||||
|  | ||||
|     const statusBody: NewStatusBody = { | ||||
|       content_type: "text/markdown", | ||||
|       status: `${ollamaResponseBody.message.content}`, | ||||
|       status: sanitizedContent, | ||||
|       in_reply_to_id: notification.status.id, | ||||
|       to: [originalAuthor], // Only send to the person who mentioned the bot | ||||
|     }; | ||||
|     if ( | ||||
|       notification.status.mentions && | ||||
|       notification.status.mentions.length > 0 | ||||
|     ) { | ||||
|       mentions = notification.status.mentions.map((mention) => { | ||||
|         return mention.acct; | ||||
|       }); | ||||
|       statusBody.to = mentions; | ||||
|     } | ||||
|  | ||||
|     const response = await fetch(`${pleromaInstanceUrl}/api/v1/statuses`, { | ||||
|       method: "POST", | ||||
| @ -180,9 +234,23 @@ const postReplyToStatus = async ( | ||||
|       throw new Error(`New status request failed: ${response.statusText}`); | ||||
|     } | ||||
|  | ||||
|     await deleteNotification(notification); | ||||
|     console.log(`Successfully posted reply to @${originalAuthor}`); | ||||
|   } catch (error: any) { | ||||
|     throw new Error(error.message); | ||||
|     console.error( | ||||
|       `Error posting reply for notification ${notification.id}:`, | ||||
|       error.message | ||||
|     ); | ||||
|     throw error; | ||||
|   } finally { | ||||
|     // Always try to delete the notification, even if posting failed | ||||
|     try { | ||||
|       await deleteNotification(notification); | ||||
|     } catch (deleteError: any) { | ||||
|       console.error( | ||||
|         `Failed to delete notification ${notification.id}:`, | ||||
|         deleteError.message | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
| }; | ||||
|  | ||||
| @ -193,15 +261,22 @@ const createTimelinePost = async () => { | ||||
|     ollamaSystemPrompt, | ||||
|     ollamaUrl, | ||||
|     pleromaInstanceUrl, | ||||
|     adHocPostInstructions, | ||||
|   } = envConfig; | ||||
|   const ollamaRequestBody: OllamaChatRequest = { | ||||
|     model: ollamaModel, | ||||
|     messages: [ | ||||
|       { role: "system", content: ollamaSystemPrompt as string }, | ||||
|       { role: "user", content: "Say something random." }, | ||||
|       { role: "system", content: ollamaSystemPrompt }, | ||||
|       { | ||||
|         role: "user", | ||||
|         content: adHocPostInstructions, | ||||
|       }, | ||||
|     ], | ||||
|     stream: false, | ||||
|     options: ollamaConfig, | ||||
|     options: { | ||||
|       ...ollamaConfig, | ||||
|       stop: ["</s>", "[INST]"], // Mistral 0.3 stop tokens | ||||
|     }, | ||||
|   }; | ||||
|   try { | ||||
|     const response = await fetch(`${ollamaUrl}/api/chat`, { | ||||
| @ -244,18 +319,21 @@ const beginFetchCycle = async () => { | ||||
|   setInterval(async () => { | ||||
|     notifications = await getNotifications(); | ||||
|     if (notifications.length > 0) { | ||||
|       await Promise.all( | ||||
|         notifications.map(async (notification) => { | ||||
|       // Process notifications sequentially to avoid race conditions | ||||
|       for (const notification of notifications) { | ||||
|         try { | ||||
|           const ollamaResponse = await generateOllamaRequest(notification); | ||||
|           if (ollamaResponse) { | ||||
|               postReplyToStatus(notification, ollamaResponse); | ||||
|             await postReplyToStatus(notification, ollamaResponse); | ||||
|           } | ||||
|         } catch (error: any) { | ||||
|             throw new Error(error.message); | ||||
|           } | ||||
|         }) | ||||
|           console.error( | ||||
|             `Error processing notification ${notification.id}:`, | ||||
|             error.message | ||||
|           ); | ||||
|           // Continue processing other notifications even if one fails | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }, envConfig.fetchInterval); // lower intervals may cause the bot to respond multiple times to the same message, but we try to mitigate this with the deleteNotification function | ||||
| }; | ||||
| @ -277,6 +355,11 @@ console.log( | ||||
|     envConfig.fetchInterval / 1000 | ||||
|   } seconds.` | ||||
| ); | ||||
| console.log( | ||||
|   `Making ad-hoc post to ${envConfig.pleromaInstanceDomain}, every ${ | ||||
|     envConfig.adHocPostInterval / 1000 / 60 | ||||
|   } minutes.` | ||||
| ); | ||||
| console.log( | ||||
|   `Accepting prompts from: ${envConfig.whitelistedDomains.join(", ")}` | ||||
| ); | ||||
| @ -288,7 +371,4 @@ console.log( | ||||
| console.log(`System prompt: ${envConfig.ollamaSystemPrompt}`); | ||||
|  | ||||
| await beginFetchCycle(); | ||||
| // setInterval(async () => { | ||||
| //   createTimelinePost(); | ||||
| // }, 10000); | ||||
| await beginStatusPostInterval(); | ||||
|  | ||||
		Reference in New Issue
	
	Block a user