A simple Bluesky bot to make sense of the noise, with responses powered by Gemini, similar to Grok.

feat: add citations when response uses Google Search tool

Changed files
+103 -14
src
handlers
model
utils
+69 -8
src/handlers/messages.ts
··· 1 1 import modelPrompt from "../model/prompt.txt"; 2 - import { ChatMessage, Conversation } from "@skyware/bot"; 2 + import { ChatMessage, Conversation, RichText } from "@skyware/bot"; 3 3 import * as c from "../core"; 4 4 import * as tools from "../tools"; 5 5 import consola from "consola"; ··· 37 37 parts: [ 38 38 { 39 39 text: modelPrompt 40 - .replace("{{ handle }}", env.HANDLE), 40 + .replace("$handle", env.HANDLE), 41 41 }, 42 42 ], 43 43 }, ··· 102 102 return inference; 103 103 } 104 104 105 + function addCitations( 106 + inference: Awaited<ReturnType<typeof c.ai.models.generateContent>>, 107 + ) { 108 + let originalText = inference.text ?? ""; 109 + if (!inference.candidates) { 110 + return originalText; 111 + } 112 + const supports = inference.candidates[0]?.groundingMetadata 113 + ?.groundingSupports; 114 + const chunks = inference.candidates[0]?.groundingMetadata?.groundingChunks; 115 + 116 + const richText = new RichText(); 117 + 118 + if (!supports || !chunks || originalText === "") { 119 + return richText.addText(originalText); 120 + } 121 + 122 + const sortedSupports = [...supports].sort( 123 + (a, b) => (b.segment?.endIndex ?? 0) - (a.segment?.endIndex ?? 0), 124 + ); 125 + 126 + let currentText = originalText; 127 + 128 + for (const support of sortedSupports) { 129 + const endIndex = support.segment?.endIndex; 130 + if (endIndex === undefined || !support.groundingChunkIndices?.length) { 131 + continue; 132 + } 133 + 134 + const citationLinks = support.groundingChunkIndices 135 + .map((i) => { 136 + const uri = chunks[i]?.web?.uri; 137 + if (uri) { 138 + return { index: i + 1, uri }; 139 + } 140 + return null; 141 + }) 142 + .filter(Boolean); 143 + 144 + if (citationLinks.length > 0) { 145 + richText.addText(currentText.slice(endIndex)); 146 + 147 + citationLinks.forEach((citation, idx) => { 148 + if (citation) { 149 + richText.addLink(`[${citation.index}]`, citation.uri); 150 + if (idx < citationLinks.length - 1) { 151 + richText.addText(", "); 152 + } 153 + } 154 + }); 155 + 156 + currentText = currentText.slice(0, endIndex); 157 + } 158 + } 159 + 160 + richText.addText(currentText); 161 + 162 + return richText; 163 + } 164 + 105 165 export async function handler(message: ChatMessage): Promise<void> { 106 166 const conversation = await message.getConversation(); 107 167 // ? Conversation should always be able to be found, but just in case: ··· 162 222 } 163 223 164 224 const responseText = inference.text; 225 + const responseWithCitations = addCitations(inference); 165 226 166 - if (responseText) { 167 - logger.success("Generated text:", inference.text); 168 - saveMessage(conversation, env.DID, inference.text!); 227 + if (responseWithCitations) { 228 + logger.success("Generated text:", responseText); 229 + saveMessage(conversation, env.DID, responseText!); 169 230 170 - if (exceedsGraphemes(responseText)) { 171 - multipartResponse(conversation, responseText); 231 + if (exceedsGraphemes(responseWithCitations)) { 232 + multipartResponse(conversation, responseWithCitations); 172 233 } else { 173 234 conversation.sendMessage({ 174 - text: responseText, 235 + text: responseWithCitations, 175 236 }); 176 237 } 177 238 }
+1 -1
src/model/prompt.txt
··· 1 1 You are Aero, a neutral and helpful assistant on Bluesky. 2 2 Your job is to give clear, factual, and concise explanations or context about posts users send you. 3 3 4 - Handle: {{ handle }} 4 + Handle: $handle 5 5 6 6 Guidelines: 7 7
+33 -5
src/utils/conversation.ts
··· 2 2 type ChatMessage, 3 3 type Conversation, 4 4 graphemeLength, 5 + RichText, 5 6 } from "@skyware/bot"; 6 7 import * as yaml from "js-yaml"; 7 8 import db from "../db"; ··· 61 62 did: user.did, 62 63 postUri, 63 64 revision: _convo.revision, 64 - text: initialMessage.text, 65 + text: 66 + !initialMessage.text || 67 + initialMessage.text.trim().length == 0 68 + ? "Explain this post." 69 + : initialMessage.text, 65 70 }); 66 71 67 72 return _convo!; ··· 110 115 did: getUserDid(convo).did, 111 116 postUri: row.postUri, 112 117 revision: row.revision, 113 - text: latestMessage!.text, 118 + text: postUri && 119 + (!latestMessage.text || 120 + latestMessage.text.trim().length == 0) 121 + ? "Explain this post." 122 + : latestMessage.text, 114 123 }); 115 124 } 116 125 ··· 196 205 /* 197 206 Reponse Utilities 198 207 */ 199 - export function exceedsGraphemes(content: string) { 208 + export function exceedsGraphemes(content: string | RichText) { 209 + if (content instanceof RichText) { 210 + return graphemeLength(content.text) > MAX_GRAPHEMES; 211 + } 200 212 return graphemeLength(content) > MAX_GRAPHEMES; 201 213 } 202 214 ··· 224 236 return chunks.map((chunk, i) => `(${i + 1}/${total}) ${chunk}`); 225 237 } 226 238 227 - export async function multipartResponse(convo: Conversation, content: string) { 228 - const parts = splitResponse(content).filter((p) => p.trim().length > 0); 239 + export async function multipartResponse( 240 + convo: Conversation, 241 + content: string | RichText, 242 + ) { 243 + let parts: (string | RichText)[]; 244 + 245 + if (content instanceof RichText) { 246 + if (exceedsGraphemes(content)) { 247 + // If RichText exceeds grapheme limit, convert to plain text for splitting 248 + parts = splitResponse(content.text); 249 + } else { 250 + // Otherwise, send the RichText directly as a single part 251 + parts = [content]; 252 + } 253 + } else { 254 + // If content is a string, behave as before 255 + parts = splitResponse(content); 256 + } 229 257 230 258 for (const segment of parts) { 231 259 await convo.sendMessage({