Last active
February 17, 2025 01:07
-
-
Save abfo/b564087acd47e9a57974e29f80c4c9c7 to your computer and use it in GitHub Desktop.
Revisions
-
abfo revised this gist
Feb 17, 2025 . 1 changed file with 165 additions and 31 deletions.There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -1,74 +1,132 @@ // config - ok to leave PERPLEXITY_API_TOKEN blank const OPENAI_API_TOKEN = ''; const PERPLEXITY_API_TOKEN = '' const TODOIST_API_TOKEN = ''; const AI_TASK_LABEL = 'ai'; const AI_MESSAGE_PREFIX = 'AI:'; const MAX_GENERATION_ATTEMPTS = 10; const OPENAI_MODEL = 'gpt-4o' const PERPLEXITY_MODEL = 'sonar-pro' const IMAGE_MIME_TYPES = [ 'image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif' ]; function trigger() { runAssistant(); } function runAssistant() { // process all open tasks with the AI_TASK_LABEL label tasks = getAiTasks(); tasks.forEach(task => { checkTask(task); }); } function checkTask(task) { // if the user was the last message then we generate a response var responseNeeded = true; const comments = getComments(task.id); comments.forEach(comment => { if (comment?.content) { responseNeeded = !comment.content.startsWith(AI_MESSAGE_PREFIX); } }); if(responseNeeded){ processTask(task, comments); } } function processTask(task, comments) { // build the conversation from the task and comments... // the message array is in openai chat completion format const messages = []; const now = new Date(); const timeZone = Session.getScriptTimeZone(); const timeZoneName = Utilities.formatDate(now, timeZone, "z"); // Format the date. This pattern outputs: "August 10, 2025 20:00:00 PST" const formattedDate = Utilities.formatDate(now, timeZone, "MMMM dd, yyyy HH:mm:ss z"); messages.push({ role: 'developer', content: `You are a helpful assistant that works with Todoist tasks. You are given the current task and any comments and try to help as best as you can. If the user is researching you respond with the information they're looking for. If you have a tool that can help with the task you call it. If you believe that you have fully completed the task then you call the complete_task function to close it. The current task ID is ${task.id}. The current date and time is ${formattedDate}.` }); if (task?.content) { messages.push({ role: 'user', content: task.content }); } if (task?.description) { messages.push({ role: 'user', content: task.description }); } comments.forEach(comment => { if (comment?.content) { if (comment.content.startsWith(AI_MESSAGE_PREFIX)) { // AI message messages.push({ role: 'assistant', content: comment.content.substring(AI_MESSAGE_PREFIX.length).trim() }); } else { // User message - might include an image content = [] // the text part of the comment content.push({type: 'text', text: comment.content}); // if there is an immage attachment add it if (comment.attachment) { if (IMAGE_MIME_TYPES.includes(comment.attachment.file_type)) { var url = ''; if (comment.attachment.tn_l) { // use large thumbnail if it exists url = comment.attachment.tn_l[0]; } else { // full attachment - may fail if too large url = comment.attachment.file_url; } // download to base 64 - openai can't access Todoist attachments from the URL // see https://platform.openai.com/docs/guides/vision#uploading-base64-encoded-images base64 = getAttachment(url); content.push({ type: 'image_url', image_url: { url: `data:${comment.attachment.file_type};base64,${base64}` } }); } } messages.push({ role: 'user', content: content }); } } }); if (messages[messages.length - 1].role = 'user') { // if the user is the last in the thread generate an AI response generateAiResponse(messages, task.id, timeZoneName); } } function generateAiResponse(messages, taskId, timeZoneName) { const payload = { model: OPENAI_MODEL, messages: messages, @@ -116,6 +174,13 @@ function generateAiResponse(rawMessages, taskId) { result = error.message; } break; case 'answer_question': try { result = generatePerplexityResponse(args.question); } catch (error) { result = error.message; } break; default: result = 'Unknown function?!'; break; @@ -141,6 +206,38 @@ function generateAiResponse(rawMessages, taskId) { } } function generatePerplexityResponse(question) { const messages = []; messages.push({ role: 'system', content: 'You are an artificial intelligence assistant and you answer questions for your user. Your answer will be interpreted by another AI so do not inclue any formating or special text in your answer. Be brief and answer the question in a single concise paragraph. You never ask any clarifying questions or suggest any follow up, just respond as best as you can.' }); messages.push({ role: 'user', content: question }); const payload = { model: PERPLEXITY_MODEL, messages: messages }; const options = { method: 'post', contentType: 'application/json', headers: { 'Authorization': 'Bearer ' + PERPLEXITY_API_TOKEN }, payload: JSON.stringify(payload) }; const response = UrlFetchApp.fetch('https://api.perplexity.ai/chat/completions', options); const result = JSON.parse(response.getContentText()); return result.choices[0].message.content; } function getTools(timeZoneName) { tools = []; @@ -202,6 +299,26 @@ function getTools(timeZoneName) { } }); if (PERPLEXITY_API_TOKEN){ tools.push({ "type": "function", "function": { "name": "answer_question", "description": "Answers a question using Internet search via the Perplexity Sonar API. Use this for information after your knowlege cutoff date, to reserch questions you do not know the answer to, or for local search.", "parameters": { "type": "object", "properties": { "question": { "type": "string", "description": "The question to answer, i.e. 'How many stars are there in the galaxy?'" } }, "required": ["question"] } } }); } return tools; } @@ -277,4 +394,21 @@ function getAiTasks() { }); return JSON.parse(response.getContentText()); } function getAttachment(url) { var options = { method: "get", headers: { "Authorization": "Bearer " + TODOIST_API_TOKEN } }; var response = UrlFetchApp.fetch(url, options); var fileBlob = response.getBlob(); // Get the file as a Blob var base64String = Utilities.base64Encode(fileBlob.getBytes()); // Convert Blob to Base64 return base64String; } -
abfo revised this gist
Feb 2, 2025 . No changes.There are no files selected for viewing
-
abfo created this gist
Feb 2, 2025 .There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,280 @@ // config const OPENAI_API_TOKEN = ''; const TODOIST_API_TOKEN = ''; const AI_TASK_LABEL = 'ai'; const AI_MESSAGE_PREFIX = 'AI:'; const MAX_GENERATION_ATTEMPTS = 10; const OPENAI_MODEL = 'gpt-4o' function trigger() { runAssistant(); } function runAssistant() { tasks = getAiTasks(); tasks.forEach(task => { processTask(task); }); } function processTask(task) { const messages = []; if (task?.content) { messages.push(task.content); } if (task?.description) { messages.push(task.description); } const comments = getComments(task.id); comments.forEach(comment => { if (comment?.content) { messages.push(comment.content); } }); if (messages.length > 0 && (!messages[messages.length - 1].startsWith(AI_MESSAGE_PREFIX))) { // if the user is the last in the thread generate an AI response generateAiResponse(messages, task.id); } } function generateAiResponse(rawMessages, taskId) { const now = new Date(); const timeZone = Session.getScriptTimeZone(); const timeZoneName = Utilities.formatDate(now, timeZone, "z"); // Format the date. This pattern outputs: "August 10, 2025 20:00:00 PST" const formattedDate = Utilities.formatDate(now, timeZone, "MMMM dd, yyyy HH:mm:ss z"); messages = []; messages.push({ role: 'developer', content: `You are a helpful assistant that works with Todoist tasks. You are given the current task and any comments and try to help as best as you can. If the user is researching you respond with the information they're looking for. If you have a tool that can help with the task you call it. If you believe that you have fully completed the task then you call the complete_task function to close it. The current task ID is ${taskId}. The current date and time is ${formattedDate}.` }); rawMessages.forEach(message => { if (message.startsWith(AI_MESSAGE_PREFIX )) { messages.push({ role: 'assistant', content: message.substring(AI_MESSAGE_PREFIX.length).trim() }); } else { messages.push({ role: 'user', content: message }); } }) const payload = { model: OPENAI_MODEL, messages: messages, tools: getTools(timeZoneName) }; // loop to allow for tool use for(var retry = 0; retry < MAX_GENERATION_ATTEMPTS; retry++) { const options = { method: 'post', contentType: 'application/json', headers: { 'Authorization': 'Bearer ' + OPENAI_API_TOKEN }, payload: JSON.stringify(payload) }; const response = UrlFetchApp.fetch('https://api.openai.com/v1/chat/completions', options); const result = JSON.parse(response.getContentText()); if (result.choices && result.choices.length > 0) { if (result.choices[0].message.tool_calls && result.choices[0].message.tool_calls.length > 0) { // we have at least one tool request, add the requst message to the conversation payload.messages.push(result.choices[0].message); // run all the tools... result.choices[0].message.tool_calls.forEach(tool_call => { const args = JSON.parse(tool_call['function'].arguments); var result = ''; Logger.log(`Calling ${tool_call['function'].name} (call ID ${tool_call.id})`) switch(tool_call['function'].name) { case 'create_event': try { result = createCalendarAppointment(args.title, args.start, args.end, args?.description, args?.location, args?.guests); } catch (error) { result = error.message; } break; case 'complete_task': try { result = closeTask(args.task_id); } catch (error) { result = error.message; } break; default: result = 'Unknown function?!'; break; } Logger.log(`Tool response: ${result.substring(0, 50)}...`) payload.messages.push({ "role": "tool", "tool_call_id": tool_call.id, "content": result }); }) continue; } else { // message back from AI, post it as a comment to the task const aiMessage = result.choices[0].message.content; Logger.log(`AI Response: ${aiMessage.substring(0, 50)}...`) addComment(taskId, AI_MESSAGE_PREFIX + ' ' + aiMessage) break; } } } } function getTools(timeZoneName) { tools = []; // complete a todoist task by ID, call closeTask() tools.push({ "type": "function", "function": { "name": "complete_task", "description": "Closes or completes a task", "parameters": { "type": "object", "properties": { "task_id": { "type": "string", "description": "The ID of the task to complete" } }, "required": ["task_id"] } } }); // create a meeting on the user's calendar, call createCalendarAppointment() tools.push({ "type": "function", "function": { "name": "create_event", "description": "Adds an event to the user's calendar. The start and end timestamps must be in 'MMMM D, YYYY HH:mm:ss z' javascript format", "parameters": { "type": "object", "properties": { "title": { "type": "string", "description": "Name of the calendar event, i.e. 'Lunch with Bob'." }, "start": { "type": "string", "description": `Start time for the event, i.e. 'August 10, 2025 20:00:00 ${timeZoneName}', assume ${timeZoneName} if the user does not specify` }, "end": { "type": "string", "description": `End time for the event, i.e. 'August 10, 2025 21:00:00 ${timeZoneName}', assume ${timeZoneName} if the user does not specify, assume 1 hour duration if no end time or length is given` }, "description": { "type": "string", "description": "Optional description for the event" }, "location": { "type": "string", "description": "Optional location for the event" }, "guests": { "type": "string", "description": "Optional comma separated list of email addresses to invite to the event. Never provide an email address unless the user specifically provided it" }, }, "required": ["title", "start", "end"] } } }); return tools; } function createCalendarAppointment(title, start, end, description, location, guests) { options = {} if (description?.length > 0) { options.description = description; } if (location?.length > 0) { options.location = location; } if (guests?.length > 0) { options.guests = guests; options.sendInvites = true; } CalendarApp.getDefaultCalendar().createEvent(title, new Date(start), new Date(end), options); return `Calendar event ${title} has been created.`; } function closeTask(taskId) { const options = { method: 'post', headers: { 'Authorization': 'Bearer ' + TODOIST_API_TOKEN } }; UrlFetchApp.fetch(`https://api.todoist.com/rest/v2/tasks/${taskId}/close`, options); return `Task ${taskId} has been closed. You don't need to do anything else and can move to your next step.`; } function addComment(taskId, comment) { const payload = { task_id: taskId, content: comment }; const options = { method: 'post', contentType: 'application/json', headers: { 'Authorization': 'Bearer ' + TODOIST_API_TOKEN }, payload: JSON.stringify(payload) }; UrlFetchApp.fetch('https://api.todoist.com/rest/v2/comments', options); } function getComments(taskId) { var response = UrlFetchApp.fetch(`https://api.todoist.com/rest/v2/comments?task_id=${encodeURIComponent(taskId)}`, { headers: { Authorization: 'Bearer ' + TODOIST_API_TOKEN } }); return JSON.parse(response.getContentText()); } function getAiTasks() { var response = UrlFetchApp.fetch(`https://api.todoist.com/rest/v2/tasks?label=${encodeURIComponent(AI_TASK_LABEL)}`, { headers: { Authorization: 'Bearer ' + TODOIST_API_TOKEN } }); return JSON.parse(response.getContentText()); }