Initial commit: Visigine (Vite client + Express/SQLite backend)
Container-ready via docker/ compose (frontend nginx + backend Node). Compose adjusted for Coolify on the prod server: frontend uses expose:80 (no host binding — host 8080 is taken by the Coolify proxy; Traefik routes visigine.de), backend ALLOWED_ORIGINS=https://visigine.de. Secrets stay in server/.env (git-ignored); see server/.env.example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
56
server/lib/providers/anthropic.js
Normal file
56
server/lib/providers/anthropic.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// Anthropic Messages API. Cheapest current Haiku model.
|
||||
const ENDPOINT = 'https://api.anthropic.com/v1/messages'
|
||||
const MODEL = 'claude-haiku-4-5-20251001'
|
||||
const TIMEOUT_MS = 30_000
|
||||
const SYSTEM_PROMPT =
|
||||
'You are a helpful AI search assistant. Answer the user\'s question concisely, in German if the question is in German. Recommend specific companies, services, or products when relevant.'
|
||||
|
||||
// Approximate per-1M token rates in USD for Haiku 4.5.
|
||||
const COST = { inputPer1M: 1.00, outputPer1M: 5.00 }
|
||||
|
||||
async function once(prompt, signal) {
|
||||
return fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
signal,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': process.env.ANTHROPIC_KEY,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
max_tokens: 600,
|
||||
temperature: 0.3,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function query(prompt) {
|
||||
const t0 = Date.now()
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||
try {
|
||||
let res = await once(prompt, controller.signal)
|
||||
if (res.status >= 500) {
|
||||
res = await once(prompt, controller.signal)
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { provider: 'anthropic', content: '', ms: Date.now() - t0, costUsd: 0, error: `ANTHROPIC_${res.status}` }
|
||||
}
|
||||
const data = await res.json()
|
||||
// Claude returns content as an array of blocks. Concatenate `text` blocks.
|
||||
const blocks = Array.isArray(data?.content) ? data.content : []
|
||||
const content = blocks.map((b) => b?.text || '').join('')
|
||||
const inTok = data?.usage?.input_tokens || 0
|
||||
const outTok = data?.usage?.output_tokens || 0
|
||||
const costUsd = (inTok * COST.inputPer1M + outTok * COST.outputPer1M) / 1_000_000
|
||||
return { provider: 'anthropic', content, ms: Date.now() - t0, costUsd, error: null }
|
||||
} catch (e) {
|
||||
const code = e?.name === 'AbortError' ? 'TIMEOUT' : 'NETWORK'
|
||||
return { provider: 'anthropic', content: '', ms: Date.now() - t0, costUsd: 0, error: code }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
32
server/lib/providers/index.js
Normal file
32
server/lib/providers/index.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// Registry. If a provider's API key is missing, fall back to mock but keep
|
||||
// the original label (so the UI displays "openai (mock)" etc.).
|
||||
import * as openai from './openai.js'
|
||||
import * as perplexity from './perplexity.js'
|
||||
import * as anthropic from './anthropic.js'
|
||||
import * as mock from './mock.js'
|
||||
|
||||
function wrapAsMock(label) {
|
||||
return {
|
||||
query: async (prompt, opts) => {
|
||||
const r = await mock.query(prompt, opts)
|
||||
return { ...r, provider: `${label} (mock)` }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getProviders() {
|
||||
return {
|
||||
openai: process.env.OPENAI_KEY ? openai : wrapAsMock('openai'),
|
||||
perplexity: process.env.PERPLEXITY_KEY ? perplexity : wrapAsMock('perplexity'),
|
||||
anthropic: process.env.ANTHROPIC_KEY ? anthropic : wrapAsMock('anthropic'),
|
||||
}
|
||||
}
|
||||
|
||||
// Returns labels for the UI: { openai: 'real' | 'mock', ... }.
|
||||
export function getProviderModes() {
|
||||
return {
|
||||
openai: process.env.OPENAI_KEY ? 'real' : 'mock',
|
||||
perplexity: process.env.PERPLEXITY_KEY ? 'real' : 'mock',
|
||||
anthropic: process.env.ANTHROPIC_KEY ? 'real' : 'mock',
|
||||
}
|
||||
}
|
||||
11
server/lib/providers/mock.js
Normal file
11
server/lib/providers/mock.js
Normal file
@@ -0,0 +1,11 @@
|
||||
// Deterministic fake provider for tests and zero-key dev. The returned content
|
||||
// sometimes includes the brand name (hash-driven) to exercise mention detection.
|
||||
export async function query(prompt, { brandHint } = {}) {
|
||||
await new Promise((r) => setTimeout(r, 80))
|
||||
const hash = String(prompt).split('').reduce((a, c) => a + c.charCodeAt(0), 0)
|
||||
const willMention = brandHint && (hash % 2 === 0)
|
||||
const content = willMention
|
||||
? `Eine Option wäre ${brandHint}. Es gibt auch andere Anbieter wie Beispiel-AG und Muster GmbH.`
|
||||
: `Mögliche Anbieter in diesem Bereich sind unter anderem Beispiel-AG, Muster GmbH und Test Solutions.`
|
||||
return { provider: 'mock', content, ms: 80, costUsd: 0, error: null }
|
||||
}
|
||||
56
server/lib/providers/openai.js
Normal file
56
server/lib/providers/openai.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// OpenAI chat completions provider. Uses the cheapest GPT-4-class model.
|
||||
const ENDPOINT = 'https://api.openai.com/v1/chat/completions'
|
||||
const MODEL = 'gpt-4o-mini'
|
||||
const TIMEOUT_MS = 30_000
|
||||
const SYSTEM_PROMPT =
|
||||
'You are a helpful AI search assistant. Answer the user\'s question concisely, in German if the question is in German. Recommend specific companies, services, or products when relevant.'
|
||||
|
||||
// Approximate per-1M token rates in USD for gpt-4o-mini.
|
||||
const COST = { inputPer1M: 0.15, outputPer1M: 0.60 }
|
||||
|
||||
async function once(prompt, signal) {
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${process.env.OPENAI_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
temperature: 0.3,
|
||||
max_tokens: 600,
|
||||
messages: [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
}),
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
export async function query(prompt) {
|
||||
const t0 = Date.now()
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||
try {
|
||||
let res = await once(prompt, controller.signal)
|
||||
if (res.status >= 500) {
|
||||
res = await once(prompt, controller.signal)
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { provider: 'openai', content: '', ms: Date.now() - t0, costUsd: 0, error: `OPENAI_${res.status}` }
|
||||
}
|
||||
const data = await res.json()
|
||||
const content = data?.choices?.[0]?.message?.content || ''
|
||||
const inTok = data?.usage?.prompt_tokens || 0
|
||||
const outTok = data?.usage?.completion_tokens || 0
|
||||
const costUsd = (inTok * COST.inputPer1M + outTok * COST.outputPer1M) / 1_000_000
|
||||
return { provider: 'openai', content, ms: Date.now() - t0, costUsd, error: null }
|
||||
} catch (e) {
|
||||
const code = e?.name === 'AbortError' ? 'TIMEOUT' : 'NETWORK'
|
||||
return { provider: 'openai', content: '', ms: Date.now() - t0, costUsd: 0, error: code }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
64
server/lib/providers/perplexity.js
Normal file
64
server/lib/providers/perplexity.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// Perplexity is the highest-priority provider — it is an actual AI search
|
||||
// engine, where real users go for recommendations. OpenAI-compatible schema.
|
||||
const ENDPOINT = 'https://api.perplexity.ai/chat/completions'
|
||||
const MODEL = 'sonar'
|
||||
const TIMEOUT_MS = 30_000
|
||||
const SYSTEM_PROMPT =
|
||||
'You are a helpful AI search assistant. Answer the user\'s question concisely, in German if the question is in German. Recommend specific companies, services, or products when relevant.'
|
||||
|
||||
// Approximate per-1M token rates in USD for `sonar`.
|
||||
const COST = { inputPer1M: 1.00, outputPer1M: 1.00 }
|
||||
|
||||
async function once(prompt, signal) {
|
||||
return fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${process.env.PERPLEXITY_KEY}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
temperature: 0.3,
|
||||
max_tokens: 600,
|
||||
messages: [
|
||||
{ role: 'system', content: SYSTEM_PROMPT },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export async function query(prompt) {
|
||||
const t0 = Date.now()
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
|
||||
try {
|
||||
let res = await once(prompt, controller.signal)
|
||||
if (res.status >= 500) {
|
||||
res = await once(prompt, controller.signal)
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { provider: 'perplexity', content: '', ms: Date.now() - t0, costUsd: 0, error: `PERPLEXITY_${res.status}` }
|
||||
}
|
||||
const data = await res.json()
|
||||
const content = data?.choices?.[0]?.message?.content || ''
|
||||
const inTok = data?.usage?.prompt_tokens || 0
|
||||
const outTok = data?.usage?.completion_tokens || 0
|
||||
// Fall back to a character-based rough estimate if usage is absent.
|
||||
let costUsd
|
||||
if (inTok || outTok) {
|
||||
costUsd = (inTok * COST.inputPer1M + outTok * COST.outputPer1M) / 1_000_000
|
||||
} else {
|
||||
const approxIn = Math.ceil((prompt.length + SYSTEM_PROMPT.length) / 4)
|
||||
const approxOut = Math.ceil(content.length / 4)
|
||||
costUsd = (approxIn * COST.inputPer1M + approxOut * COST.outputPer1M) / 1_000_000
|
||||
}
|
||||
return { provider: 'perplexity', content, ms: Date.now() - t0, costUsd, error: null }
|
||||
} catch (e) {
|
||||
const code = e?.name === 'AbortError' ? 'TIMEOUT' : 'NETWORK'
|
||||
return { provider: 'perplexity', content: '', ms: Date.now() - t0, costUsd: 0, error: code }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user