feat: fixed vision/image provider specific errors and fixed podcast/video streaming
Some checks are pending
Build and Push Docker Images / tag_release (push) Waiting to run
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_backend, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64) (push) Blocked by required conditions
Build and Push Docker Images / build (./surfsense_web, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (backend, surfsense-backend) (push) Blocked by required conditions
Build and Push Docker Images / create_manifest (web, surfsense-web) (push) Blocked by required conditions

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-02 19:18:53 -07:00
parent ae9d36d77f
commit 47b2994ec7
54 changed files with 4469 additions and 563 deletions

View file

@ -477,9 +477,7 @@ const MessageInfoDropdown: FC = () => {
</span>
<span className="text-xs text-muted-foreground">
{counts.total_tokens.toLocaleString()} tokens
{costMicros && costMicros > 0
? ` · ${formatTurnCost(costMicros)}`
: ""}
{costMicros && costMicros > 0 ? ` · ${formatTurnCost(costMicros)}` : ""}
</span>
</ActionBarMorePrimitive.Item>
);

View file

@ -19,6 +19,7 @@ import {
import type React from "react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
import {
globalImageGenConfigsAtom,
imageGenConfigsAtom,
@ -461,6 +462,18 @@ export function ModelSelector({
const { data: visionUserConfigs, isLoading: visionUserLoading } =
useAtomValue(visionLLMConfigsAtom);
// Pending image attachments on the composer. Used to surface an
// amber "No image" hint on chat models the catalog reports as
// non-vision (`supports_image_input=false`) when the next message
// will carry an image. The hint is purely advisory: selection,
// focus, and click handling are unaffected. The backend's safety
// net (`is_known_text_only_chat_model`) is the actual block, and
// it only fires when LiteLLM *explicitly* marks a model as
// text-only — so a model that's secretly capable but hasn't been
// annotated will still flow through to the provider.
const pendingUserImageUrls = useAtomValue(pendingUserImageDataUrlsAtom);
const hasPendingImages = pendingUserImageUrls.length > 0;
const isLoading =
llmUserLoading ||
llmGlobalLoading ||
@ -984,6 +997,21 @@ export function ModelSelector({
const isSelected = getSelectedId() === config.id;
const isFocused = focusedIndex === index;
const hasCitations = "citations_enabled" in config && !!config.citations_enabled;
// Chat-tab only: surface an amber "No image" hint when the
// composer carries images and the catalog reports the model as
// non-vision. This is purely advisory — selection is *not*
// blocked. The backend's narrow safety net
// (`is_known_text_only_chat_model`) is the source of truth for
// rejecting image turns, and it only fires when LiteLLM
// explicitly marks the model as text-only. A model surfaced as
// `supports_image_input=false` here may still be capable in
// practice (unknown / unmapped LiteLLM entry), so we let the
// user pick it and the provider response decide.
const isImageIncompatibleChatModel =
activeTab === "llm" &&
hasPendingImages &&
"supports_image_input" in config &&
(config as Record<string, unknown>).supports_image_input === false;
return (
<div
@ -992,6 +1020,11 @@ export function ModelSelector({
role="option"
tabIndex={isMobile ? -1 : 0}
aria-selected={isSelected}
title={
isImageIncompatibleChatModel
? "This model is reported as text-only. You can still pick it; the provider may reject image turns."
: undefined
}
onClick={() => handleSelectItem(item)}
onKeyDown={
isMobile
@ -1005,9 +1038,8 @@ export function ModelSelector({
}
onMouseEnter={() => setFocusedIndex(index)}
className={cn(
"group flex items-center gap-2.5 px-3 py-2 rounded-xl cursor-pointer",
"transition-all duration-150 mx-2",
"hover:bg-accent/40",
"group flex items-center gap-2.5 px-3 py-2 rounded-xl",
"transition-all duration-150 mx-2 cursor-pointer hover:bg-accent/40",
isSelected && "bg-primary/6 dark:bg-primary/8",
isFocused && "bg-accent/50"
)}
@ -1053,6 +1085,14 @@ export function ModelSelector({
Free
</Badge>
) : null}
{isImageIncompatibleChatModel && (
<Badge
variant="secondary"
className="text-[9px] px-1 py-0 h-3.5 bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300 border-0"
>
No image
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 mt-0.5">
<span className="text-xs text-muted-foreground truncate">

View file

@ -250,8 +250,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 pages, premium credit, and billing.
Can&apos;t find what you need? Reach out at{" "}
Everything you need to know about SurfSense pages, premium credit, 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

@ -22,6 +22,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
@ -190,8 +191,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
? "model"
: "models"}
</span>{" "}
available from your administrator.{" "}
{(() => {
available from your administrator. {(() => {
const nonAuto = globalConfigs.filter(
(g) => !("is_auto_mode" in g && g.is_auto_mode)
);
@ -214,6 +214,75 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</Alert>
)}
{/* Global Image Models read-only cards with per-model Free/Premium
badges. Mirrors the badge palette used by the chat role selector
(`llm-role-manager.tsx`) so the meaning is consistent across
every model-configuration surface (chat / image / vision). */}
{!isLoading &&
globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
<div className="space-y-3">
<h3 className="text-xs md:text-sm font-semibold text-muted-foreground">
Global Image Models
</h3>
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{globalConfigs
.filter((g) => !("is_auto_mode" in g && g.is_auto_mode))
.map((cfg) => {
const billingTier =
("billing_tier" in cfg &&
typeof (cfg as { billing_tier?: string }).billing_tier === "string" &&
(cfg as { billing_tier?: string }).billing_tier) ||
"free";
const isPremium = billingTier === "premium";
return (
<Card
key={cfg.id}
className="border-border/60 bg-muted/20 overflow-hidden h-full"
>
<CardContent className="p-4 flex flex-col gap-3 h-full">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0">
{getProviderIcon(cfg.provider, { className: "size-4" })}
</div>
<div className="min-w-0 flex-1 flex items-center gap-1.5">
<h4 className="text-sm font-semibold tracking-tight truncate">
{cfg.name}
</h4>
{isPremium ? (
<Badge
variant="secondary"
className="text-[8px] md:text-[9px] shrink-0 bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0"
>
Premium
</Badge>
) : (
<Badge
variant="secondary"
className="text-[8px] md:text-[9px] shrink-0 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 border-0"
>
Free
</Badge>
)}
</div>
</div>
{cfg.description && (
<p className="text-[11px] text-muted-foreground/70 line-clamp-2">
{cfg.description}
</p>
)}
<div className="flex items-center pt-2 border-t border-border/40 mt-auto">
<span className="text-[11px] text-muted-foreground/60 truncate">
{cfg.model_name}
</span>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
)}
{/* Loading Skeleton */}
{isLoading && (
<div className="space-y-4 md:space-y-6">

View file

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

View file

@ -22,6 +22,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
@ -191,8 +192,7 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
? "model"
: "models"}
</span>{" "}
available from your administrator.{" "}
{(() => {
available from your administrator. {(() => {
const nonAuto = globalConfigs.filter(
(g) => !("is_auto_mode" in g && g.is_auto_mode)
);
@ -215,6 +215,75 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
</Alert>
)}
{/* Global Vision Models read-only cards with per-model Free/Premium
badges. Mirrors the badge palette used by the chat role selector
(`llm-role-manager.tsx`) so the meaning is consistent across
every model-configuration surface (chat / image / vision). */}
{!isLoading &&
globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
<div className="space-y-3">
<h3 className="text-xs md:text-sm font-semibold text-muted-foreground">
Global Vision Models
</h3>
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
{globalConfigs
.filter((g) => !("is_auto_mode" in g && g.is_auto_mode))
.map((cfg) => {
const billingTier =
("billing_tier" in cfg &&
typeof (cfg as { billing_tier?: string }).billing_tier === "string" &&
(cfg as { billing_tier?: string }).billing_tier) ||
"free";
const isPremium = billingTier === "premium";
return (
<Card
key={cfg.id}
className="border-border/60 bg-muted/20 overflow-hidden h-full"
>
<CardContent className="p-4 flex flex-col gap-3 h-full">
<div className="flex items-center gap-2 min-w-0">
<div className="shrink-0">
{getProviderIcon(cfg.provider, { className: "size-4" })}
</div>
<div className="min-w-0 flex-1 flex items-center gap-1.5">
<h4 className="text-sm font-semibold tracking-tight truncate">
{cfg.name}
</h4>
{isPremium ? (
<Badge
variant="secondary"
className="text-[8px] md:text-[9px] shrink-0 bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0"
>
Premium
</Badge>
) : (
<Badge
variant="secondary"
className="text-[8px] md:text-[9px] shrink-0 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 border-0"
>
Free
</Badge>
)}
</div>
</div>
{cfg.description && (
<p className="text-[11px] text-muted-foreground/70 line-clamp-2">
{cfg.description}
</p>
)}
<div className="flex items-center pt-2 border-t border-border/40 mt-auto">
<span className="text-[11px] text-muted-foreground/60 truncate">
{cfg.model_name}
</span>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
)}
{isLoading && (
<div className="space-y-4 md:space-y-6">
<div className="space-y-4">

View file

@ -416,9 +416,19 @@ export const GeneratePodcastToolUI = ({
return <PodcastErrorState title={title} error={result.error || "Generation failed"} />;
}
// Already generating - show simple warning, don't create another poller
// The FIRST tool call will display the podcast when ready
// (new: "generating", legacy: "already_generating")
// Pending/generating rows have a stable podcast_id, so the card can poll
// independently while the chat stream finishes.
if (
(result.status === "pending" ||
result.status === "generating" ||
result.status === "processing") &&
result.podcast_id
) {
return <PodcastStatusPoller podcastId={result.podcast_id} title={result.title || title} />;
}
// Legacy duplicate/no-ID result - show a simple warning, don't create
// another poller. The first tool call will display the podcast when ready.
if (result.status === "generating" || result.status === "already_generating") {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none">
@ -432,11 +442,6 @@ export const GeneratePodcastToolUI = ({
);
}
// Pending - poll for completion (new: "pending" with podcast_id)
if (result.status === "pending" && result.podcast_id) {
return <PodcastStatusPoller podcastId={result.podcast_id} title={result.title || title} />;
}
// Ready with podcast_id (new: "ready", legacy: "success")
if ((result.status === "ready" || result.status === "success") && result.podcast_id) {
return <PodcastPlayer podcastId={result.podcast_id} title={result.title || title} />;