├── .vercelignore ├── .gitignore ├── package.json ├── tsconfig.json ├── readme.md └── api └── annotate.ts /.vercelignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | readme.md -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | schema.graphql 3 | .env*.local 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "omnivore-ai-annotations", 3 | "version": "1.0.0", 4 | "description": "This serverless function can be used to add annotations to Omnivore articles via webhook when a specific label (say, 'summarize') is added to them.", 5 | "author": "jancbeck", 6 | "license": "MIT", 7 | "type": "module", 8 | "dependencies": { 9 | "@types/uuid": "^10.0.0", 10 | "openai": "^4.x", 11 | "uuid": "^10.x" 12 | }, 13 | "devDependencies": { 14 | "typescript": "^5.5.4" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": ["ES2020"], 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | "jsx": "preserve", 14 | "resolveJsonModule": true, 15 | "allowJs": true, 16 | "incremental": true 17 | }, 18 | "include": ["**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Let ChatGPT annotate Omnivore articles for you 2 | 3 | ## Overview 4 | 5 | This serverless function can be used to automatically add annotations to Omnivore articles when a specific label (say, "summarize") is added to them. It uses Omnivore's [API](https://docs.omnivore.app/integrations/api.html) and [webhooks](https://docs.omnivore.app/integrations/webhooks.html) as well as [OpenAI's chat completions API](https://platform.openai.com/docs/guides/text-generation). 6 | 7 | ## How to Use 8 | 9 | **See this article for detailed instructions: https://blog.omnivore.app/p/using-chatgpt-to-automatically-add** 10 | 11 | For most convenience, deployment using [Vercel](https://vercel.com) is recommended. Theoretically it could work on other serverless functions providers but I have only tested it with Vercel. Vercel offers a free hobby plan that should cover basic usage of this function. 12 | 13 | Deploy the example using Vercel: 14 | 15 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fjancbeck%2Fomnivore-ai-annotations%2Ftree%2Fmain&env=OMNIVORE_API_KEY,OPENAI_API_KEY,OMNIVORE_ANNOTATE_LABEL,OPENAI_PROMPT&envDescription=API%20keys%20are%20required.%20OMNIVORE_ANNOTATE_LABEL%20is%20the%20name%20label%20that%20should%20trigger%20the%20workflow.%20OPENAI_PROMPT%20contains%20instructions%20for%20the%20AI%20model.&envLink=https%3A%2F%2Fgithub.com%2Fjancbeck%2Fomnivore-ai-annotations%2Ftree%2Fmain%23vercel-setup) 16 | 17 | ## Vercel Setup 18 | 19 | When adding the repo to Vercel, set the [environment variables](https://vercel.com/docs/projects/environment-variables) to make the APIs work and allow customization. 20 | 21 | - `OMNIVORE_API_KEY` (required): omnivore.app --> [API Key](https://omnivore.app/settings/api) 22 | - `OPENAI_API_KEY` (required): platform.openai.com --> [API Keys](https://platform.openai.com/api-keys) 23 | - `OMNIVORE_ANNOTATE_LABEL` (optional): set this to the name of label you want to use to trigger processing. Example: "Summarize" (without quotes). Use colons to seperate label variants e.g. naming a label "Summarize:outline" will match the environment variable value "Summarize". Not required if you use the `PAGE_CREATED` Omnivore webhook event type which process every article added to Omnivore. 24 | - `OPENAI_PROMPT` (optional): the instruction that's send to OpenAI's GPT model in addition to the article content if no label description has been entered. Uses the label description from Omnivore if available. 25 | - `OPENAI_MODEL` (optional): the [model name](https://platform.openai.com/docs/models/gpt-4-and-gpt-4-turbo) to use. Defaults to "gpt-4o-mini" (without quotes). 26 | - `OPENAI_SETTINGS` (optional, advanced): additional [request parameters](https://platform.openai.com/docs/api-reference/chat/create) send when generating the chat completion. Use JSON. Example: `{"temperature": 0, "seed": 1234}`. 27 | 28 | Deploy and copy the URL of your deployment. 29 | 30 | ### Omnivore Webhook Setup 31 | 32 | In Omnivore add a new [webhook](https://omnivore.app/settings/webhooks) and set the URL to the deployed Vercel function URL from the step above and add the path `/api/annotate` to it. Example: `https://projectname.vercel.app/api/annotate` 33 | 34 | If you have defined a label name to listen for in the step above, then select `LABEL_ADDED` as event type. 35 | If you want the function to process every article you add to Omnivore, then instead select `PAGE_CREATED`. 36 | 37 | Now either add a new article to Omnivore or your label to an existing article. Within less than a minute, the response of the model's completion should appear in the notebook of the article. 38 | 39 | Check the [runtime logs](https://vercel.com/docs/observability/runtime-logs) if you encounter issues. Check your API keys and never share them publicly. 40 | 41 | ## Development 42 | 43 | ### Clone and Deploy 44 | 45 | ```bash 46 | git clone https://github.com/jancbeck/omnivore-ai-annotations 47 | ``` 48 | 49 | Install the Vercel CLI and dependencies: 50 | 51 | ```bash 52 | npm i -g vercel 53 | npm i 54 | ``` 55 | 56 | Then run the app at the root of the repository: 57 | 58 | ```bash 59 | vercel dev 60 | ``` 61 | 62 | ## API Endpoints 63 | 64 | - **POST /api/annotate**: Annotates an article with an AI generated response. 65 | 66 | ### Local testing with Postman 67 | 68 | 1. **Local Testing**: Vercel offers a local development environment using the `vercel dev` command. Run this command in your project directory. 69 | 2. **Postman Setup**: Open Postman and create a new request. Set the request type to whatever your function expects (likely POST or GET). 70 | 3. **Request URL**: Use `http://localhost:3000/api/annotate` as the URL, replacing `3000` with whatever port `vercel dev` is using. 71 | 4. **Send Request**: Click "Send" in Postman to trigger the function. 72 | 73 | Observe the response and terminal output for logging information. 74 | 75 | ## Changelog 76 | 77 | - 2024-08-24: allow prompts from label description and label variants 78 | - 2023-09-28: inital release 79 | 80 | ## License 81 | 82 | MIT License. 83 | 84 | ## Ideas 85 | 86 | - [ ] use individual article highlights to allow "chatting" within Omnivore (e.g. highlight text, add note "explain" and GPT will generate the highlight with a reply based on the prompt and context. 87 | - [ ] instruct the model to highlight the article for you via [function calls](https://platform.openai.com/docs/guides/function-calling). Perhaps using the article notebook as an instruction input. 88 | -------------------------------------------------------------------------------- /api/annotate.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from "openai"; 2 | import { v4 as uuidv4 } from "uuid"; 3 | 4 | export const config = { 5 | runtime: "edge", 6 | }; 7 | 8 | interface Label { 9 | id: string; 10 | name: string; 11 | color: string; 12 | } 13 | 14 | interface LabelPayload { 15 | pageId: string; 16 | labels: Label[]; 17 | } 18 | 19 | interface PagePayload { 20 | id: string; 21 | userId: string; 22 | state: "SUCCEEDED" | string; 23 | originalUrl: string; 24 | downloadUrl: string | null; 25 | slug: string; 26 | title: string; 27 | author: string | null; 28 | description: string; 29 | savedAt: string; 30 | createdAt: string; 31 | publishedAt: string; 32 | archivedAt: string | null; 33 | deletedAt: string | null; 34 | readAt: string | null; 35 | updatedAt: string; 36 | itemLanguage: string; 37 | wordCount: number; 38 | siteName: string; 39 | siteIcon: string; 40 | readingProgressLastReadAnchor: number; 41 | readingProgressHighestReadAnchor: number; 42 | readingProgressTopPercent: number; 43 | readingProgressBottomPercent: number; 44 | thumbnail: string; 45 | itemType: "WEBSITE" | string; 46 | uploadFileId: string | null; 47 | contentReader: "WEB" | string; 48 | subscription: object | null; 49 | directionality: "LTR" | "RTL"; 50 | note: string | null; 51 | recommenderNames: string[]; 52 | folder: string; 53 | labelNames: string[]; 54 | highlightAnnotations: object[]; 55 | seenAt: string | null; 56 | topic: string | null; 57 | digestedAt: string | null; 58 | score: number | null; 59 | previewContent: string; 60 | } 61 | 62 | interface WebhookPayload { 63 | action: string; 64 | label?: LabelPayload; 65 | page?: PagePayload; 66 | } 67 | 68 | export default async (req: Request): Promise => { 69 | try { 70 | const body: WebhookPayload = (await req.json()) as WebhookPayload; 71 | console.log("Received webhook payload:", body); 72 | const label = body.label as LabelPayload; 73 | const pageCreated = body.page as PagePayload; 74 | 75 | let webhookType: "LABEL_ADDED" | "PAGE_CREATED"; 76 | // detect webhook type 77 | if (label) { 78 | webhookType = "LABEL_ADDED"; 79 | } else if (pageCreated) { 80 | webhookType = "PAGE_CREATED"; 81 | } else { 82 | throw new Error("No label or page data found in the webhook payload."); 83 | } 84 | let articleId = ""; 85 | // get the label to annotate from the environment 86 | const annotateLabel = process.env["OMNIVORE_ANNOTATE_LABEL"] ?? ""; 87 | 88 | switch (webhookType) { 89 | case "LABEL_ADDED": 90 | console.log(`Received LABEL_ADDED webhook.`, label); 91 | 92 | // bail if no label is specified in the environment 93 | if (!annotateLabel) { 94 | throw new Error("No label specified in environment."); 95 | } 96 | 97 | const labels = label?.labels || [label]; // handle one vs multiple labels 98 | const labelNames = labels.map((label) => label.name.split(":")[0]); // split at ":" to handle label variants 99 | const matchedLabel = labelNames.find( 100 | (labelName) => labelName === annotateLabel 101 | ); 102 | 103 | // bail if a label is specified in the environment but not in the webhook we received 104 | if (!matchedLabel) { 105 | throw new Error( 106 | `Label "${annotateLabel}" does not match any of the labels <${labelNames.join( 107 | ", " 108 | )}> provided in the webhook.` 109 | ); 110 | } 111 | articleId = label.pageId; 112 | break; 113 | 114 | case "PAGE_CREATED": 115 | console.log(`Received PAGE_CREATED webhook.`, pageCreated); 116 | articleId = pageCreated.id; 117 | break; 118 | 119 | default: 120 | // don't do anything if no label is specified in the environment 121 | // and we didn't receive a label in the webhook payload 122 | const errorMessage = 123 | "Neither label data received nor PAGE_CREATED event."; 124 | console.log(errorMessage); 125 | return new Response(errorMessage, { 126 | status: 400, 127 | }); 128 | } 129 | 130 | // STEP 1: fetch the full article content from Omnivore (not part of the webhook payload) 131 | const omnivoreHeaders = { 132 | "Content-Type": "application/json", 133 | Authorization: process.env["OMNIVORE_API_KEY"] ?? "", 134 | }; 135 | 136 | interface FetchQueryResponse { 137 | data: { 138 | article: { 139 | article: { 140 | content: string; 141 | title: string; 142 | labels: Array<{ 143 | name: string; 144 | description: string; 145 | }>; 146 | highlights: Array<{ 147 | id: string; 148 | type: string; 149 | }>; 150 | }; 151 | }; 152 | }; 153 | } 154 | 155 | let fetchQuery = { 156 | query: `query Article { 157 | article( 158 | slug: "${articleId}" 159 | username: "." 160 | format: "markdown" 161 | ) { 162 | ... on ArticleSuccess { 163 | article { 164 | title 165 | content 166 | labels { 167 | name 168 | description 169 | } 170 | highlights(input: { includeFriends: false }) { 171 | id 172 | shortId 173 | user { 174 | id 175 | name 176 | createdAt 177 | } 178 | type 179 | } 180 | } 181 | } 182 | } 183 | }`, 184 | }; 185 | 186 | const omnivoreRequest = await fetch( 187 | "https://api-prod.omnivore.app/api/graphql", 188 | { 189 | method: "POST", 190 | headers: omnivoreHeaders, 191 | body: JSON.stringify(fetchQuery), 192 | redirect: "follow", 193 | } 194 | ); 195 | const omnivoreResponse = 196 | (await omnivoreRequest.json()) as FetchQueryResponse; 197 | 198 | const { 199 | data: { 200 | article: { 201 | article: { 202 | content: articleContent, 203 | title: articleTitle, 204 | labels: articleLabels, 205 | highlights, 206 | }, 207 | }, 208 | }, 209 | } = omnivoreResponse; 210 | 211 | const promptFromLabel = articleLabels.find( 212 | ({ name }) => name.split(":")[0] === annotateLabel 213 | )?.description; 214 | 215 | const existingNote = highlights.find(({ type }) => type === "NOTE"); 216 | 217 | if (articleContent.length < 280) { 218 | throw new Error( 219 | "Article content is less than 280 characters, no need to summarize." 220 | ); 221 | } 222 | 223 | // STEP 2: generate a completion using OpenAI's API 224 | const openai = new OpenAI(); // defaults to process.env["OPENAI_API_KEY"] 225 | let prompt = 226 | promptFromLabel || 227 | process.env["OPENAI_PROMPT"] || 228 | "Return a tweet-length TL;DR of the following article."; 229 | const model = process.env["OPENAI_MODEL"] || "gpt-4o-mini"; 230 | const settings = process.env["OPENAI_SETTINGS"] || `{"model":"${model}"}`; 231 | 232 | const completionResponse = await openai.chat.completions 233 | .create({ 234 | ...JSON.parse(settings), 235 | messages: [ 236 | { 237 | role: "user", 238 | content: `Instruction: ${prompt} 239 | Article title: ${articleTitle} 240 | Article content: ${articleContent}`, 241 | }, 242 | ], 243 | }) 244 | .catch((err) => { 245 | throw err; 246 | }); 247 | // log stats about response incorporating the prompt, title and usage of 248 | console.log( 249 | `Fetched completion from OpenAI for article "${articleTitle}" (ID: ${articleId}) using prompt "${prompt}": ${JSON.stringify( 250 | completionResponse.usage 251 | )}` 252 | ); 253 | 254 | const articleAnnotation = ( 255 | completionResponse?.choices?.[0].message?.content || "" 256 | ) 257 | .trim() 258 | .replace(/\\/g, "\\\\") 259 | .replace(/"/g, '\\"'); 260 | 261 | // STEP 3: Update Omnivore article with OpenAI completion 262 | 263 | let mutationQuery: { 264 | query: string; 265 | variables: { 266 | input: { 267 | highlightId?: string; 268 | annotation: string; 269 | type?: string; 270 | id?: string; 271 | shortId?: string; 272 | articleId?: string; 273 | }; 274 | }; 275 | }; 276 | const fragment = ` 277 | fragment HighlightFields on Highlight { 278 | id 279 | type 280 | shortId 281 | quote 282 | prefix 283 | suffix 284 | patch 285 | color 286 | annotation 287 | createdByMe 288 | createdAt 289 | updatedAt 290 | sharedAt 291 | highlightPositionPercent 292 | highlightPositionAnchorIndex 293 | labels { 294 | id 295 | name 296 | color 297 | createdAt 298 | } 299 | }`; 300 | 301 | // Omnivore UI only shows one highlight note per article so 302 | // if we have an existing note, update it; otherwise, create a new one 303 | if (existingNote) { 304 | mutationQuery = { 305 | query: `mutation UpdateHighlight($input: UpdateHighlightInput!) { 306 | updateHighlight(input: $input) { 307 | ... on UpdateHighlightSuccess { 308 | highlight { 309 | ...HighlightFields 310 | } 311 | } 312 | ... on UpdateHighlightError { 313 | errorCodes 314 | } 315 | } 316 | }${fragment}`, 317 | variables: { 318 | input: { 319 | highlightId: existingNote.id, 320 | annotation: articleAnnotation, 321 | }, 322 | }, 323 | }; 324 | } else { 325 | const id = uuidv4(); 326 | const shortId = id.substring(0, 8); 327 | 328 | mutationQuery = { 329 | query: `mutation CreateHighlight($input: CreateHighlightInput!) { 330 | createHighlight(input: $input) { 331 | ... on CreateHighlightSuccess { 332 | highlight { 333 | ...HighlightFields 334 | } 335 | } 336 | ... on CreateHighlightError { 337 | errorCodes 338 | } 339 | } 340 | }${fragment}`, 341 | variables: { 342 | input: { 343 | type: "NOTE", 344 | id: id, 345 | shortId: shortId, 346 | articleId: articleId, 347 | annotation: articleAnnotation, 348 | }, 349 | }, 350 | }; 351 | } 352 | 353 | const OmnivoreAnnotationRequest = await fetch( 354 | "https://api-prod.omnivore.app/api/graphql", 355 | { 356 | method: "POST", 357 | headers: omnivoreHeaders, 358 | body: JSON.stringify(mutationQuery), 359 | } 360 | ); 361 | const OmnivoreAnnotationResponse = 362 | (await OmnivoreAnnotationRequest.json()) as { data: unknown }; 363 | console.log( 364 | `Article annotation added to article "${articleTitle}" (ID: ${articleId}): ${JSON.stringify( 365 | OmnivoreAnnotationResponse.data 366 | )}`, 367 | `Used this GraphQL query: ${JSON.stringify(mutationQuery)}` 368 | ); 369 | 370 | return new Response(`Article annotation added.`); 371 | } catch (error) { 372 | return new Response( 373 | `Error adding annotation to Omnivore article: ${ 374 | (error as Error).message 375 | }`, 376 | { status: 500 } 377 | ); 378 | } 379 | }; 380 | --------------------------------------------------------------------------------