feat: use vercel ai gateway (#38)
This commit is contained in:
@@ -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
86
.github/workflows/ci.yml
vendored
Normal 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
|
||||||
11
README.md
11
README.md
@@ -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)
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
52
utils/ai/client.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user