feat: use vercel ai gateway (#38)

This commit is contained in:
2025-10-11 16:37:36 +02:00
committed by GitHub
parent 8ed813dd53
commit 2afe8e39b9
10 changed files with 1271 additions and 948 deletions

View File

@@ -1,5 +1,4 @@
ADMIN_EMAIL="" ADMIN_EMAIL=""
ANTHROPIC_API_KEY=""
CRON_SECRET="" CRON_SECRET=""
HOME_URL="" HOME_URL=""
MAINTENANCE_MODE="0" MAINTENANCE_MODE="0"

86
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,86 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
lint-and-typecheck:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Run ESLint
run: yarn lint
- name: Run TypeScript type check
run: yarn typecheck
- name: Run Prettier check
run: yarn format
build:
name: Build
runs-on: ubuntu-latest
needs: lint-and-typecheck
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Build application
run: yarn build
- name: Report deployment readiness
if: github.ref == 'refs/heads/main'
run: echo "✅ All checks passed - ready for deployment"
commitlint:
name: Commit Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Validate commit messages
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
yarn commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
else
yarn commitlint --from HEAD~1 --to HEAD --verbose
fi

View File

@@ -3,13 +3,16 @@
A Next.js application that aggregates HackerNews stories and delivers them as personalized daily newsletters. A Next.js application that aggregates HackerNews stories and delivers them as personalized daily newsletters.
## ✨ Features ## ✨ Features
- 📧 Daily newsletter containing curated HackerNews stories - 📧 Daily newsletter containing curated HackerNews stories
- ☁️ Vercel hosting integration - ☁️ Vercel hosting integration
## 🚀 Future improvements ## 🚀 Future improvements
- ⏰ Cron every 10 minutes and reduce time delta to 10 minutes: people are more likely to open the newsletter if delivered around the time when they subscribed (if cron becomes not enough, then the cost of sending all the emails might be a bigger issue) - ⏰ Cron every 10 minutes and reduce time delta to 10 minutes: people are more likely to open the newsletter if delivered around the time when they subscribed (if cron becomes not enough, then the cost of sending all the emails might be a bigger issue)
## 🛠️ Tech Stack ## 🛠️ Tech Stack
- ⚡ Next.js - ⚡ Next.js
- 🗄️ Prisma (Database ORM) - 🗄️ Prisma (Database ORM)
- 🚀 Vercel (Hosting) - 🚀 Vercel (Hosting)
@@ -18,6 +21,7 @@ A Next.js application that aggregates HackerNews stories and delivers them as pe
## 🏁 Getting Started ## 🏁 Getting Started
### 📋 Prerequisites ### 📋 Prerequisites
- 📦 Node.js - 📦 Node.js
- 🐳 Docker - 🐳 Docker
- 🔧 Vercel CLI - 🔧 Vercel CLI
@@ -26,17 +30,20 @@ A Next.js application that aggregates HackerNews stories and delivers them as pe
### 💻 Installation ### 💻 Installation
1. Clone the repository: 1. Clone the repository:
```bash ```bash
git clone https://github.com/RiccardoSenica/newsletter-hackernews git clone https://github.com/RiccardoSenica/newsletter-hackernews
cd hackernews-newsletter cd hackernews-newsletter
``` ```
2. Install dependencies: 2. Install dependencies:
```bash ```bash
yarn install yarn install
``` ```
3. Set up Vercel: 3. Set up Vercel:
```bash ```bash
# Install Vercel CLI # Install Vercel CLI
yarn add -g vercel@latest yarn add -g vercel@latest
@@ -49,6 +56,7 @@ yarn vercel:env
``` ```
4. Set up the database: 4. Set up the database:
```bash ```bash
# Push Prisma schema to database # Push Prisma schema to database
yarn db:push yarn db:push
@@ -60,6 +68,7 @@ yarn prisma:generate
### 🔧 Development ### 🔧 Development
Run locally with Docker: Run locally with Docker:
```bash ```bash
docker-compose up --build docker-compose up --build
``` ```
@@ -67,10 +76,12 @@ docker-compose up --build
### 🗄️ Database Management ### 🗄️ Database Management
Reset database (⚠️ caution: this will delete all data): Reset database (⚠️ caution: this will delete all data):
```bash ```bash
yarn db:reset yarn db:reset
``` ```
## 🙏 Acknowledgments ## 🙏 Acknowledgments
- 🎨 [Gradient Buttons](https://gradientbuttons.colorion.co/) - 🎨 [Gradient Buttons](https://gradientbuttons.colorion.co/)
- ✨ [Custom Animation Effects](https://codepen.io/alphardex/pen/vYEYGzp) - ✨ [Custom Animation Effects](https://codepen.io/alphardex/pen/vYEYGzp)

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { summirize } from '@utils/anthropic/summarize'; import { summirize } from '@utils/ai/summarize';
import { NewsType } from '@utils/validationSchemas'; import { NewsType } from '@utils/validationSchemas';
import createDOMPurify from 'isomorphic-dompurify'; import createDOMPurify from 'isomorphic-dompurify';
import { Template } from './Template'; import { Template } from './Template';

View File

@@ -21,13 +21,13 @@
"prisma:reset": "npx prisma db push --force-reset" "prisma:reset": "npx prisma db push --force-reset"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.27.3",
"@hookform/resolvers": "^3.3.2", "@hookform/resolvers": "^3.3.2",
"@next/bundle-analyzer": "^14.2.5", "@next/bundle-analyzer": "^14.2.5",
"@prisma/client": "^5.6.0", "@prisma/client": "^5.6.0",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@vercel/analytics": "^1.1.1", "@vercel/analytics": "^1.1.1",
"ai": "^5.0.68",
"axios": "^1.12.0", "axios": "^1.12.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
@@ -41,7 +41,7 @@
"resend": "^3.1.0", "resend": "^3.1.0",
"tailwind-merge": "^2.1.0", "tailwind-merge": "^2.1.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4" "zod": "^4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^18.4.3", "@commitlint/cli": "^18.4.3",

52
utils/ai/client.ts Normal file
View File

@@ -0,0 +1,52 @@
import { generateText, tool, jsonSchema } from 'ai';
import { BaseTool } from './tool';
import type { JSONSchema7 } from 'json-schema';
export async function getMessage<T>(text: string, baseTool: BaseTool) {
console.info('Vercel AI Gateway request with text: ', text);
try {
const { steps } = await generateText({
model: 'anthropic/claude-sonnet-4.5',
temperature: 1,
tools: {
[baseTool.name]: tool({
description: baseTool.input_schema.description || '',
inputSchema: jsonSchema(baseTool.input_schema as JSONSchema7),
execute: async args => args
})
},
toolChoice: {
type: 'tool',
toolName: baseTool.name
},
prompt: text
});
console.info('Vercel AI Gateway response steps: ', steps);
const toolCalls = steps.flatMap(step => step.toolCalls);
if (!toolCalls || toolCalls.length === 0) {
throw new Error('No tool calls found in response');
}
const typedCall = toolCalls[0] as unknown as {
toolName: string;
input: Record<string, unknown>;
};
console.info('Tool call input: ', typedCall.input);
if (typedCall.toolName !== baseTool.name) {
throw new Error(
`Expected tool ${baseTool.name} but got ${typedCall.toolName}`
);
}
return typedCall.input as T;
} catch (error) {
console.error('Vercel AI Gateway error: ', error);
throw new Error('Failed to get message from AI Gateway');
}
}

View File

@@ -8,12 +8,12 @@ export interface BaseTool {
}; };
} }
export type ToolUseBlock = { export interface ToolUseBlock {
type: 'tool_use'; type: 'tool_use';
id: string; id: string;
name: string; name: string;
input: Record<string, any>; input: Record<string, unknown>;
}; }
export interface NewsletterTool { export interface NewsletterTool {
title: string; title: string;

View File

@@ -1,33 +0,0 @@
import Anthropic from '@anthropic-ai/sdk';
import { BaseTool, ToolUseBlock } from './tool';
export async function getMessage<T>(text: string, tool: BaseTool) {
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY
});
console.info('Anthropic request with text: ', text);
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-0',
max_tokens: 2048,
messages: [{ role: 'user', content: text }],
tools: [tool]
});
console.info('Anthropic response: ', response);
try {
const content = response.content;
const toolUse = content.find((block): block is ToolUseBlock => block.type === 'tool_use');
if (!toolUse) {
throw new Error('No tool_use block found in response');
}
return toolUse.input as T;
} catch (error) {
throw Error(JSON.stringify(error));
}
}

2024
yarn.lock

File diff suppressed because it is too large Load Diff