Fix hero card aspect ratio and add horoscope generation functionality

Introduce horoscope generation via OpenAI API, including new API endpoints and database schema. Adjust card components in `home.tsx` to use `aspect-[16/9]` for consistent image sizing, resolving previous height stretching issues. Update dependencies in `package.json`.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 413891e8-d784-4bea-b9f5-91a5a68316b4
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: ca1aa952-242c-43c1-9e28-47aed39cee1b
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/413891e8-d784-4bea-b9f5-91a5a68316b4/nTLKCC5
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
sebastjanartic 2026-02-28 20:25:58 +00:00
parent 774a72bc22
commit 308e602c73
27 changed files with 1653 additions and 27 deletions

View File

@ -44,3 +44,6 @@ waitForPort = 5000
[userenv.shared]
BUNNY_LIBRARY_ID = "476412"
BUNNY_CDN_HOST = "vz-7982dfc4-cc8.b-cdn.net"
[agent]
integrations = ["javascript_openai_ai_integrations:2.0.0"]

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

View File

@ -0,0 +1,112 @@
/**
* Reusable AudioWorklet for streaming PCM16 audio playback.
* Place in public/ folder and load via audioContext.audioWorklet.addModule()
*/
class RingBuffer {
constructor(initialCapacity) {
this.capacity = initialCapacity;
this.buffer = new Float32Array(initialCapacity);
this.readIndex = 0;
this.writeIndex = 0;
this.availableData = 0;
}
push(data) {
const len = data.length;
// Auto-grow if needed
while (this.availableData + len > this.capacity) {
this.grow();
}
for (let i = 0; i < len; i++) {
this.buffer[this.writeIndex] = data[i];
this.writeIndex = (this.writeIndex + 1) % this.capacity;
this.availableData++;
}
}
grow() {
const newCapacity = this.capacity * 2;
const newBuffer = new Float32Array(newCapacity);
// Copy existing data maintaining order
for (let i = 0; i < this.availableData; i++) {
const srcIndex = (this.readIndex + i) % this.capacity;
newBuffer[i] = this.buffer[srcIndex];
}
this.buffer = newBuffer;
this.readIndex = 0;
this.writeIndex = this.availableData;
this.capacity = newCapacity;
}
pull(outputBuffer) {
const len = outputBuffer.length;
const available = Math.min(len, this.availableData);
for (let i = 0; i < available; i++) {
outputBuffer[i] = this.buffer[this.readIndex];
this.readIndex = (this.readIndex + 1) % this.capacity;
}
// Pad remaining with silence
for (let i = available; i < len; i++) {
outputBuffer[i] = 0;
}
this.availableData -= available;
return available > 0;
}
available() {
return this.availableData;
}
clear() {
this.readIndex = 0;
this.writeIndex = 0;
this.availableData = 0;
}
}
class AudioPlaybackProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.ringBuffer = new RingBuffer(24000 * 30); // 30s initial capacity
this.isPlaying = false;
this.streamComplete = false;
this.port.onmessage = (event) => {
const { type, samples } = event.data;
if (type === "audio") {
this.ringBuffer.push(samples);
this.isPlaying = true;
} else if (type === "clear") {
this.ringBuffer.clear();
this.isPlaying = false;
this.streamComplete = false;
} else if (type === "streamComplete") {
this.streamComplete = true;
} else if (type === "stop") {
this.isPlaying = false;
this.streamComplete = false;
}
};
}
process(inputs, outputs) {
const output = outputs[0];
if (!output || output.length === 0) return true;
const channel = output[0];
if (this.isPlaying) {
this.ringBuffer.pull(channel);
if (this.streamComplete && this.ringBuffer.available() === 0) {
this.isPlaying = false;
this.streamComplete = false;
this.port.postMessage({ type: "ended" });
}
} else {
channel.fill(0);
}
return true;
}
}
registerProcessor("audio-playback-processor", AudioPlaybackProcessor);

View File

@ -0,0 +1,36 @@
/**
* Audio utility functions for voice chat.
* Handles PCM16 decoding and AudioContext initialization.
*/
/**
* Decode base64 PCM16 audio to Float32Array for Web Audio API
*/
export function decodePCM16ToFloat32(base64Audio: string): Float32Array {
const raw = atob(base64Audio);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) {
bytes[i] = raw.charCodeAt(i);
}
const pcm16 = new Int16Array(bytes.buffer);
const float32 = new Float32Array(pcm16.length);
for (let i = 0; i < pcm16.length; i++) {
float32[i] = pcm16[i] / 32768;
}
return float32;
}
/**
* Create and initialize AudioContext with worklet
*/
export async function createAudioPlaybackContext(
workletPath = "/audio-playback-worklet.js",
sampleRate = 24000
): Promise<{ ctx: AudioContext; worklet: AudioWorkletNode }> {
const ctx = new AudioContext({ sampleRate });
await ctx.audioWorklet.addModule(workletPath);
const worklet = new AudioWorkletNode(ctx, "audio-playback-processor");
worklet.connect(ctx.destination);
return { ctx, worklet };
}

View File

@ -0,0 +1,45 @@
/**
* Voice chat client utilities for Replit AI Integrations.
*
* Usage:
* 1. Copy audio-playback-worklet.js to your public/ folder
* 2. Import and use the React hooks in your components
*
* Example:
* ```tsx
* import { useVoiceRecorder, useVoiceStream } from "./audio";
*
* function VoiceChat() {
* const [transcript, setTranscript] = useState("");
* const recorder = useVoiceRecorder();
* const stream = useVoiceStream({
* onTranscript: (_, full) => setTranscript(full),
* onComplete: (text) => console.log("Done:", text),
* });
*
* const handleClick = async () => {
* if (recorder.state === "recording") {
* const blob = await recorder.stopRecording();
* await stream.streamVoiceResponse("/api/voice-conversations/1/messages", blob);
* } else {
* await recorder.startRecording();
* }
* };
*
* return (
* <div>
* <button onClick={handleClick}>
* {recorder.state === "recording" ? "Stop" : "Record"}
* </button>
* <p>{transcript}</p>
* </div>
* );
* }
* ```
*/
export { decodePCM16ToFloat32, createAudioPlaybackContext } from "./audio-utils";
export { useVoiceRecorder, type RecordingState } from "./useVoiceRecorder";
export { useAudioPlayback, type PlaybackState } from "./useAudioPlayback";
export { useVoiceStream } from "./useVoiceStream";

View File

