fIx: smaller nits

This commit is contained in:
2026-03-20 00:04:46 +01:00
parent bc67d01bc2
commit c3cbe35883
21 changed files with 2093 additions and 116 deletions

View File

@@ -1,101 +0,0 @@
/**
* Minimal Express API: /api/agent, /health.
* No DB, no auth. CORS allowed for frontend. Production-ready env (PORT, CORS_ORIGIN).
*/
const express = require('express')
const cors = require('cors')
const PORT = Number(process.env.PORT) || 8080
const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:3000'
const app = express()
app.use(cors({ origin: CORS_ORIGIN }))
app.use(express.json())
/** Build OpenAI client and full prompt from request body. Returns { openai, modelId, fullPrompt } or { error }. */
function buildAgentRequest(body) {
const { prompt, context, contextNodes, connection: conn, reasoning } = body ?? {}
const reasoningEnabled = Boolean(reasoning)
let baseURL = process.env.AI_BASE_URL?.trim() || null
let apiKey = process.env.OPENAI_API_KEY?.trim() || null
let modelId = process.env.AI_MODEL?.trim() || (baseURL ? 'local-model' : 'gpt-4o-mini')
if (conn && typeof conn === 'object') {
const c = conn
const provider = c.provider === 'openai' ? 'openai' : 'local'
if (provider === 'local') {
baseURL = (typeof c.baseURL === 'string' && c.baseURL.trim()) ? c.baseURL.trim() : baseURL
apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : (apiKey || 'lm-studio')
} else {
baseURL = null
apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : apiKey
}
if (typeof c.model === 'string' && c.model.trim()) modelId = c.model.trim()
}
if (!baseURL && !apiKey) {
return { error: 'No AI configured. Set connection in Settings (AI) or env: OPENAI_API_KEY or AI_BASE_URL.' }
}
const basePrompt = [
typeof prompt === 'string' ? prompt : 'No prompt provided.',
context && typeof context === 'string' ? `\n\nAdditional context:\n${context}` : '',
Array.isArray(contextNodes) && contextNodes.length > 0
? `\n\nContext from connected nodes:\n${contextNodes.map((n) => (n.content != null ? n.content : `${n.id}: (no content)`)).join('\n\n')}`
: '',
].join('')
const fullPrompt = reasoningEnabled
? basePrompt + '\n\nRespond in exactly two markdown sections. First: "## Reasoning" with your step-by-step reasoning. Then: "## Output" with only the final answer. No preamble.'
: basePrompt + '\n\nRespond with structured markdown only. No preamble.'
const { createOpenAI } = require('@ai-sdk/openai')
const openai = createOpenAI({
apiKey: apiKey || 'lm-studio',
...(baseURL && { baseURL, compatibility: 'compatible' }),
})
return { openai, modelId, fullPrompt }
}
/** POST /api/agent — run AI agent; body: { prompt, context?, contextNodes?, connection? }; returns { markdown }. */
app.post('/api/agent', async (req, res) => {
try {
const built = buildAgentRequest(req.body)
if (built.error) return res.status(503).json({ error: built.error })
const { generateText } = await import('ai')
const result = await generateText({
model: built.openai(built.modelId),
prompt: built.fullPrompt,
})
const markdown = result?.text ?? ''
res.json({ markdown })
} catch (err) {
console.error('Agent error:', err)
res.status(500).json({ error: err?.message ?? 'Agent request failed' })
}
})
/** POST /api/agent/stream — same as /api/agent but streams plain text (markdown) chunks. */
app.post('/api/agent/stream', async (req, res) => {
try {
const built = buildAgentRequest(req.body)
if (built.error) return res.status(503).json({ error: built.error })
const { streamText } = await import('ai')
const result = streamText({
model: built.openai(built.modelId),
prompt: built.fullPrompt,
})
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
res.setHeader('Transfer-Encoding', 'chunked')
result.pipeTextStreamToResponse(res)
} catch (err) {
console.error('Agent stream error:', err)
res.status(500).json({ error: err?.message ?? 'Agent request failed' })
}
})
/** Health check for Docker / orchestration */
app.get('/health', (req, res) => {
res.status(200).json({ ok: true })
})
app.listen(PORT, '0.0.0.0', () => {
console.log(`Backend listening on port ${PORT} (CORS: ${CORS_ORIGIN})`)
})

