Merge remote-tracking branch 'upstream/dev' into feat/unified-model-connections

This commit is contained in:
Anish Sarkar 2026-06-13 19:04:49 +05:30
commit ab5423d2d2
45 changed files with 775 additions and 272 deletions

View file

@ -3,10 +3,7 @@ import type { MDXComponents } from "mdx/types";
import type { Metadata } from "next";
import type { ComponentType } from "react";
import { changelog } from "@/.source/server";
import {
ChangelogTimeline,
type ChangelogTimelineEntry,
} from "@/components/ui/changelog-timeline";
import { ChangelogTimeline, type ChangelogTimelineEntry } from "@/components/ui/changelog-timeline";
import { formatDate } from "@/lib/utils";
import { getMDXComponents } from "@/mdx-components";

View file

@ -13,11 +13,7 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import type {
CreditPurchase,
PagePurchase,
PurchaseStatus,
} from "@/contracts/types/stripe.types";
import type { CreditPurchase, PagePurchase, PurchaseStatus } from "@/contracts/types/stripe.types";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
import { cn } from "@/lib/utils";

View file

@ -48,8 +48,8 @@ import {
isCommentReplyMetadata,
isConnectorIndexingMetadata,
isDocumentProcessingMetadata,
isNewMentionMetadata,
isInsufficientCreditsMetadata,
isNewMentionMetadata,
} from "@/contracts/types/inbox.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import type { InboxItem } from "@/hooks/use-inbox";

View file

@ -1,9 +1,9 @@
export { AllChatsSidebar, AllChatsSidebarContent } from "./AllChatsSidebar";
export { ChatListItem } from "./ChatListItem";
export { CreditBalanceDisplay } from "./CreditBalanceDisplay";
export { DocumentsSidebar } from "./DocumentsSidebar";
export { InboxSidebar, InboxSidebarContent } from "./InboxSidebar";
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
export { CreditBalanceDisplay } from "./CreditBalanceDisplay";
export { NavSection } from "./NavSection";
export { Sidebar } from "./Sidebar";
export { SidebarCollapseButton } from "./SidebarCollapseButton";

View file

@ -3,9 +3,9 @@
import {
AlarmClock,
FilePlus2,
type LucideIcon,
Search,
Settings2,
type LucideIcon,
WandSparkles,
X,
} from "lucide-react";

View file