@ -0,0 +1,105 @@
/**
* React hook for streaming audio playback using AudioWorklet.
* Supports real-time PCM16 audio streaming from SSE responses.
* Includes sequence buffer for reordering out-of-order chunks.
*/
import { useRef, useCallback, useState } from "react";
import { decodePCM16ToFloat32 } from "./audio-utils";
export type PlaybackState = "idle" | "playing" | "ended";
/**
* Reorders audio chunks that may arrive out of sequence.
* Buffers chunks until they can be played in correct order.
*
* Example: If chunks arrive as seq 2, seq 0, seq 1:
* - seq 2 arrives buffered (waiting for seq 0)
* - seq 0 arrives played immediately, then check buffer
* - seq 1 arrives played immediately (seq 0 done), seq 2 now plays
*/
class SequenceBuffer {
private pending = new Map<number, string[]>();
private nextSeq = 0;
/** Add chunk with sequence number, returns chunks ready to play in order */
push(seq: number, data: string): string[] {
// Store the chunk under its sequence number
if (!this.pending.has(seq)) {
this.pending.set(seq, []);
}
this.pending.get(seq)!.push(data);
// Drain consecutive ready sequences
const ready: string[] = [];
while (this.pending.has(this.nextSeq)) {
ready.push(...this.pending.get(this.nextSeq)!);
this.pending.delete(this.nextSeq);
this.nextSeq++;
}
return ready;
}
reset() {
this.pending.clear();
this.nextSeq = 0;
}
}
export function useAudioPlayback(workletPath = "/audio-playback-worklet.js") {
const [state, setState] = useState<PlaybackState>("idle");
const ctxRef = useRef<AudioContext | null>(null);
const workletRef = useRef<AudioWorkletNode | null>(null);
const readyRef = useRef(false);
const seqBufferRef = useRef(new SequenceBuffer());
const init = useCallback(async () => {
if (readyRef.current) return;
const ctx = new AudioContext({ sampleRate: 24000 });
await ctx.audioWorklet.addModule(workletPath);
const worklet = new AudioWorkletNode(ctx, "audio-playback-processor");
worklet.connect(ctx.destination);
worklet.port.onmessage = (e) => {
if (e.data.type === "ended") setState("idle");
};
ctxRef.current = ctx;
workletRef.current = worklet;
readyRef.current = true;
}, [workletPath]);
/** Push audio directly (no sequencing) - for simple streaming */
const pushAudio = useCallback((base64Audio: string) => {
if (!workletRef.current) return;
const samples = decodePCM16ToFloat32(base64Audio);
workletRef.current.port.postMessage({ type: "audio", samples });
setState("playing");
}, []);
/** Push audio with sequence number - reorders before playback */
const pushSequencedAudio = useCallback((seq: number, base64Audio: string) => {
if (!workletRef.current) return;
const readyChunks = seqBufferRef.current.push(seq, base64Audio);
for (const chunk of readyChunks) {
const samples = decodePCM16ToFloat32(chunk);
workletRef.current.port.postMessage({ type: "audio", samples });
}
if (readyChunks.length > 0) {
setState("playing");
}
}, []);
const signalComplete = useCallback(() => {
workletRef.current?.port.postMessage({ type: "streamComplete" });
}, []);
const clear = useCallback(() => {
workletRef.current?.port.postMessage({ type: "clear" });
seqBufferRef.current.reset();
setState("idle");
}, []);
return { state, init, pushAudio, pushSequencedAudio, signalComplete, clear };
}

View File

@ -0,0 +1,52 @@
/**
* React hook for voice recording using MediaRecorder API.
* Records audio in WebM/Opus format for efficient streaming.
*/
import { useRef, useCallback, useState } from "react";
export type RecordingState = "idle" | "recording" | "stopped";
export function useVoiceRecorder() {
const [state, setState] = useState<RecordingState>("idle");
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const startRecording = useCallback(async (): Promise<void> => {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream, {
mimeType: "audio/webm;codecs=opus",
});
mediaRecorderRef.current = recorder;
chunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.start(100); // Collect chunks every 100ms
setState("recording");
}, []);
const stopRecording = useCallback((): Promise<Blob> => {
return new Promise((resolve) => {
const recorder = mediaRecorderRef.current;
if (!recorder || recorder.state !== "recording") {
resolve(new Blob());
return;
}
recorder.onstop = () => {
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
recorder.stream.getTracks().forEach((t) => t.stop());
setState("stopped");
resolve(blob);
};
recorder.stop();
});
}, []);
return { state, startRecording, stopRecording };
}

View File

@ -0,0 +1,91 @@
/**
* React hook for handling SSE voice streaming responses.
* Converts audio blob to base64 and sends as JSON to match server expectations.
*/
import { useCallback } from "react";
import { useAudioPlayback } from "./useAudioPlayback";
interface StreamCallbacks {
onUserTranscript?: (text: string) => void;
onTranscript?: (text: string, full: string) => void;
onComplete?: (transcript: string) => void;
onError?: (error: Error) => void;
}
export function useVoiceStream(callbacks: StreamCallbacks = {}) {
const playback = useAudioPlayback();
const streamVoiceResponse = useCallback(
async (url: string, audioBlob: Blob) => {
await playback.init();
playback.clear();
// Convert blob to base64 for JSON body (server expects express.json())
const base64Audio = await new Promise<string>((resolve) => {
const fileReader = new FileReader();
fileReader.onload = () => {
const result = fileReader.result as string;
resolve(result.split(",")[1]); // Remove data URL prefix
};
fileReader.readAsDataURL(audioBlob);
});
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ audio: base64Audio }),
});
if (!response.ok) throw new Error("Voice request failed");
const streamReader = response.body?.getReader();
if (!streamReader) throw new Error("No response body");
const decoder = new TextDecoder();
let buffer = "";
let fullTranscript = "";
while (true) {
const { done, value } = await streamReader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const event = JSON.parse(line.slice(6));
switch (event.type) {
case "user_transcript":
callbacks.onUserTranscript?.(event.data);
break;
case "transcript":
fullTranscript += event.data;
callbacks.onTranscript?.(event.data, fullTranscript);
break;
case "audio":
playback.pushAudio(event.data);
break;
case "done":
playback.signalComplete();
callbacks.onComplete?.(fullTranscript);
break;
case "error":
throw new Error(event.error);
}
} catch (e) {
if (!(e instanceof SyntaxError)) {
callbacks.onError?.(e as Error);
}
}
}
}
},
[playback, callbacks]
);
return { streamVoiceResponse, playbackState: playback.state };
}

View File

