diff --git a/package-lock.json b/package-lock.json
index 29f8d8a..cb61a18 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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"
}
}
}
diff --git a/package.json b/package.json
index ce43f55..cfaa9d3 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/server/horoscope-generator.ts b/server/horoscope-generator.ts
new file mode 100644
index 0000000..e96781b
--- /dev/null
+++ b/server/horoscope-generator.ts
@@ -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
{
+ 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 {
+ 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 {
+ 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;
+}
diff --git a/server/replit_integrations/audio/client.ts b/server/replit_integrations/audio/client.ts
new file mode 100644
index 0000000..d00642e
--- /dev/null
+++ b/server/replit_integrations/audio/client.ts
@@ -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 {
+ 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((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> {
+ 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 {
+ 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> {
+ 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 {
+ 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> {
+ 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;
+ }
+ }
+ })();
+}
diff --git a/server/replit_integrations/audio/index.ts b/server/replit_integrations/audio/index.ts
new file mode 100644
index 0000000..8d2a257
--- /dev/null
+++ b/server/replit_integrations/audio/index.ts
@@ -0,0 +1,14 @@
+export { registerAudioRoutes } from "./routes";
+export {
+ openai,
+ detectAudioFormat,
+ convertToWav,
+ ensureCompatibleFormat,
+ type AudioFormat,
+ voiceChat,
+ voiceChatStream,
+ textToSpeech,
+ textToSpeechStream,
+ speechToText,
+ speechToTextStream,
+} from "./client";
diff --git a/server/replit_integrations/audio/routes.ts b/server/replit_integrations/audio/routes.ts
new file mode 100644
index 0000000..b6e2421
--- /dev/null
+++ b/server/replit_integrations/audio/routes.ts
@@ -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" });
+ }
+ }
+ });
+}
diff --git a/server/replit_integrations/batch/index.ts b/server/replit_integrations/batch/index.ts
new file mode 100644
index 0000000..4d7efd0
--- /dev/null
+++ b/server/replit_integrations/batch/index.ts
@@ -0,0 +1,7 @@
+export {
+ batchProcess,
+ batchProcessWithSSE,
+ isRateLimitError,
+ type BatchOptions,
+} from "./utils";
+
diff --git a/server/replit_integrations/batch/utils.ts b/server/replit_integrations/batch/utils.ts
new file mode 100644
index 0000000..ee594d9
--- /dev/null
+++ b/server/replit_integrations/batch/utils.ts
@@ -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(
+ items: T[],
+ processor: (item: T, index: number) => Promise,
+ options: BatchOptions = {}
+): Promise {
+ 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(
+ items: T[],
+ processor: (item: T, index: number) => Promise,
+ sendEvent: (event: { type: string; [key: string]: unknown }) => void,
+ options: Omit = {}
+): Promise {
+ 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;
+}
+
diff --git a/server/replit_integrations/chat/index.ts b/server/replit_integrations/chat/index.ts
new file mode 100644
index 0000000..822d8f7
--- /dev/null
+++ b/server/replit_integrations/chat/index.ts
@@ -0,0 +1,3 @@
+export { registerChatRoutes } from "./routes";
+export { chatStorage, type IChatStorage } from "./storage";
+
diff --git a/server/replit_integrations/chat/routes.ts b/server/replit_integrations/chat/routes.ts
new file mode 100644
index 0000000..fd74f8d
--- /dev/null
+++ b/server/replit_integrations/chat/routes.ts
@@ -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" });
+ }
+ }
+ });
+}
+
diff --git a/server/replit_integrations/chat/storage.ts b/server/replit_integrations/chat/storage.ts
new file mode 100644
index 0000000..3fd72c4
--- /dev/null
+++ b/server/replit_integrations/chat/storage.ts
@@ -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;
+ getAllConversations(): Promise<(typeof conversations.$inferSelect)[]>;
+ createConversation(title: string): Promise;
+ deleteConversation(id: number): Promise;
+ getMessagesByConversation(conversationId: number): Promise<(typeof messages.$inferSelect)[]>;
+ createMessage(conversationId: number, role: string, content: string): Promise;
+}
+
+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;
+ },
+};
+
diff --git a/server/replit_integrations/image/client.ts b/server/replit_integrations/image/client.ts
new file mode 100644
index 0000000..bb5bc8a
--- /dev/null
+++ b/server/replit_integrations/image/client.ts
@@ -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 {
+ 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 {
+ 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;
+}
+
diff --git a/server/replit_integrations/image/index.ts b/server/replit_integrations/image/index.ts
new file mode 100644
index 0000000..2ad0d29
--- /dev/null
+++ b/server/replit_integrations/image/index.ts
@@ -0,0 +1,3 @@
+export { registerImageRoutes } from "./routes";
+export { openai, generateImageBuffer, editImages } from "./client";
+
diff --git a/server/replit_integrations/image/routes.ts b/server/replit_integrations/image/routes.ts
new file mode 100644
index 0000000..a62fbae
--- /dev/null
+++ b/server/replit_integrations/image/routes.ts
@@ -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" });
+ }
+ });
+}
+
diff --git a/server/routes.ts b/server/routes.ts
index 87e4a7d..40fbdd2 100644
--- a/server/routes.ts
+++ b/server/routes.ts
@@ -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 {
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 {
diff --git a/shared/models/chat.ts b/shared/models/chat.ts
new file mode 100644
index 0000000..54c94f4
--- /dev/null
+++ b/shared/models/chat.ts
@@ -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;
+export type Message = typeof messages.$inferSelect;
+export type InsertMessage = z.infer;
+
diff --git a/shared/schema.ts b/shared/schema.ts
index 5c78109..1171776 100644
--- a/shared/schema.ts
+++ b/shared/schema.ts
@@ -26,6 +26,23 @@ export const insertArticleSchema = createInsertSchema(articles).omit({
export type InsertArticle = z.infer;
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(),