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:
parent
774a72bc22
commit
308e602c73
3
.replit
3
.replit
@ -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"]
|
||||
|
||||
BIN
attached_assets/image_1772310288253.png
Normal file
BIN
attached_assets/image_1772310288253.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 769 KiB |
112
client/replit_integrations/audio/audio-playback-worklet.js
Normal file
112
client/replit_integrations/audio/audio-playback-worklet.js
Normal 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);
|
||||
|
||||
36
client/replit_integrations/audio/audio-utils.ts
Normal file
36
client/replit_integrations/audio/audio-utils.ts
Normal 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 };
|
||||
}
|
||||
|
||||
45
client/replit_integrations/audio/index.ts
Normal file
45
client/replit_integrations/audio/index.ts
Normal 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";
|
||||
|
||||
105
client/replit_integrations/audio/useAudioPlayback.ts
Normal file
105
client/replit_integrations/audio/useAudioPlayback.ts
Normal 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 };
|
||||
}
|
||||
52
client/replit_integrations/audio/useVoiceRecorder.ts
Normal file
52
client/replit_integrations/audio/useVoiceRecorder.ts
Normal 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 };
|
||||
}
|
||||
|
||||
91
client/replit_integrations/audio/useVoiceStream.ts
Normal file
91
client/replit_integrations/audio/useVoiceStream.ts
Normal 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 };
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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
105
package-lock.json
generated
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
113
server/horoscope-generator.ts
Normal file
113
server/horoscope-generator.ts
Normal 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;
|
||||
}
|
||||
274
server/replit_integrations/audio/client.ts
Normal file
274
server/replit_integrations/audio/client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
14
server/replit_integrations/audio/index.ts
Normal file
14
server/replit_integrations/audio/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export { registerAudioRoutes } from "./routes";
|
||||
export {
|
||||
openai,
|
||||
detectAudioFormat,
|
||||
convertToWav,
|
||||
ensureCompatibleFormat,
|
||||
type AudioFormat,
|
||||
voiceChat,
|
||||
voiceChatStream,
|
||||
textToSpeech,
|
||||
textToSpeechStream,
|
||||
speechToText,
|
||||
speechToTextStream,
|
||||
} from "./client";
|
||||
136
server/replit_integrations/audio/routes.ts
Normal file
136
server/replit_integrations/audio/routes.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
7
server/replit_integrations/batch/index.ts
Normal file
7
server/replit_integrations/batch/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export {
|
||||
batchProcess,
|
||||
batchProcessWithSSE,
|
||||
isRateLimitError,
|
||||
type BatchOptions,
|
||||
} from "./utils";
|
||||
|
||||
182
server/replit_integrations/batch/utils.ts
Normal file
182
server/replit_integrations/batch/utils.ts
Normal 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;
|
||||
}
|
||||
|
||||
3
server/replit_integrations/chat/index.ts
Normal file
3
server/replit_integrations/chat/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { registerChatRoutes } from "./routes";
|
||||
export { chatStorage, type IChatStorage } from "./storage";
|
||||
|
||||
118
server/replit_integrations/chat/routes.ts
Normal file
118
server/replit_integrations/chat/routes.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
43
server/replit_integrations/chat/storage.ts
Normal file
43
server/replit_integrations/chat/storage.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
||||
59
server/replit_integrations/image/client.ts
Normal file
59
server/replit_integrations/image/client.ts
Normal 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;
|
||||
}
|
||||
|
||||
3
server/replit_integrations/image/index.ts
Normal file
3
server/replit_integrations/image/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { registerImageRoutes } from "./routes";
|
||||
export { openai, generateImageBuffer, editImages } from "./client";
|
||||
|
||||
31
server/replit_integrations/image/routes.ts
Normal file
31
server/replit_integrations/image/routes.ts
Normal 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" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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
34
shared/models/chat.ts
Normal 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>;
|
||||
|
||||
@ -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(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user