@ -42,8 +42,8 @@ function HeroCard({ article }: { article: Article }) {
const isVideo = article.category === "Video";
return (
<Link href={`/article/${article.slug}`}>
<div className="relative group cursor-pointer rounded-lg overflow-hidden h-full" data-testid={`card-hero-${article.id}`}>
<div className="relative h-full min-h-[300px] md:min-h-[380px]">
<div className="relative group cursor-pointer rounded-lg overflow-hidden" data-testid={`card-hero-${article.id}`}>
<div className="relative aspect-[16/9]">
<img src={article.coverImage || "/images/article-1.png"} alt={article.title} className="w-full h-full object-cover absolute inset-0 transition-transform duration-700 group-hover:scale-105" style={{ objectPosition: "center 25%" }} loading="lazy" />
{isVideo && (
<div className="absolute inset-0 flex items-center justify-center z-10">
@ -77,8 +77,8 @@ function GalleryHeroCard({ images }: { images: GalleryImage[] }) {
return (
<Link href="/gallery">
<div className="relative group cursor-pointer rounded-lg overflow-hidden h-full" data-testid="card-hero-gallery">
<div className="relative h-full min-h-[300px] md:min-h-[380px]">
<div className="relative group cursor-pointer rounded-lg overflow-hidden" data-testid="card-hero-gallery">
<div className="relative aspect-[16/9]">
<img src={images[idx].large || images[idx].thumb} alt={images[idx].fileName} className="w-full h-full object-cover absolute inset-0 transition-opacity duration-1000" style={{ objectPosition: "center 25%" }} loading="lazy" />
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/30 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-5">

View File

@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link, useParams } from "wouter";
import {
Star,
@ -15,6 +16,7 @@ import {
Moon,
Sun,
ArrowRight,
Loader2,
} from "lucide-react";
import Header from "@/components/header";
import Footer from "@/components/footer";
@ -23,12 +25,24 @@ import {
SIGNS,
ELEMENT_COLORS,
ASTRO_EVENTS,
getHoroscope,
getHoroscope as getStaticHoroscope,
getRating,
getLuckyNumbers,
getDailyColor,
} from "@/lib/horoscope-data";
interface AIHoroscope {
signIndex: number;
signName: string;
general: string;
love: string;
career: string;
health: string;
tip: string;
weekly: string;
monthly: string;
}
function StarRating({ count }: { count: number }) {
return (
<div className="flex gap-0.5">
@ -103,7 +117,7 @@ function AstroEventsSection() {
);
}
function SignGrid({ onSelect, selectedIndex }: { onSelect: (i: number) => void; selectedIndex: number | null }) {
function SignGrid({ onSelect, selectedIndex, aiHoroscopes }: { onSelect: (i: number) => void; selectedIndex: number | null; aiHoroscopes: AIHoroscope[] }) {
return (
<section className="mb-10" data-testid="section-sign-grid">
<h2 className="text-lg font-bold text-foreground flex items-center gap-2 mb-4">
@ -113,7 +127,8 @@ function SignGrid({ onSelect, selectedIndex }: { onSelect: (i: number) => void;
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
{SIGNS.map((sign, i) => {
const ec = ELEMENT_COLORS[sign.element];
const horoscope = getHoroscope(i);
const aiH = aiHoroscopes.find(h => h.signIndex === i);
const horoscope = aiH || getStaticHoroscope(i);
const isSelected = selectedIndex === i;
return (
<button
@ -145,14 +160,16 @@ function SignGrid({ onSelect, selectedIndex }: { onSelect: (i: number) => void;
);
}
function SignDetail({ signIndex, onNavigate }: { signIndex: number; onNavigate: (i: number) => void }) {
function SignDetail({ signIndex, onNavigate, aiHoroscopes }: { signIndex: number; onNavigate: (i: number) => void; aiHoroscopes: AIHoroscope[] }) {
const [tab, setTab] = useState<"daily" | "weekly" | "monthly">("daily");
const sign = SIGNS[signIndex];
const ec = ELEMENT_COLORS[sign.element];
const horoscope = getHoroscope(signIndex);
const aiH = aiHoroscopes.find(h => h.signIndex === signIndex);
const horoscope = aiH || getStaticHoroscope(signIndex);
const luckyNums = getLuckyNumbers(signIndex);
const dailyColor = getDailyColor(signIndex);
const detailRef = useRef<HTMLDivElement>(null);
const isAI = !!aiH;
const prevIndex = (signIndex - 1 + SIGNS.length) % SIGNS.length;
const nextIndex = (signIndex + 1) % SIGNS.length;
@ -355,6 +372,10 @@ export default function HoroscopePage() {
const [selected, setSelected] = useState<number | null>(null);
const detailRef = useRef<HTMLDivElement>(null);
const { data: aiHoroscopes = [], isLoading: aiLoading } = useQuery<AIHoroscope[]>({
queryKey: ["/api/horoscopes/today"],
});
useEffect(() => {
if (params.sign) {
const idx = SIGNS.findIndex((s) => s.name.toLowerCase() === params.sign?.toLowerCase());
@ -388,13 +409,20 @@ export default function HoroscopePage() {
Entdecken Sie, was die Sterne für Sie bereithalten. Aktuelle kosmische Ereignisse und Ihr persönliches Tageshoroskop.
</p>
{aiLoading && (
<div className="flex items-center gap-2 mb-6 text-sm text-primary bg-primary/10 border border-primary/20 rounded-lg px-4 py-2" data-testid="text-horoscope-loading">
<Loader2 className="w-4 h-4 animate-spin" />
Horoskope werden von den Sternen gelesen...
</div>
)}
<AstroEventsSection />
<SignGrid onSelect={handleSelect} selectedIndex={selected} />
<SignGrid onSelect={handleSelect} selectedIndex={selected} aiHoroscopes={aiHoroscopes} />
<div ref={detailRef}>
{selected !== null ? (
<SignDetail signIndex={selected} onNavigate={handleSelect} />
<SignDetail signIndex={selected} onNavigate={handleSelect} aiHoroscopes={aiHoroscopes} />
) : (
<div className="text-center py-16 bg-card rounded-xl border border-card-border" data-testid="text-horoscope-prompt">
<Sparkles className="w-16 h-16 text-primary mx-auto mb-4" />

105
package-lock.json generated
View File

@ -49,7 +49,7 @@
"date-fns": "^3.6.0",
"dompurify": "^3.3.1",
"drizzle-orm": "^0.39.3",
"drizzle-zod": "^0.7.0",
"drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0",
"express": "^5.0.1",
"express-session": "^1.18.1",
@ -59,6 +59,9 @@
"memorystore": "^1.6.7",
"multer": "^2.1.0",
"next-themes": "^0.4.6",
"openai": "^6.25.0",
"p-limit": "^7.3.0",
"p-retry": "^7.1.1",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"pg": "^8.16.3",
@ -75,8 +78,8 @@
"vaul": "^1.1.2",
"wouter": "^3.3.5",
"ws": "^8.18.0",
"zod": "^3.24.2",
"zod-validation-error": "^3.4.0"
"zod": "^3.25.76",
"zod-validation-error": "^3.5.4"
},
"devDependencies": {
"@replit/vite-plugin-cartographer": "^0.4.4",
@ -4722,9 +4725,9 @@
}
},
"node_modules/drizzle-zod": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/drizzle-zod/-/drizzle-zod-0.7.0.tgz",
"integrity": "sha512-xgCRYYVEzRkeXTS33GSMgoowe3vKsMNBjSI+cwG1oLQVEhAWWbqtb/AAMlm7tkmV4fG/uJjEmWzdzlEmTgWOoQ==",
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/drizzle-zod/-/drizzle-zod-0.7.1.tgz",
"integrity": "sha512-nZzALOdz44/AL2U005UlmMqaQ1qe5JfanvLujiTHiiT8+vZJTBFhj3pY4Vk+L6UWyKFfNmLhk602Hn4kCTynKQ==",
"license": "Apache-2.0",
"peerDependencies": {
"drizzle-orm": ">=0.36.0",
@ -5505,6 +5508,18 @@
"node": ">=0.10.0"
}
},
"node_modules/is-network-error": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz",
"integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@ -6298,6 +6313,57 @@
"wrappy": "1"
}
},
"node_modules/openai": {
"version": "6.25.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.25.0.tgz",
"integrity": "sha512-mEh6VZ2ds2AGGokWARo18aPISI1OhlgdEIC1ewhkZr8pSIT31dec0ecr9Nhxx0JlybyOgoAT1sWeKtwPZzJyww==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/p-limit": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz",
"integrity": "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==",
"license": "MIT",
"dependencies": {
"yocto-queue": "^1.2.1"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-retry": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-7.1.1.tgz",
"integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==",
"license": "MIT",
"dependencies": {
"is-network-error": "^1.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@ -8840,24 +8906,37 @@
"node": ">= 14"
}
},
"node_modules/yocto-queue": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
"integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==",
"license": "MIT",
"engines": {
"node": ">=12.20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zod": {
"version": "3.24.2",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz",
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==",
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-validation-error": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.4.0.tgz",
"integrity": "sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==",
"version": "3.5.4",
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-3.5.4.tgz",
"integrity": "sha512-+hEiRIiPobgyuFlEojnqjJnhFvg4r/i3cqgcm67eehZf/WBaK3g6cD02YU9mtdVxZjv8CzCA9n/Rhrs3yAAvAw==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"zod": "^3.18.0"
"zod": "^3.24.4"
}
}
}

View File

@ -51,7 +51,7 @@
"date-fns": "^3.6.0",
"dompurify": "^3.3.1",
"drizzle-orm": "^0.39.3",
"drizzle-zod": "^0.7.0",
"drizzle-zod": "^0.7.1",
"embla-carousel-react": "^8.6.0",
"express": "^5.0.1",
"express-session": "^1.18.1",
@ -61,6 +61,9 @@
"memorystore": "^1.6.7",
"multer": "^2.1.0",
"next-themes": "^0.4.6",
"openai": "^6.25.0",
"p-limit": "^7.3.0",
"p-retry": "^7.1.1",
"passport": "^0.7.0",
"passport-local": "^1.0.0",
"pg": "^8.16.3",
@ -77,8 +80,8 @@
"vaul": "^1.1.2",
"wouter": "^3.3.5",
"ws": "^8.18.0",
"zod": "^3.24.2",
"zod-validation-error": "^3.4.0"
"zod": "^3.25.76",
"zod-validation-error": "^3.5.4"
},
"devDependencies": {
"@replit/vite-plugin-cartographer": "^0.4.4",

View File

@ -0,0 +1,113 @@
import OpenAI from "openai";
import { db } from "./db";
import { dailyHoroscopes } from "@shared/schema";
import { eq, and } from "drizzle-orm";
const openai = new OpenAI({
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
});
const SIGN_NAMES = [
"Widder", "Stier", "Zwillinge", "Krebs", "Löwe", "Jungfrau",
"Waage", "Skorpion", "Schütze", "Steinbock", "Wassermann", "Fische"
];
function getTodayStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export async function getHoroscopesForToday(): Promise<any[]> {
const today = getTodayStr();
const existing = await db.select().from(dailyHoroscopes).where(eq(dailyHoroscopes.dateStr, today));
if (existing.length === 12) return existing;
return [];
}
export async function generateDailyHoroscopes(): Promise<void> {
const today = getTodayStr();
const existing = await db.select().from(dailyHoroscopes).where(eq(dailyHoroscopes.dateStr, today));
if (existing.length >= 12) {
console.log(`Horoscopes for ${today} already exist.`);
return;
}
console.log(`Generating horoscopes for ${today}...`);
for (let i = 0; i < SIGN_NAMES.length; i++) {
const signName = SIGN_NAMES[i];
const alreadyExists = existing.find(h => h.signIndex === i);
if (alreadyExists) continue;
try {
const response = await openai.chat.completions.create({
model: "gpt-5-mini",
messages: [
{
role: "system",
content: `Du bist ein erfahrener Astrologe, der tägliche Horoskope für eine deutschsprachige Volksmusik- und Schlager-Nachrichtenwebsite schreibt. Dein Stil ist warm, ermutigend und poetisch. Du beziehst manchmal Musik, Natur und alpine Kultur in deine Texte ein. Schreibe immer auf Deutsch. Das heutige Datum ist ${today}.`
},
{
role: "user",
content: `Erstelle ein ausführliches Tageshoroskop für das Sternzeichen ${signName} für heute (${today}).
Antworte NUR mit einem JSON-Objekt in diesem exakten Format (kein Markdown, keine Erklärung):
{
"general": "Ausführlicher allgemeiner Tagestext, mindestens 4-5 Sätze über die allgemeine Energie, Stimmung und Möglichkeiten des Tages.",
"love": "Ausführlicher Text über Liebe und Partnerschaft, mindestens 3-4 Sätze mit konkreten Ratschlägen für Singles und Paare.",
"career": "Ausführlicher Text über Beruf und Finanzen, mindestens 3-4 Sätze mit konkreten Tipps.",
"health": "Ausführlicher Text über Gesundheit und Wohlbefinden, mindestens 3-4 Sätze.",
"tip": "Ein konkreter, umsetzbarer Tipp des Tages in 1-2 Sätzen.",
"weekly": "Ausführliche Wochenvorschau, mindestens 4-5 Sätze mit Hinweisen für jeden Wochentag.",
"monthly": "Ausführliche Monatsvorschau, mindestens 4-5 Sätze über die wichtigsten Themen des Monats."
}`
}
],
temperature: 0.9,
max_tokens: 2000,
});
const content = response.choices[0]?.message?.content || "";
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
console.error(`Failed to parse horoscope for ${signName}`);
continue;
}
const parsed = JSON.parse(jsonMatch[0]);
await db.insert(dailyHoroscopes).values({
signIndex: i,
signName,
dateStr: today,
general: parsed.general || "",
love: parsed.love || "",
career: parsed.career || "",
health: parsed.health || "",
tip: parsed.tip || "",
weekly: parsed.weekly || "",
monthly: parsed.monthly || "",
});
console.log(`Generated horoscope for ${signName}`);
} catch (err: any) {
console.error(`Error generating horoscope for ${signName}:`, err.message);
}
}
console.log(`Horoscope generation complete for ${today}.`);
}
export async function getOrGenerateHoroscope(signIndex: number): Promise<any | null> {
const today = getTodayStr();
const [existing] = await db.select().from(dailyHoroscopes)
.where(and(eq(dailyHoroscopes.dateStr, today), eq(dailyHoroscopes.signIndex, signIndex)));
if (existing) return existing;
return null;
}

View File

@ -0,0 +1,274 @@
import OpenAI, { toFile } from "openai";
import { Buffer } from "node:buffer";
import { spawn } from "child_process";
import { writeFile, unlink, readFile } from "fs/promises";
import { randomUUID } from "crypto";
import { tmpdir } from "os";
import { join } from "path";
export const openai = new OpenAI({
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
});
export type AudioFormat = "wav" | "mp3" | "webm" | "mp4" | "ogg" | "unknown";
/**
* Detect audio format from buffer magic bytes.
* Supports: WAV, MP3, WebM (Chrome/Firefox), MP4/M4A/MOV (Safari/iOS), OGG
*/
export function detectAudioFormat(buffer: Buffer): AudioFormat {
if (buffer.length < 12) return "unknown";
// WAV: RIFF....WAVE
if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46) {
return "wav";
}
// WebM: EBML header
if (buffer[0] === 0x1a && buffer[1] === 0x45 && buffer[2] === 0xdf && buffer[3] === 0xa3) {
return "webm";
}
// MP3: ID3 tag or frame sync
if (
(buffer[0] === 0xff && (buffer[1] === 0xfb || buffer[1] === 0xfa || buffer[1] === 0xf3)) ||
(buffer[0] === 0x49 && buffer[1] === 0x44 && buffer[2] === 0x33)
) {
return "mp3";
}
// MP4/M4A/MOV: ....ftyp (Safari/iOS records in these containers)
if (buffer[4] === 0x66 && buffer[5] === 0x74 && buffer[6] === 0x79 && buffer[7] === 0x70) {
return "mp4";
}
// OGG: OggS
if (buffer[0] === 0x4f && buffer[1] === 0x67 && buffer[2] === 0x67 && buffer[3] === 0x53) {
return "ogg";
}
return "unknown";
}
/**
* Convert any audio/video format to WAV using ffmpeg.
* Uses temp files instead of pipes because video containers (MP4/MOV)
* require seeking to find the audio track.
*/
export async function convertToWav(audioBuffer: Buffer): Promise<Buffer> {
const inputPath = join(tmpdir(), `input-${randomUUID()}`);
const outputPath = join(tmpdir(), `output-${randomUUID()}.wav`);
try {
// Write input to temp file (required for video containers that need seeking)
await writeFile(inputPath, audioBuffer);
// Run ffmpeg with file paths
await new Promise<void>((resolve, reject) => {
const ffmpeg = spawn("ffmpeg", [
"-i", inputPath,
"-vn", // Extract audio only (ignore video track)
"-f", "wav",
"-ar", "16000", // 16kHz sample rate (good for speech)
"-ac", "1", // Mono
"-acodec", "pcm_s16le",
"-y", // Overwrite output
outputPath,
]);
ffmpeg.stderr.on("data", () => {}); // Suppress logs
ffmpeg.on("close", (code) => {
if (code === 0) resolve();
else reject(new Error(`ffmpeg exited with code ${code}`));
});
ffmpeg.on("error", reject);
});
// Read converted audio
return await readFile(outputPath);
} finally {
// Clean up temp files
await unlink(inputPath).catch(() => {});
await unlink(outputPath).catch(() => {});
}
}
/**
* Auto-detect and convert audio to OpenAI-compatible format.
* - WAV/MP3: Pass through (already compatible)
* - WebM/MP4/OGG: Convert to WAV via ffmpeg
*/
export async function ensureCompatibleFormat(
audioBuffer: Buffer
): Promise<{ buffer: Buffer; format: "wav" | "mp3" }> {
const detected = detectAudioFormat(audioBuffer);
if (detected === "wav") return { buffer: audioBuffer, format: "wav" };
if (detected === "mp3") return { buffer: audioBuffer, format: "mp3" };
// Convert WebM, MP4, OGG, or unknown to WAV
const wavBuffer = await convertToWav(audioBuffer);
return { buffer: wavBuffer, format: "wav" };
}
/**
* Voice Chat: User speaks, LLM responds with audio (audio-in, audio-out).
* Uses gpt-audio model via Replit AI Integrations.
* Note: Browser records WebM/opus - convert to WAV using ffmpeg before calling this.
*/
export async function voiceChat(
audioBuffer: Buffer,
voice: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer" = "alloy",
inputFormat: "wav" | "mp3" = "wav",
outputFormat: "wav" | "mp3" = "mp3"
): Promise<{ transcript: string; audioResponse: Buffer }> {
const audioBase64 = audioBuffer.toString("base64");
const response = await openai.chat.completions.create({
model: "gpt-audio",
modalities: ["text", "audio"],
audio: { voice, format: outputFormat },
messages: [{
role: "user",
content: [
{ type: "input_audio", input_audio: { data: audioBase64, format: inputFormat } },
],
}],
});
const message = response.choices[0]?.message as any;
const transcript = message?.audio?.transcript || message?.content || "";
const audioData = message?.audio?.data ?? "";
return {
transcript,
audioResponse: Buffer.from(audioData, "base64"),
};
}
/**
* Streaming Voice Chat: For real-time audio responses.
* Note: Streaming only supports pcm16 output format.
*
* @example
* // Converting browser WebM to WAV before calling:
* const webmBuffer = Buffer.from(req.body.audio, "base64");
* const wavBuffer = await convertWebmToWav(webmBuffer);
* for await (const chunk of voiceChatStream(wavBuffer)) { ... }
*/
export async function voiceChatStream(
audioBuffer: Buffer,
voice: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer" = "alloy",
inputFormat: "wav" | "mp3" = "wav"
): Promise<AsyncIterable<{ type: "transcript" | "audio"; data: string }>> {
const audioBase64 = audioBuffer.toString("base64");
const stream = await openai.chat.completions.create({
model: "gpt-audio",
modalities: ["text", "audio"],
audio: { voice, format: "pcm16" },
messages: [{
role: "user",
content: [
{ type: "input_audio", input_audio: { data: audioBase64, format: inputFormat } },
],
}],
stream: true,
});
return (async function* () {
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta as any;
if (!delta) continue;
if (delta?.audio?.transcript) {
yield { type: "transcript", data: delta.audio.transcript };
}
if (delta?.audio?.data) {
yield { type: "audio", data: delta.audio.data };
}
}
})();
}
/**
* Text-to-Speech: Converts text to speech verbatim.
* Uses gpt-audio model via Replit AI Integrations.
*/
export async function textToSpeech(
text: string,
voice: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer" = "alloy",
format: "wav" | "mp3" | "flac" | "opus" | "pcm16" = "wav"
): Promise<Buffer> {
const response = await openai.chat.completions.create({
model: "gpt-audio",
modalities: ["text", "audio"],
audio: { voice, format },
messages: [
{ role: "system", content: "You are an assistant that performs text-to-speech." },
{ role: "user", content: `Repeat the following text verbatim: ${text}` },
],
});
const audioData = (response.choices[0]?.message as any)?.audio?.data ?? "";
return Buffer.from(audioData, "base64");
}
/**
* Streaming Text-to-Speech: Converts text to speech with real-time streaming.
* Uses gpt-audio model via Replit AI Integrations.
* Note: Streaming only supports pcm16 output format.
*/
export async function textToSpeechStream(
text: string,
voice: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer" = "alloy"
): Promise<AsyncIterable<string>> {
const stream = await openai.chat.completions.create({
model: "gpt-audio",
modalities: ["text", "audio"],
audio: { voice, format: "pcm16" },
messages: [
{ role: "system", content: "You are an assistant that performs text-to-speech." },
{ role: "user", content: `Repeat the following text verbatim: ${text}` },
],
stream: true,
});
return (async function* () {
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta as any;
if (!delta) continue;
if (delta?.audio?.data) {
yield delta.audio.data;
}
}
})();
}
/**
* Speech-to-Text: Transcribes audio using dedicated transcription model.
* Uses gpt-4o-mini-transcribe for accurate transcription.
*/
export async function speechToText(
audioBuffer: Buffer,
format: "wav" | "mp3" | "webm" = "wav"
): Promise<string> {
const file = await toFile(audioBuffer, `audio.${format}`);
const response = await openai.audio.transcriptions.create({
file,
model: "gpt-4o-mini-transcribe",
});
return response.text;
}
/**
* Streaming Speech-to-Text: Transcribes audio with real-time streaming.
* Uses gpt-4o-mini-transcribe for accurate transcription.
*/
export async function speechToTextStream(
audioBuffer: Buffer,
format: "wav" | "mp3" | "webm" = "wav"
): Promise<AsyncIterable<string>> {
const file = await toFile(audioBuffer, `audio.${format}`);
const stream = await openai.audio.transcriptions.create({
file,
model: "gpt-4o-mini-transcribe",
stream: true,
});
return (async function* () {
for await (const event of stream) {
if (event.type === "transcript.text.delta") {
yield event.delta;
}
}
})();
}

View File

@ -0,0 +1,14 @@
export { registerAudioRoutes } from "./routes";
export {
openai,
detectAudioFormat,
convertToWav,
ensureCompatibleFormat,
type AudioFormat,
voiceChat,
voiceChatStream,
textToSpeech,
textToSpeechStream,
speechToText,
speechToTextStream,
} from "./client";

View File

@ -0,0 +1,136 @@
import express, { type Express, type Request, type Response } from "express";
import { chatStorage } from "../chat/storage";
import { openai, speechToText, ensureCompatibleFormat } from "./client";
// Body parser with 50MB limit for audio payloads
const audioBodyParser = express.json({ limit: "50mb" });
export function registerAudioRoutes(app: Express): void {
// Get all conversations
app.get("/api/conversations", async (req: Request, res: Response) => {
try {
const conversations = await chatStorage.getAllConversations();
res.json(conversations);
} catch (error) {
console.error("Error fetching conversations:", error);
res.status(500).json({ error: "Failed to fetch conversations" });
}
});
// Get single conversation with messages
app.get("/api/conversations/:id", async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id);
const conversation = await chatStorage.getConversation(id);
if (!conversation) {
return res.status(404).json({ error: "Conversation not found" });
}
const messages = await chatStorage.getMessagesByConversation(id);
res.json({ ...conversation, messages });
} catch (error) {
console.error("Error fetching conversation:", error);
res.status(500).json({ error: "Failed to fetch conversation" });
}
});
// Create new conversation
app.post("/api/conversations", async (req: Request, res: Response) => {
try {
const { title } = req.body;
const conversation = await chatStorage.createConversation(title || "New Chat");
res.status(201).json(conversation);
} catch (error) {
console.error("Error creating conversation:", error);
res.status(500).json({ error: "Failed to create conversation" });
}
});
// Delete conversation
app.delete("/api/conversations/:id", async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id);
await chatStorage.deleteConversation(id);
res.status(204).send();
} catch (error) {
console.error("Error deleting conversation:", error);
res.status(500).json({ error: "Failed to delete conversation" });
}
});
// Send voice message and get streaming audio response
// Auto-detects audio format and converts WebM/MP4/OGG to WAV
// Uses gpt-4o-mini-transcribe for STT, gpt-audio for voice response
app.post("/api/conversations/:id/messages", audioBodyParser, async (req: Request, res: Response) => {
try {
const conversationId = parseInt(req.params.id);
const { audio, voice = "alloy" } = req.body;
if (!audio) {
return res.status(400).json({ error: "Audio data (base64) is required" });
}
// 1. Auto-detect format and convert to OpenAI-compatible format
const rawBuffer = Buffer.from(audio, "base64");
const { buffer: audioBuffer, format: inputFormat } = await ensureCompatibleFormat(rawBuffer);
// 2. Transcribe user audio
const userTranscript = await speechToText(audioBuffer, inputFormat);
// 3. Save user message
await chatStorage.createMessage(conversationId, "user", userTranscript);
// 4. Get conversation history
const existingMessages = await chatStorage.getMessagesByConversation(conversationId);
const chatHistory = existingMessages.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
}));
// 5. Set up SSE
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.write(`data: ${JSON.stringify({ type: "user_transcript", data: userTranscript })}\n\n`);
// 6. Stream audio response from gpt-audio
const stream = await openai.chat.completions.create({
model: "gpt-audio",
modalities: ["text", "audio"],
audio: { voice, format: "pcm16" },
messages: chatHistory,
stream: true,
});
let assistantTranscript = "";
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta as any;
if (!delta) continue;
if (delta?.audio?.transcript) {
assistantTranscript += delta.audio.transcript;
res.write(`data: ${JSON.stringify({ type: "transcript", data: delta.audio.transcript })}\n\n`);
}
if (delta?.audio?.data) {
res.write(`data: ${JSON.stringify({ type: "audio", data: delta.audio.data })}\n\n`);
}
}
// 7. Save assistant message
await chatStorage.createMessage(conversationId, "assistant", assistantTranscript);
res.write(`data: ${JSON.stringify({ type: "done", transcript: assistantTranscript })}\n\n`);
res.end();
} catch (error) {
console.error("Error processing voice message:", error);
if (res.headersSent) {
res.write(`data: ${JSON.stringify({ type: "error", error: "Failed to process voice message" })}\n\n`);
res.end();
} else {
res.status(500).json({ error: "Failed to process voice message" });
}
}
});
}

View File

@ -0,0 +1,7 @@
export {
batchProcess,
batchProcessWithSSE,
isRateLimitError,
type BatchOptions,
} from "./utils";

View File

@ -0,0 +1,182 @@
import pLimit from "p-limit";
import pRetry from "p-retry";
/**
* Batch Processing Utilities
*
* This module provides a generic batch processing function with built-in
* rate limiting and automatic retries. Use it for any task that requires
* processing multiple items through an LLM or external API.
*
* USAGE:
* ```typescript
* import { batchProcess, isRateLimitError } from "./replit_integrations/batch";
*
* const results = await batchProcess(
* artworks,
* async (artwork) => {
* // Your custom LLM logic here
* const response = await openai.chat.completions.create({
* model: "gpt-5.1",
* messages: [{ role: "user", content: `Categorize: ${artwork.name}` }],
* response_format: { type: "json_object" },
* });
* return JSON.parse(response.choices[0]?.message?.content || "{}");
* },
* { concurrency: 2, retries: 5 }
* );
* ```
*/
export interface BatchOptions {
/** Max concurrent requests (default: 2) */
concurrency?: number;
/** Max retry attempts for rate limit errors (default: 7) */
retries?: number;
/** Initial retry delay in ms (default: 2000) */
minTimeout?: number;
/** Max retry delay in ms (default: 128000) */
maxTimeout?: number;
/** Callback for progress updates */
onProgress?: (completed: number, total: number, item: unknown) => void;
}
/**
* Check if an error is a rate limit or quota violation.
* Use this in custom error handling if needed.
*/
export function isRateLimitError(error: unknown): boolean {
const errorMsg = error instanceof Error ? error.message : String(error);
return (
errorMsg.includes("429") ||
errorMsg.includes("RATELIMIT_EXCEEDED") ||
errorMsg.toLowerCase().includes("quota") ||
errorMsg.toLowerCase().includes("rate limit")
);
}
/**
* Process items in batches with rate limiting and automatic retries.
*
* @param items - Array of items to process
* @param processor - Async function to process each item (write your LLM logic here)
* @param options - Concurrency and retry settings
* @returns Promise resolving to array of results in the same order as input
*
* @example
* // Process CSV artwork data with custom categorization
* const categorized = await batchProcess(
* csvRows,
* async (row) => {
* const response = await openai.chat.completions.create({
* model: "gpt-5.1", // the newest OpenAI model
* messages: [{ role: "user", content: `Categorize artwork: ${row.name}` }],
* response_format: { type: "json_object" },
* });
* return { ...row, category: JSON.parse(response.choices[0]?.message?.content || "{}") };
* }
* );
*/
export async function batchProcess<T, R>(
items: T[],
processor: (item: T, index: number) => Promise<R>,
options: BatchOptions = {}
): Promise<R[]> {
const {
concurrency = 2,
retries = 7,
minTimeout = 2000,
maxTimeout = 128000,
onProgress,
} = options;
const limit = pLimit(concurrency);
let completed = 0;
const promises = items.map((item, index) =>
limit(() =>
pRetry(
async () => {
try {
const result = await processor(item, index);
completed++;
onProgress?.(completed, items.length, item);
return result;
} catch (error: unknown) {
if (isRateLimitError(error)) {
throw error; // Rethrow to trigger p-retry
}
// For non-rate-limit errors, abort immediately
throw new pRetry.AbortError(
error instanceof Error ? error : new Error(String(error))
);
}
},
{ retries, minTimeout, maxTimeout, factor: 2 }
)
)
);
return Promise.all(promises);
}
/**
* Process items sequentially with SSE progress streaming.
* Use this when you need real-time progress updates to the client.
*
* @param items - Array of items to process
* @param processor - Async function to process each item
* @param sendEvent - Function to send SSE events to the client
* @param options - Retry settings (concurrency is always 1 for sequential)
*/
export async function batchProcessWithSSE<T, R>(
items: T[],
processor: (item: T, index: number) => Promise<R>,
sendEvent: (event: { type: string; [key: string]: unknown }) => void,
options: Omit<BatchOptions, "concurrency" | "onProgress"> = {}
): Promise<R[]> {
const { retries = 5, minTimeout = 1000, maxTimeout = 15000 } = options;
sendEvent({ type: "started", total: items.length });
const results: R[] = [];
let errors = 0;
for (let index = 0; index < items.length; index++) {
const item = items[index];
sendEvent({ type: "processing", index, item });
try {
const result = await pRetry(
() => processor(item, index),
{
retries,
minTimeout,
maxTimeout,
factor: 2,
onFailedAttempt: (error) => {
if (!isRateLimitError(error)) {
throw new pRetry.AbortError(
error instanceof Error ? error : new Error(String(error))
);
}
},
}
);
results.push(result);
sendEvent({ type: "progress", index, result });
} catch (error) {
errors++;
results.push(undefined as R); // Placeholder for failed items
sendEvent({
type: "progress",
index,
error: error instanceof Error ? error.message : "Processing failed",
});
}
}
sendEvent({ type: "complete", processed: items.length, errors });
return results;
}

View File

@ -0,0 +1,3 @@
export { registerChatRoutes } from "./routes";
export { chatStorage, type IChatStorage } from "./storage";

View File

@ -0,0 +1,118 @@
import type { Express, Request, Response } from "express";
import OpenAI from "openai";
import { chatStorage } from "./storage";
const openai = new OpenAI({
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
});
export function registerChatRoutes(app: Express): void {
// Get all conversations
app.get("/api/conversations", async (req: Request, res: Response) => {
try {
const conversations = await chatStorage.getAllConversations();
res.json(conversations);
} catch (error) {
console.error("Error fetching conversations:", error);
res.status(500).json({ error: "Failed to fetch conversations" });
}
});
// Get single conversation with messages
app.get("/api/conversations/:id", async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id);
const conversation = await chatStorage.getConversation(id);
if (!conversation) {
return res.status(404).json({ error: "Conversation not found" });
}
const messages = await chatStorage.getMessagesByConversation(id);
res.json({ ...conversation, messages });
} catch (error) {
console.error("Error fetching conversation:", error);
res.status(500).json({ error: "Failed to fetch conversation" });
}
});
// Create new conversation
app.post("/api/conversations", async (req: Request, res: Response) => {
try {
const { title } = req.body;
const conversation = await chatStorage.createConversation(title || "New Chat");
res.status(201).json(conversation);
} catch (error) {
console.error("Error creating conversation:", error);
res.status(500).json({ error: "Failed to create conversation" });
}
});
// Delete conversation
app.delete("/api/conversations/:id", async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id);
await chatStorage.deleteConversation(id);
res.status(204).send();
} catch (error) {
console.error("Error deleting conversation:", error);
res.status(500).json({ error: "Failed to delete conversation" });
}
});
// Send message and get AI response (streaming)
app.post("/api/conversations/:id/messages", async (req: Request, res: Response) => {
try {
const conversationId = parseInt(req.params.id);
const { content } = req.body;
// Save user message
await chatStorage.createMessage(conversationId, "user", content);
// Get conversation history for context
const messages = await chatStorage.getMessagesByConversation(conversationId);
const chatMessages = messages.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
}));
// Set up SSE
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
// Stream response from OpenAI
const stream = await openai.chat.completions.create({
model: "gpt-5.1",
messages: chatMessages,
stream: true,
max_completion_tokens: 8192,
});
let fullResponse = "";
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || "";
if (content) {
fullResponse += content;
res.write(`data: ${JSON.stringify({ content })}\n\n`);
}
}
// Save assistant message
await chatStorage.createMessage(conversationId, "assistant", fullResponse);
res.write(`data: ${JSON.stringify({ done: true })}\n\n`);
res.end();
} catch (error) {
console.error("Error sending message:", error);
// Check if headers already sent (SSE streaming started)
if (res.headersSent) {
res.write(`data: ${JSON.stringify({ error: "Failed to send message" })}\n\n`);
res.end();
} else {
res.status(500).json({ error: "Failed to send message" });
}
}
});
}

