Skip to content

Commit 21bf2df

Browse files
committed
feat: add QwkSearch API tools and markdown to HTML conversion utility
- Introduced specialized tools for interacting with the QwkSearch API, including web search, content extraction, AI response generation, and page rendering with JavaScript execution. - Added a new module for converting Markdown to HTML with syntax highlighting using Highlight.js. - Implemented shared types for few-shot prompt examples in the MetaSearchAgent.
1 parent a34a5d1 commit 21bf2df

69 files changed

Lines changed: 3113 additions & 7620 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 35 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,25 @@
11
/**
22
* @fileoverview Text-to-speech endpoint. POST converts text to audio using
3-
* Cloudflare Workers AI Deepgram Aura voices. Supports multiple speakers
4-
* and enforces per-user daily rate limits (10/day for guests).
3+
* Kokoro (default, faster & more natural) or Deepgram Aura.
4+
* Enforces per-user daily rate limits (10/day for guests).
55
*/
66
import { NextRequest, NextResponse } from "next/server";
7-
import { getCloudflareContext } from "@/lib/cloudflare-context";
87
import { getUserId } from "@/lib/auth/session";
98
import { checkTTSRateLimit } from "@/lib/rate-limit/guestRateLimiter";
9+
import { generateSpeech, type TTSProvider } from "@/lib/speech";
1010

1111
export const runtime = "nodejs";
1212

13-
const AURA_SPEAKERS = [
14-
"angus", "asteria", "arcas", "orion", "orpheus", "athena",
15-
"luna", "zeus", "perseus", "helios", "hera", "stella",
16-
] as const;
17-
18-
type AuraSpeaker = (typeof AURA_SPEAKERS)[number];
19-
2013
export async function POST(request: NextRequest) {
2114
let text: string;
22-
let speaker: AuraSpeaker;
15+
let voice: string;
16+
let provider: TTSProvider;
17+
2318
try {
2419
const body = await request.json();
2520
text = body.text;
26-
const requested = body.speaker as string | undefined;
27-
speaker = AURA_SPEAKERS.includes(requested as AuraSpeaker)
28-
? (requested as AuraSpeaker)
29-
: "angus";
21+
voice = body.voice || body.speaker || "af_heart";
22+
provider = body.provider || "kokoro";
3023
} catch {
3124
return NextResponse.json(
3225
{ error: "Invalid request body" },
@@ -54,31 +47,36 @@ export async function POST(request: NextRequest) {
5447
);
5548
}
5649

57-
let ai: any;
5850
try {
59-
const ctx = getCloudflareContext();
60-
ai = (ctx.env as any)?.AI;
61-
} catch {
62-
// CF bindings not available (local dev)
63-
}
51+
const result = await generateSpeech({
52+
text: text.slice(0, 5000),
53+
provider,
54+
voice,
55+
});
56+
57+
return new Response(result.audio, {
58+
headers: {
59+
"Content-Type": result.contentType,
60+
"Cache-Control": "public, max-age=86400",
61+
"Content-Disposition": `inline; filename="speech.${result.contentType.includes('wav') ? 'wav' : 'mp3'}"`,
62+
},
63+
});
64+
} catch (error) {
65+
console.error("[TTS] Error:", error);
66+
67+
// Handle specific errors
68+
const message = error instanceof Error ? error.message : "TTS generation failed";
69+
70+
if (message.includes("Cloudflare AI binding")) {
71+
return NextResponse.json(
72+
{ error: "Deepgram provider requires Cloudflare AI binding. Use 'kokoro' provider instead." },
73+
{ status: 503 }
74+
);
75+
}
6476

65-
if (!ai) {
6677
return NextResponse.json(
67-
{ error: "Cloudflare AI binding not available" },
68-
{ status: 503 }
78+
{ error: message },
79+
{ status: 500 }
6980
);
7081
}
71-
72-
const result = await ai.run("@cf/deepgram/aura-1", {
73-
text: text.slice(0, 5000),
74-
speaker,
75-
encoding: "mp3",
76-
});
77-
78-
return new Response(result, {
79-
headers: {
80-
"Content-Type": "audio/mpeg",
81-
"Cache-Control": "no-store",
82-
},
83-
});
8482
}

apps/qwksearch-web/components/Settings/Sections/Models/ModelProvider.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import AddModel from './AddModelDialog';
99
import UpdateProvider from './UpdateProviderDialog';
1010
import DeleteProvider from './DeleteProviderDialog';
1111
import TestModelsButton from './TestModelsButton';
12+
import ProviderIcon from './ProviderIcon';
1213

1314
const ModelProvider = ({
1415
modelProvider,
@@ -69,9 +70,7 @@ const ModelProvider = ({
6970
>
7071
<div className="px-5 py-3.5 flex flex-row justify-between w-full items-center border-b border-light-200 dark:border-dark-200 bg-light-secondary/30 dark:bg-dark-secondary/30">
7172
<div className="flex items-center gap-2.5">
72-
<div className="p-1.5 rounded-md bg-sky-500/10 dark:bg-sky-500/10">
73-
<Plug2 size={14} className="text-sky-500" />
74-
</div>
73+
<ProviderIcon providerType={modelProvider.type} size={20} />
7574
<div className="flex flex-col">
7675
<p className="text-sm lg:text-sm text-black dark:text-white font-medium">
7776
{modelProvider.name}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { useEffect, useRef, useState } from 'react';
2+
import { cropProviderAsDataURL, Provider } from 'agent-toolkit';
3+
import { Plug2 } from 'lucide-react';
4+
5+
interface ProviderIconProps {
6+
providerType: string;
7+
size?: number;
8+
className?: string;
9+
}
10+
11+
const ProviderIcon = ({
12+
providerType,
13+
size = 14,
14+
className = '',
15+
}: ProviderIconProps) => {
16+
const [iconUrl, setIconUrl] = useState<string | null>(null);
17+
const [isLoading, setIsLoading] = useState(true);
18+
const [hasError, setHasError] = useState(false);
19+
20+
useEffect(() => {
21+
const loadProviderIcon = async () => {
22+
try {
23+
setIsLoading(true);
24+
setHasError(false);
25+
26+
const img = new Image();
27+
img.src = '/images/providers-sprite.png';
28+
await img.decode();
29+
30+
const normalizedProvider = providerType.toLowerCase() as Provider;
31+
const dataUrl = await cropProviderAsDataURL(img, normalizedProvider);
32+
setIconUrl(dataUrl);
33+
} catch (error) {
34+
console.error('Failed to load provider icon:', error);
35+
setHasError(true);
36+
} finally {
37+
setIsLoading(false);
38+
}
39+
};
40+
41+
loadProviderIcon();
42+
}, [providerType]);
43+
44+
if (isLoading || hasError || !iconUrl) {
45+
return (
46+
<div className={`p-1.5 rounded-md bg-sky-500/10 dark:bg-sky-500/10 ${className}`}>
47+
<Plug2 size={size} className="text-sky-500" />
48+
</div>
49+
);
50+
}
51+
52+
return (
53+
<div className={`p-1.5 rounded-md bg-white/50 dark:bg-white/10 ${className}`}>
54+
<img
55+
src={iconUrl}
56+
alt={`${providerType} logo`}
57+
width={size}
58+
height={size}
59+
className="object-contain"
60+
/>
61+
</div>
62+
);
63+
};
64+
65+
export default ProviderIcon;

0 commit comments

Comments
 (0)