@ -286,8 +286,8 @@ function PricingFAQ() {
Frequently Asked Questions
</h2>
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
Everything you need to know about SurfSense credits and billing.
Can&apos;t find what you need? Reach out at{" "}
Everything you need to know about SurfSense credits and billing. Can&apos;t find what you
need? Reach out at{" "}
<a href="mailto:rohan@surfsense.com" className="text-blue-500 underline">
rohan@surfsense.com
</a>

View file

@ -77,9 +77,7 @@ export function EarnCreditsContent() {
<div className="w-full space-y-5">
<div className="text-center">
<h2 className="text-xl font-bold tracking-tight">Earn Credits</h2>
<p className="mt-1 text-sm text-muted-foreground">
Earn bonus credits by completing tasks
</p>
<p className="mt-1 text-sm text-muted-foreground">Earn bonus credits by completing tasks</p>
</div>
<div className="space-y-2">

View file

@ -1,11 +1,20 @@
"use client";
import { Loader2, Plus, Trash2 } from "lucide-react";
import { Check, ChevronDown, Loader2, Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
@ -15,6 +24,7 @@ import {
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
type LanguageOptions,
MAX_SPEAKERS,
type PodcastSpec,
type PodcastStyle,
@ -56,6 +66,7 @@ interface BriefReviewProps {
export function BriefReview({ podcast, spec }: BriefReviewProps) {
const [draft, setDraft] = useState<PodcastSpec>(spec);
const [voices, setVoices] = useState<VoiceOption[] | null>(null);
const [offering, setOffering] = useState<LanguageOptions | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// A pushed spec change (saved edit or concurrent editor) resets the form to
@ -75,19 +86,26 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
.catch(() => {
if (!cancelled) setVoices([]);
});
podcastsApiService
.listLanguages()
.then((options) => {
if (!cancelled) setOffering(options);
})
.catch(() => {
if (!cancelled) setOffering({ languages: [], allows_custom: false });
});
return () => {
cancelled = true;
};
}, []);
// The backend owns the offering; the draft's language stays listed even
// when it falls outside it (e.g. a custom tag entered earlier).
const languages = useMemo(() => {
const tags = new Set<string>();
for (const voice of voices ?? []) {
if (voice.language !== ANY_LANGUAGE) tags.add(voice.language);
}
const tags = new Set(offering?.languages ?? []);
tags.add(draft.language);
return [...tags].sort();
}, [voices, draft.language]);
}, [offering, draft.language]);
const voicesForLanguage = useMemo(
() => (voices ?? []).filter((voice) => speaks(voice, draft.language)),
@ -193,18 +211,22 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="podcast-language">Language</Label>
<Select value={draft.language} onValueChange={setLanguage}>
<SelectTrigger id="podcast-language">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{languages.map((tag) => (
<SelectItem key={tag} value={tag}>
{languageLabel(tag)}
</SelectItem>
))}
</SelectContent>
</Select>
{offering?.allows_custom ? (
<LanguageCombobox value={draft.language} languages={languages} onSelect={setLanguage} />
) : (
<Select value={draft.language} onValueChange={setLanguage}>
<SelectTrigger id="podcast-language">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
{languages.map((tag) => (
<SelectItem key={tag} value={tag}>
{languageLabel(tag)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="podcast-style">Style</Label>
@ -375,6 +397,80 @@ export function BriefReview({ podcast, spec }: BriefReviewProps) {
);
}
/** A searchable language picker for providers whose voices speak anything:
* the offered list comes from the backend, and any BCP-47 tag may be typed
* when none of them fits. */
function LanguageCombobox({
value,
languages,
onSelect,
}: {
value: string;
languages: string[];
onSelect: (language: string) => void;
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const pick = (tag: string) => {
onSelect(tag);
setOpen(false);
setQuery("");
};
const customTag = query.trim();
const isNewTag =
customTag.length > 0 && !languages.some((tag) => tag.toLowerCase() === customTag.toLowerCase());
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
id="podcast-language"
className="border-popover-border flex h-9 w-full items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50"
>
<span className="line-clamp-1 text-left">{languageLabel(value)}</span>
<ChevronDown className="size-4 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command>
<CommandInput
placeholder="Search or type a language tag…"
value={query}
onValueChange={setQuery}
/>
<CommandList>
<CommandEmpty>No matching language.</CommandEmpty>
<CommandGroup>
{languages.map((tag) => (
<CommandItem
key={tag}
value={tag}
keywords={[languageLabel(tag)]}
onSelect={() => pick(tag)}
>
<Check className={tag === value ? "size-4" : "size-4 opacity-0"} />
{languageLabel(tag)}
</CommandItem>
))}
{isNewTag ? (
<CommandItem value={customTag} onSelect={() => pick(customTag)}>
<Plus className="size-4" />
Use {customTag}
</CommandItem>
) : null}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
/** The current selection stays listed even when it no longer matches the
* language filter, so the Select never renders an orphaned value. */
function voiceItems(candidates: VoiceOption[], selectedId: string): VoiceOption[] {

View file

@ -103,6 +103,15 @@ export const voiceOption = z.object({
});
export type VoiceOption = z.infer<typeof voiceOption>;
// The languages the backend offers for the active TTS provider. When
// `allows_custom` is true the list is a starting point and any BCP-47 tag
// may be entered.
export const languageOptions = z.object({
languages: z.array(z.string()),
allows_custom: z.boolean(),
});
export type LanguageOptions = z.infer<typeof languageOptions>;
export const updateSpecRequest = z.object({
spec: podcastSpec,
expected_version: z.number().int().min(1),

View file

@ -1,5 +1,6 @@
import { z } from "zod";
import {
languageOptions,
type PodcastSpec,
podcastDetail,
updateSpecRequest,
@ -60,6 +61,12 @@ class PodcastsApiService {
return baseApiService.get(`${BASE}/voices${qs}`, voiceOptionList);
};
// The languages the active provider can offer; the brief form renders
// exactly this list and only opens free entry when the backend allows it.
listLanguages = async () => {
return baseApiService.get(`${BASE}/languages`, languageOptions);
};
// A short audio sample of a voice, cached server-side per voice.
previewVoice = async (voiceId: string) => {
return baseApiService.getBlob(`${BASE}/voices/${encodeURIComponent(voiceId)}/preview`);

View file

@ -1,6 +1,6 @@
{
"name": "surfsense_web",
"version": "0.0.27",
"version": "0.0.28",
"private": true,
"packageManager": "pnpm@10.26.0",
"description": "SurfSense Frontend",