View File

@ -0,0 +1,43 @@
import { db } from "../../db";
import { conversations, messages } from "@shared/schema";
import { eq, desc } from "drizzle-orm";
export interface IChatStorage {
getConversation(id: number): Promise<typeof conversations.$inferSelect | undefined>;
getAllConversations(): Promise<(typeof conversations.$inferSelect)[]>;
createConversation(title: string): Promise<typeof conversations.$inferSelect>;
deleteConversation(id: number): Promise<void>;
getMessagesByConversation(conversationId: number): Promise<(typeof messages.$inferSelect)[]>;
createMessage(conversationId: number, role: string, content: string): Promise<typeof messages.$inferSelect>;
}
export const chatStorage: IChatStorage = {
async getConversation(id: number) {
const [conversation] = await db.select().from(conversations).where(eq(conversations.id, id));
return conversation;
},
async getAllConversations() {
return db.select().from(conversations).orderBy(desc(conversations.createdAt));
},
async createConversation(title: string) {
const [conversation] = await db.insert(conversations).values({ title }).returning();
return conversation;
},
async deleteConversation(id: number) {
await db.delete(messages).where(eq(messages.conversationId, id));
await db.delete(conversations).where(eq(conversations.id, id));
},
async getMessagesByConversation(conversationId: number) {
return db.select().from(messages).where(eq(messages.conversationId, conversationId)).orderBy(messages.createdAt);
},
async createMessage(conversationId: number, role: string, content: string) {
const [message] = await db.insert(messages).values({ conversationId, role, content }).returning();
return message;
},
};