102
backend/src/index.ts Normal file
View File

@@ -0,0 +1,102 @@
/**
* Backend API: Layered architecture for scalability and maintainability.
*
* ## Architecture Layers
*
* ### Controllers (routes/)
* - Handle HTTP requests and responses
* - Validate input and format output
* - Call services for business logic
*
* ### Services (services/)
* - Implement business logic
* - Coordinate between controllers and repositories
* - Handle validation and transformation
*
* ### Repositories (repositories/)
* - Data access layer
* - Interact with databases or external APIs
* - Return domain models
*
* ### Models (models/)
* - Data structures and types
* - Validation schemas
*
* ### Middleware (middleware/)
* - Request validation
* - Authentication/authorization
* - Rate limiting
* - Error handling
*
* ## Adding New Endpoints
*
* 1. Define the model in `models/`
* 2. Implement repository logic in `repositories/`
* 3. Implement service logic in `services/`
* 4. Create controller in `routes/`
* 5. Register route in `index.ts`
*/
import express from 'express'
import cors from 'cors'
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const PORT = Number(process.env.PORT) || 8080
const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:3000'
// ---------------------------------------------------------------------------
// Express App Setup
// ---------------------------------------------------------------------------
const app = express()
app.use(cors({ origin: CORS_ORIGIN }))
app.use(express.json())
// ---------------------------------------------------------------------------
// Agent Routes
// ---------------------------------------------------------------------------
import { handleAgentRequest, handleAgentStreamRequest } from './routes/agentRoutes.js'
/** POST /api/agent - Run AI agent */
app.post('/api/agent', handleAgentRequest)
/** POST /api/agent/stream - Stream AI agent response */
app.post('/api/agent/stream', handleAgentStreamRequest)
// ---------------------------------------------------------------------------
// Health Check Endpoint
// ---------------------------------------------------------------------------
/** GET /health - Health check for Docker / orchestration */
app.get('/health', (req, res) => {
res.status(200).json({ ok: true, timestamp: Date.now() })
})
// ---------------------------------------------------------------------------
// Error Handling Middleware
// ---------------------------------------------------------------------------
/** Global error handler for consistent error responses */
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error('Error:', err)
res.status(500).json({ error: err.message ?? 'Internal server error' })
})
// ---------------------------------------------------------------------------
// Start Server
// ---------------------------------------------------------------------------
app.listen(PORT, '0.0.0.0', () => {
console.log(`Backend listening on port ${PORT} (CORS: ${CORS_ORIGIN})`)
})
// ---------------------------------------------------------------------------
// Export for testing
// ---------------------------------------------------------------------------
export default app

View File

@@ -0,0 +1,88 @@
/**
* Middleware: Rate limiting for API endpoints.
*
* This module provides rate limiting middleware to protect the API
* from abuse and ensure fair usage.
*/
/**
* Rate limit configuration
*/
export type RateLimitConfig = {
/** Maximum number of requests allowed in the window */
max: number
/** Window size in milliseconds */
windowMs: number
/** Error message to return when rate limited */
message?: string
}
/**
* Default rate limit configuration
*/
export const DEFAULT_RATE_LIMIT_CONFIG: RateLimitConfig = {
max: 100,
windowMs: 60 * 1000, // 1 minute
message: 'Too many requests, please try again later.',
}
/**
* In-memory store for rate limiting
*/
const rateLimitStore = new Map<string, { count: number; resetTime: number }>()
/**
* Rate limiting middleware
*
* @param config - Rate limit configuration
* @returns Express middleware function
*/
export function rateLimit(config: RateLimitConfig = DEFAULT_RATE_LIMIT_CONFIG) {
return (req: { ip?: string; socket?: { remoteAddress?: string } }, res: { status: (code: number) => { json: (body: { error: string }) => void }, locals: Record<string, unknown> }, next: () => void): void => {
const ip = req.ip || req.socket?.remoteAddress || 'unknown'
const now = Date.now()
let entry = rateLimitStore.get(ip)
if (!entry || now > entry.resetTime) {
// Reset the counter for a new window
entry = { count: 1, resetTime: now + config.windowMs }
rateLimitStore.set(ip, entry)
next()
return
}
entry.count++
if (entry.count > config.max) {
res.status(429).json({ error: config.message ?? 'Too many requests' })
return
}
next()
}
}
/**
* Clean up expired entries from the rate limit store
*/
export function cleanupRateLimitStore(): void {
const now = Date.now()
for (const [ip, entry] of rateLimitStore.entries()) {
if (now > entry.resetTime) {
rateLimitStore.delete(ip)
}
}
}
/**
* Get rate limit status for an IP
*/
export function getRateLimitStatus(ip: string): { remaining: number; resetTime: number } | null {
const entry = rateLimitStore.get(ip)
if (!entry) return null
return {
remaining: Math.max(0, DEFAULT_RATE_LIMIT_CONFIG.max - entry.count),
resetTime: entry.resetTime,
}
}

