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=""
|
ADMIN_EMAIL=""
|
||||||
CRON_SECRET=""
|
CRON_SECRET=""
|
||||||
HOME_URL=""
|
HOME_URL=""
|
||||||
|
OVHCLOUD_API_KEY=""
|
||||||
MAINTENANCE_MODE="0"
|
MAINTENANCE_MODE="0"
|
||||||
NEWS_LIMIT="50"
|
NEWS_LIMIT="50"
|
||||||
NEWS_TO_USE="10"
|
NEWS_TO_USE="10"
|
||||||
NEXT_PUBLIC_BRAND_COUNTRY=""
|
NEXT_PUBLIC_BRAND_COUNTRY=""
|
||||||
NEXT_PUBLIC_BRAND_EMAIL=""
|
NEXT_PUBLIC_BRAND_EMAIL=""
|
||||||
NEXT_PUBLIC_BRAND_NAME=""
|
NEXT_PUBLIC_BRAND_NAME=""
|
||||||
POSTGRES_DATABASE=""
|
DATABASE_URL=""
|
||||||
POSTGRES_HOST=""
|
|
||||||
POSTGRES_PASSWORD=""
|
|
||||||
POSTGRES_PRISMA_URL=""
|
|
||||||
POSTGRES_URL=""
|
|
||||||
POSTGRES_URL_NON_POOLING=""
|
|
||||||
POSTGRES_USER=""
|
|
||||||
RESEND_FROM=""
|
RESEND_FROM=""
|
||||||
RESEND_KEY=""
|
RESEND_KEY=""
|
||||||
SECRET_HASH=""
|
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 ./
|
COPY package*.json ./
|
||||||
|
|
||||||
RUN yarn
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
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
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD [ "yarn", "start" ]
|
CMD [ "npm", "start" ]
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
app:
|
postgres:
|
||||||
build:
|
image: postgres:16-alpine
|
||||||
context: .
|
container_name: newsletter-db
|
||||||
dockerfile: Dockerfile
|
environment:
|
||||||
|
- POSTGRES_USER=newsletter
|
||||||
|
- POSTGRES_PASSWORD=newsletter
|
||||||
|
- POSTGRES_DB=newsletter
|
||||||
ports:
|
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-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",
|
"openai": "^4.77.0",
|
||||||
"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": "^4.1.12"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^18.4.3",
|
"@commitlint/cli": "^18.4.3",
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ generator client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
url = env("POSTGRES_PRISMA_URL") // uses connection pooling
|
url = env("DATABASE_URL")
|
||||||
directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
|
|||||||
@@ -1,52 +1,103 @@
|
|||||||
import { generateText, tool, jsonSchema } from 'ai';
|
import OpenAI from 'openai';
|
||||||
import { BaseTool } from './tool';
|
import { BaseTool } from './tool';
|
||||||
import type { JSONSchema7 } from 'json-schema';
|
|
||||||
|
|
||||||
export async function getMessage<T>(text: string, baseTool: BaseTool) {
|
let ovhAI: OpenAI | null = null;
|
||||||
console.info('Vercel AI Gateway request with text: ', text);
|
|
||||||
|
|
||||||
try {
|
function getClient(): OpenAI {
|
||||||
const { steps } = await generateText({
|
if (!ovhAI) {
|
||||||
model: 'anthropic/claude-sonnet-4.5',
|
ovhAI = new OpenAI({
|
||||||
temperature: 1,
|
apiKey: process.env.OVHCLOUD_API_KEY,
|
||||||
tools: {
|
baseURL: 'https://oai.endpoints.kepler.ai.cloud.ovh.net/v1'
|
||||||
[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');
|
|
||||||
}
|
}
|
||||||
|
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 {
|
||||||
|
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 || '',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: baseTool.input_schema.properties,
|
||||||
|
required: requiredFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
tool_choice: {
|
||||||
|
type: 'function',
|
||||||
|
function: { name: 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`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: text
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = completion.choices[0]?.message;
|
||||||
|
|
||||||
|
if (!message?.tool_calls || message.tool_calls.length === 0) {
|
||||||
|
throw new Error('No function call found in response');
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolCall = message.tool_calls[0];
|
||||||
|
|
||||||
|
if (toolCall.function.name !== baseTool.name) {
|
||||||
|
throw new Error(
|
||||||
|
`Expected tool ${baseTool.name} but got ${toolCall.function.name}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = JSON.parse(toolCall.function.arguments);
|
||||||
|
console.info('OVH AI response:', result);
|
||||||
|
return result as T;
|
||||||
|
} catch (error) {
|
||||||
|
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,
|
name: 'NewsletterTool' as const,
|
||||||
input_schema: {
|
input_schema: {
|
||||||
type: 'object' as const,
|
type: 'object' as const,
|
||||||
description: 'Newsletter',
|
description:
|
||||||
|
'Generate a tech newsletter with title, content, and focus sections',
|
||||||
properties: {
|
properties: {
|
||||||
title: {
|
title: {
|
||||||
type: 'string' as const,
|
type: 'string' as const,
|
||||||
description: 'The title of the newsletter'
|
description:
|
||||||
|
'The title of the newsletter (40-50 characters, capturing key themes)'
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
type: 'string' as const,
|
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: {
|
focus: {
|
||||||
type: 'string' as const,
|
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;
|
} as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user