View File

@ -0,0 +1,59 @@
import fs from "node:fs";
import OpenAI, { toFile } from "openai";
import { Buffer } from "node:buffer";
export const openai = new OpenAI({
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
});
/**
* Generate an image and return as Buffer.
* Uses gpt-image-1 model via Replit AI Integrations.
*/
export async function generateImageBuffer(
prompt: string,
size: "1024x1024" | "512x512" | "256x256" = "1024x1024"
): Promise<Buffer> {
const response = await openai.images.generate({
model: "gpt-image-1",
prompt,
size,
});
const base64 = response.data[0]?.b64_json ?? "";
return Buffer.from(base64, "base64");
}
/**
* Edit/combine multiple images into a composite.
* Uses gpt-image-1 model via Replit AI Integrations.
*/
export async function editImages(
imageFiles: string[],
prompt: string,
outputPath?: string
): Promise<Buffer> {
const images = await Promise.all(
imageFiles.map((file) =>
toFile(fs.createReadStream(file), file, {
type: "image/png",
})
)
);
const response = await openai.images.edit({
model: "gpt-image-1",
image: images,
prompt,
});
const imageBase64 = response.data[0]?.b64_json ?? "";
const imageBytes = Buffer.from(imageBase64, "base64");
if (outputPath) {
fs.writeFileSync(outputPath, imageBytes);
}
return imageBytes;
}

