mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-04 22:02:16 +02:00
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
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:
parent
ae9d36d77f
commit
47b2994ec7
54 changed files with 4469 additions and 563 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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't find what you need? Reach out at{" "}
|
||||
Everything you need to know about SurfSense pages, premium credit, and billing. Can't
|
||||
find what you need? Reach out at{" "}
|
||||
<a href="mailto:rohan@surfsense.com" className="text-blue-500 underline">
|
||||
rohan@surfsense.com
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue