feat: migrate AI to llama and use local db
All checks were successful
Deploy / lint-build-deploy (push) Successful in 2m13s
All checks were successful
Deploy / lint-build-deploy (push) Successful in 2m13s
This commit is contained in:
@@ -1,19 +1,14 @@
|
||||
ADMIN_EMAIL=""
|
||||
CRON_SECRET=""
|
||||
HOME_URL=""
|
||||
OVHCLOUD_API_KEY=""
|
||||
MAINTENANCE_MODE="0"
|
||||
NEWS_LIMIT="50"
|
||||
NEWS_TO_USE="10"
|
||||
NEXT_PUBLIC_BRAND_COUNTRY=""
|
||||
NEXT_PUBLIC_BRAND_EMAIL=""
|
||||
NEXT_PUBLIC_BRAND_NAME=""
|
||||
POSTGRES_DATABASE=""
|
||||
POSTGRES_HOST=""
|
||||
POSTGRES_PASSWORD=""
|
||||
POSTGRES_PRISMA_URL=""
|
||||
POSTGRES_URL=""
|
||||
POSTGRES_URL_NON_POOLING=""
|
||||
POSTGRES_USER=""
|
||||
DATABASE_URL=""
|
||||
RESEND_FROM=""
|
||||
RESEND_KEY=""
|
||||
SECRET_HASH=""
|
||||
|
||||
41
.gitea/workflows/deploy.yml
Normal file
41
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,41 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
|
||||
jobs:
|
||||
lint-build-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --legacy-peer-deps
|
||||
|
||||
- name: Run linting
|
||||
run: npm run lint
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Deploy to VPS
|
||||
env:
|
||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -H 51.210.247.57 >> ~/.ssh/known_hosts
|
||||
ssh debian@51.210.247.57 << 'EOF'
|
||||
cd /home/debian/newsletter-hackernews
|
||||
git pull origin main
|
||||
cd /home/debian/gitea
|
||||
docker compose up -d --build newsletter
|
||||
EOF
|
||||
14
Dockerfile
14
Dockerfile
@@ -4,12 +4,20 @@ WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN yarn
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN yarn build
|
||||
ARG NEXT_PUBLIC_BRAND_NAME
|
||||
ARG NEXT_PUBLIC_BRAND_EMAIL
|
||||
ARG NEXT_PUBLIC_BRAND_COUNTRY
|
||||
|
||||
ENV NEXT_PUBLIC_BRAND_NAME=$NEXT_PUBLIC_BRAND_NAME
|
||||
ENV NEXT_PUBLIC_BRAND_EMAIL=$NEXT_PUBLIC_BRAND_EMAIL
|
||||
ENV NEXT_PUBLIC_BRAND_COUNTRY=$NEXT_PUBLIC_BRAND_COUNTRY
|
||||
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD [ "yarn", "start" ]
|
||||
CMD [ "npm", "start" ]
|
||||
@@ -1,8 +1,16 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: newsletter-db
|
||||
environment:
|
||||
- POSTGRES_USER=newsletter
|
||||
- POSTGRES_PASSWORD=newsletter
|
||||
- POSTGRES_DB=newsletter
|
||||
ports:
|
||||
- '3000:3000'
|
||||
- '5432:5432'
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
|
||||
10863
package-lock.json
generated
Normal file
10863
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,7 @@
|
||||
"@radix-ui/react-label": "^2.0.2",
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@vercel/analytics": "^1.1.1",
|
||||
"ai": "^5.0.68",
|
||||
"openai": "^4.77.0",
|
||||
"axios": "^1.12.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
@@ -41,7 +41,7 @@
|
||||
"resend": "^3.1.0",
|
||||
"tailwind-merge": "^2.1.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^4.1.12"
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^18.4.3",
|
||||
|
||||
@@ -4,8 +4,7 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("POSTGRES_PRISMA_URL") // uses connection pooling
|
||||
directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
|
||||
@@ -1,52 +1,103 @@
|
||||
import { generateText, tool, jsonSchema } from 'ai';
|
||||
import OpenAI from 'openai';
|
||||
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);
|
||||
let ovhAI: OpenAI | null = null;
|
||||
|
||||
function getClient(): OpenAI {
|
||||
if (!ovhAI) {
|
||||
ovhAI = new OpenAI({
|
||||
apiKey: process.env.OVHCLOUD_API_KEY,
|
||||
baseURL: 'https://oai.endpoints.kepler.ai.cloud.ovh.net/v1'
|
||||
});
|
||||
}
|
||||
return ovhAI;
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
export async function getMessage<T>(
|
||||
text: string,
|
||||
baseTool: BaseTool
|
||||
): Promise<T> {
|
||||
const requiredFields = baseTool.input_schema.required
|
||||
? [...baseTool.input_schema.required]
|
||||
: Object.keys(baseTool.input_schema.properties);
|
||||
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
const { steps } = await generateText({
|
||||
model: 'anthropic/claude-sonnet-4.5',
|
||||
temperature: 1,
|
||||
tools: {
|
||||
[baseTool.name]: tool({
|
||||
console.info(`OVH AI request attempt ${attempt}/${MAX_RETRIES}`);
|
||||
|
||||
const completion = await getClient().chat.completions.create({
|
||||
model: 'Meta-Llama-3_3-70B-Instruct',
|
||||
temperature: 0.7,
|
||||
max_tokens: 16000,
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: baseTool.name,
|
||||
description: baseTool.input_schema.description || '',
|
||||
inputSchema: jsonSchema(baseTool.input_schema as JSONSchema7),
|
||||
execute: async args => args
|
||||
})
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: baseTool.input_schema.properties,
|
||||
required: requiredFields
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
tool_choice: {
|
||||
type: 'function',
|
||||
function: { name: baseTool.name }
|
||||
},
|
||||
toolChoice: {
|
||||
type: 'tool',
|
||||
toolName: baseTool.name
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are a professional tech journalist creating newsletter content.
|
||||
|
||||
CRITICAL REQUIREMENTS:
|
||||
1. You MUST call the function with ALL required fields populated
|
||||
2. Required fields: ${requiredFields.join(', ')}
|
||||
3. Every field must be fully populated with high-quality content
|
||||
4. Do NOT leave any field as null, undefined, or empty`
|
||||
},
|
||||
prompt: text
|
||||
{
|
||||
role: 'user',
|
||||
content: text
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
console.info('Vercel AI Gateway response steps: ', steps);
|
||||
const message = completion.choices[0]?.message;
|
||||
|
||||
const toolCalls = steps.flatMap(step => step.toolCalls);
|
||||
|
||||
if (!toolCalls || toolCalls.length === 0) {
|
||||
throw new Error('No tool calls found in response');
|
||||
if (!message?.tool_calls || message.tool_calls.length === 0) {
|
||||
throw new Error('No function call found in response');
|
||||
}
|
||||
|
||||
const typedCall = toolCalls[0] as unknown as {
|
||||
toolName: string;
|
||||
input: Record<string, unknown>;
|
||||
};
|
||||
const toolCall = message.tool_calls[0];
|
||||
|
||||
console.info('Tool call input: ', typedCall.input);
|
||||
|
||||
if (typedCall.toolName !== baseTool.name) {
|
||||
if (toolCall.function.name !== baseTool.name) {
|
||||
throw new Error(
|
||||
`Expected tool ${baseTool.name} but got ${typedCall.toolName}`
|
||||
`Expected tool ${baseTool.name} but got ${toolCall.function.name}`
|
||||
);
|
||||
}
|
||||
|
||||
return typedCall.input as T;
|
||||
const result = JSON.parse(toolCall.function.arguments);
|
||||
console.info('OVH AI response:', result);
|
||||
return result as T;
|
||||
} catch (error) {
|
||||
console.error('Vercel AI Gateway error: ', error);
|
||||
throw new Error('Failed to get message from AI Gateway');
|
||||
console.error(`Attempt ${attempt} failed:`, error);
|
||||
lastError = error as Error;
|
||||
|
||||
if (attempt < MAX_RETRIES) {
|
||||
const delay = 1000 * attempt;
|
||||
console.info(`Retrying in ${delay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.error('All retry attempts failed');
|
||||
throw lastError || new Error('OVH AI Endpoints client error.');
|
||||
}
|
||||
|
||||
@@ -25,20 +25,25 @@ export const newsletterTool: BaseTool = {
|
||||
name: 'NewsletterTool' as const,
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
description: 'Newsletter',
|
||||
description:
|
||||
'Generate a tech newsletter with title, content, and focus sections',
|
||||
properties: {
|
||||
title: {
|
||||
type: 'string' as const,
|
||||
description: 'The title of the newsletter'
|
||||
description:
|
||||
'The title of the newsletter (40-50 characters, capturing key themes)'
|
||||
},
|
||||
content: {
|
||||
type: 'string' as const,
|
||||
description: 'The main content of the newsletter'
|
||||
description:
|
||||
'The main content of the newsletter (300-400 words, HTML formatted with paragraph tags and links)'
|
||||
},
|
||||
focus: {
|
||||
type: 'string' as const,
|
||||
description: 'The text of the focus segment'
|
||||
}
|
||||
description:
|
||||
'Forward-looking assessment of key developments and trends'
|
||||
}
|
||||
},
|
||||
required: ['title', 'content', 'focus'] as const
|
||||
}
|
||||
} as const;
|
||||
|
||||
Reference in New Issue
Block a user