View File

@ -0,0 +1,3 @@
export { registerImageRoutes } from "./routes";
export { openai, generateImageBuffer, editImages } from "./client";

View File

@ -0,0 +1,31 @@
import type { Express, Request, Response } from "express";
import { openai } from "./client";
export function registerImageRoutes(app: Express): void {
app.post("/api/generate-image", async (req: Request, res: Response) => {
try {
const { prompt, size = "1024x1024" } = req.body;
if (!prompt) {
return res.status(400).json({ error: "Prompt is required" });
}
const response = await openai.images.generate({
model: "gpt-image-1",
prompt,
n: 1,
size: size as "1024x1024" | "512x512" | "256x256",
});
const imageData = response.data[0];
res.json({
url: imageData.url,
b64_json: imageData.b64_json,
});
} catch (error) {
console.error("Error generating image:", error);
res.status(500).json({ error: "Failed to generate image" });
}
});
}

View File

@ -3,6 +3,7 @@ import { createServer, type Server } from "http";
import { storage } from "./storage";
import { insertArticleSchema } from "@shared/schema";
import { seedDatabase } from "./seed";
import { generateDailyHoroscopes, getHoroscopesForToday, getOrGenerateHoroscope } from "./horoscope-generator";
import multer from "multer";
import path from "path";
import fs from "fs";
@ -37,6 +38,10 @@ export async function registerRoutes(
): Promise<Server> {
await seedDatabase();
generateDailyHoroscopes().catch((err) =>
console.error("Background horoscope generation failed:", err.message)
);
app.get("/api/articles", async (_req, res) => {
const articles = await storage.getArticles();
res.json(articles);
@ -189,6 +194,39 @@ export async function registerRoutes(
}
});
// Horoscope API
app.get("/api/horoscopes/today", async (_req, res) => {
try {
const horoscopes = await getHoroscopesForToday();
res.json(horoscopes);
} catch (err: any) {
res.status(500).json({ message: err.message });
}
});
app.get("/api/horoscopes/sign/:index", async (req, res) => {
try {
const signIndex = parseInt(req.params.index);
if (isNaN(signIndex) || signIndex < 0 || signIndex > 11) {
return res.status(400).json({ message: "Invalid sign index" });
}
const horoscope = await getOrGenerateHoroscope(signIndex);
res.json(horoscope);
} catch (err: any) {
res.status(500).json({ message: err.message });
}
});
app.post("/api/horoscopes/generate", async (_req, res) => {
try {
await generateDailyHoroscopes();
const horoscopes = await getHoroscopesForToday();
res.json({ generated: horoscopes.length, horoscopes });
} catch (err: any) {
res.status(500).json({ message: err.message });
}
});
// News feed - Volksmusik/Schlager news from Google News RSS
app.get("/api/news-feed", async (_req, res) => {
try {

34
shared/models/chat.ts Normal file
View File

@ -0,0 +1,34 @@
import { pgTable, serial, integer, text, timestamp } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod";
import { sql } from "drizzle-orm";
export const conversations = pgTable("conversations", {
id: serial("id").primaryKey(),
title: text("title").notNull(),
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
});
export const messages = pgTable("messages", {
id: serial("id").primaryKey(),
conversationId: integer("conversation_id").notNull().references(() => conversations.id, { onDelete: "cascade" }),
role: text("role").notNull(),
content: text("content").notNull(),
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
});
export const insertConversationSchema = createInsertSchema(conversations).omit({
id: true,
createdAt: true,
});
export const insertMessageSchema = createInsertSchema(messages).omit({
id: true,
createdAt: true,
});
export type Conversation = typeof conversations.$inferSelect;
export type InsertConversation = z.infer<typeof insertConversationSchema>;
export type Message = typeof messages.$inferSelect;
export type InsertMessage = z.infer<typeof insertMessageSchema>;

View File

@ -26,6 +26,23 @@ export const insertArticleSchema = createInsertSchema(articles).omit({
export type InsertArticle = z.infer<typeof insertArticleSchema>;
export type Article = typeof articles.$inferSelect;
export const dailyHoroscopes = pgTable("daily_horoscopes", {
id: serial("id").primaryKey(),
signIndex: integer("sign_index").notNull(),
signName: varchar("sign_name", { length: 50 }).notNull(),
dateStr: varchar("date_str", { length: 10 }).notNull(),
general: text("general").notNull(),
love: text("love").notNull(),
career: text("career").notNull(),
health: text("health").notNull(),
tip: text("tip").notNull(),
weekly: text("weekly").notNull(),
monthly: text("monthly").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export type DailyHoroscope = typeof dailyHoroscopes.$inferSelect;
export const users = pgTable("users", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
username: text("username").notNull().unique(),