View File

@@ -0,0 +1,63 @@
/**
* Models: Data structures and validation schemas.
*
* This module defines the domain models used throughout the application.
*/
/**
* Agent request payload
*/
export type AgentRequest = {
prompt: string
context?: string
contextNodes?: AgentContextNode[]
connection?: AgentConnection
reasoning?: boolean
}
/**
* Context node for agent requests
*/
export type AgentContextNode = {
id: string
content?: string
}
/**
* AI connection configuration
*/
export type AgentConnection = {
provider: 'openai' | 'local'
baseURL?: string
apiKey?: string
model?: string
}
/**
* Agent response
*/
export type AgentResponse = {
markdown: string
}
/**
* Agent stream response
*/
export type AgentStreamResponse = {
markdown: string
}
/**
* Health check response
*/
export type HealthResponse = {
ok: boolean
timestamp: number
}
/**
* Error response
*/
export type ErrorResponse = {
error: string
}

View File

@@ -0,0 +1,103 @@
/**
* Repositories: Data access layer.
*
* This module handles data persistence and retrieval.
* It abstracts the underlying storage mechanism.
*/
/**
* Cache entry for expensive computations
*/
export type CacheEntry<T> = {
/** Unique key for the cache entry */
key: string
/** Cached data */
data: T
/** Timestamp (ms) when this entry was created */
createdAt: number
/** Time-to-live in milliseconds */
ttl?: number
}
/**
* In-memory cache implementation
*/
export class InMemoryCache {
private cache = new Map<string, CacheEntry<unknown>>()
private defaultTtl = 5 * 60 * 1000 // 5 minutes
/**
* Get a value from the cache
*
* @param key - Cache key
* @returns Cached value or null if not found or expired
*/
get<T>(key: string): T | null {
const entry = this.cache.get(key) as CacheEntry<T> | undefined
if (!entry) return null
// Check if entry has expired
if (entry.ttl && Date.now() - entry.createdAt > entry.ttl) {
this.cache.delete(key)
return null
}
return entry.data
}
/**
* Set a value in the cache
*
* @param key - Cache key
* @param data - Data to cache
* @param ttl - Optional time-to-live in milliseconds (uses default if not specified)
*/
set<T>(key: string, data: T, ttl?: number): void {
this.cache.set(key, {
key,
data,
createdAt: Date.now(),
ttl: ttl ?? this.defaultTtl,
})
}
/**
* Delete a value from the cache
*
* @param key - Cache key
*/
delete(key: string): void {
this.cache.delete(key)
}
/**
* Clear all entries from the cache
*/
clear(): void {
this.cache.clear()
}
/**
* Clean up expired entries
*/
cleanup(): void {
const now = Date.now()
for (const [key, entry] of this.cache.entries()) {
if (entry.ttl && now - entry.createdAt > entry.ttl) {
this.cache.delete(key)
}
}
}
/**
* Get the number of entries in the cache
*/
size(): number {
return this.cache.size
}
}
/**
* Global cache instance
*/
export const cache = new InMemoryCache()

View File

@@ -0,0 +1,72 @@
/**
* Routes: HTTP request handlers.
*
* This module contains the route handlers for the API.
* Routes validate input and format output, then call services for business logic.
*/
import type { AgentRequest, AgentResponse, ErrorResponse } from '../models'
/**
* POST /api/agent - Run AI agent
*
* Request body: { prompt, context?, contextNodes?, connection? }
* Response: { markdown }
*/
export async function handleAgentRequest(req: any, res: any): Promise<void> {
try {
const body = req.body
const { buildAgentRequest } = await import('../services/agentService')
const built = buildAgentRequest(body)
if ('error' in built) {
res.status(503).json({ error: built.error } as ErrorResponse)
return
}
const { generateText } = await import('ai')
const result = await generateText({
model: built.openai as any,
prompt: built.fullPrompt,
})
const { processAgentResponse } = await import('../services/agentService')
const response = processAgentResponse(result)
res.json(response)
} catch (err) {
console.error('Agent error:', err)
res.status(500).json({ error: (err as Error).message ?? 'Agent request failed' } as ErrorResponse)
}
}
/**
* POST /api/agent/stream - Stream AI agent response
*
* Request body: { prompt, context?, contextNodes?, connection? }
* Response: plain text (markdown) chunks
*/
export async function handleAgentStreamRequest(req: any, res: any): Promise<void> {
try {
const body = req.body
const { buildAgentRequest } = await import('../services/agentService')
const built = buildAgentRequest(body)
if ('error' in built) {
res.status(503).json({ error: built.error } as ErrorResponse)
return
}
const { streamText } = await import('ai')
const result = streamText({
model: built.openai as any,
prompt: built.fullPrompt,
})
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
res.setHeader('Transfer-Encoding', 'chunked')
result.pipeTextStreamToResponse(res)
} catch (err) {
console.error('Agent stream error:', err)
res.status(500).json({ error: (err as Error).message ?? 'Agent request failed' } as ErrorResponse)
}
}

View File

@@ -0,0 +1,65 @@
/**
* Services: Business logic layer.
*
* This module contains the business logic for the application.
* Services coordinate between controllers and repositories.
*/
import type { AgentRequest, AgentResponse, AgentConnection } from '../models'
/**
* Build OpenAI client and full prompt from request body.
* Returns { openai, modelId, fullPrompt } or { error }.
*/
export function buildAgentRequest(body: AgentRequest): { openai: unknown; modelId: string; fullPrompt: string } | { error: string } {
const { prompt, context, contextNodes, connection: conn, reasoning } = body ?? {}
const reasoningEnabled = Boolean(reasoning)
let baseURL = process.env.AI_BASE_URL?.trim() || null
let apiKey = process.env.OPENAI_API_KEY?.trim() || null
let modelId = process.env.AI_MODEL?.trim() || (baseURL ? 'local-model' : 'gpt-4o-mini')
if (conn && typeof conn === 'object') {
const c = conn as AgentConnection
const provider = c.provider === 'openai' ? 'openai' : 'local'
if (provider === 'local') {
baseURL = (typeof c.baseURL === 'string' && c.baseURL.trim()) ? c.baseURL.trim() : baseURL
apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : (apiKey || 'lm-studio')
} else {
baseURL = null
apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : apiKey
}
if (typeof c.model === 'string' && c.model.trim()) modelId = c.model.trim()
}
if (!baseURL && !apiKey) {
return { error: 'No AI configured. Set connection in Settings (AI) or env: OPENAI_API_KEY or AI_BASE_URL.' }
}
const basePrompt = [
typeof prompt === 'string' ? prompt : 'No prompt provided.',
context && typeof context === 'string' ? `\n\nAdditional context:\n${context}` : '',
Array.isArray(contextNodes) && contextNodes.length > 0
? `\n\nContext from connected nodes:\n${contextNodes.map((n) => (n.content != null ? n.content : `${n.id}: (no content)`)).join('\n\n')}`
: '',
].join('')
const fullPrompt = reasoningEnabled
? basePrompt + '\n\nRespond in exactly two markdown sections. First: "## Reasoning" with your step-by-step reasoning. Then: "## Output" with only the final answer. No preamble.'
: basePrompt + '\n\nRespond with structured markdown only. No preamble.'
const { createOpenAI } = require('@ai-sdk/openai')
const openai = createOpenAI({
apiKey: apiKey || 'lm-studio',
...(baseURL && { baseURL, compatibility: 'compatible' }),
})
return { openai, modelId, fullPrompt }
}
/**
* Process agent response
*/
export function processAgentResponse(result: { text?: string }): AgentResponse {
const markdown = result?.text ?? ''
return { markdown }
}