mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
Merge pull request #1162 from CREDO23/feat/vision-autocomplete
[Feat] Multi-suggestion autocomplete, Vision LLM config & Desktop analytics
This commit is contained in:
commit
e827a3906d
49 changed files with 3263 additions and 153 deletions
|
|
@ -7,4 +7,7 @@ NEXT_PUBLIC_ZERO_CACHE_URL=http://localhost:4848
|
|||
DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.sdsf.supabase.co:5432/postgres
|
||||
|
||||
# Deployment mode (optional)
|
||||
NEXT_PUBLIC_DEPLOYMENT_MODE="self-hosted" or "cloud"
|
||||
NEXT_PUBLIC_DEPLOYMENT_MODE="self-hosted" or "cloud"
|
||||
|
||||
# PostHog analytics (optional, leave empty to disable)
|
||||
NEXT_PUBLIC_POSTHOG_KEY=
|
||||
|
|
@ -154,11 +154,14 @@ export function DashboardClientLayout({
|
|||
|
||||
// Sync to Electron store if stored value is null (first navigation)
|
||||
if (electronAPI?.setActiveSearchSpace) {
|
||||
electronAPI.getActiveSearchSpace?.().then((stored) => {
|
||||
if (!stored) {
|
||||
electronAPI.setActiveSearchSpace!(activeSeacrhSpaceId);
|
||||
}
|
||||
}).catch(() => {});
|
||||
electronAPI
|
||||
.getActiveSearchSpace?.()
|
||||
.then((stored) => {
|
||||
if (!stored) {
|
||||
electronAPI.setActiveSearchSpace!(activeSeacrhSpaceId);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}, [search_space_id, setActiveSearchSpaceIdState, electronAPI]);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,18 @@ import { toast } from "sonner";
|
|||
import { DEFAULT_SHORTCUTS, ShortcutRecorder } from "@/components/desktop/shortcut-recorder";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type { SearchSpace } from "@/contracts/types/search-space.types";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import type { SearchSpace } from "@/contracts/types/search-space.types";
|
||||
|
||||
export function DesktopContent() {
|
||||
const api = useElectronAPI();
|
||||
|
|
@ -82,7 +88,10 @@ export function DesktopContent() {
|
|||
await api.setAutocompleteEnabled(checked);
|
||||
};
|
||||
|
||||
const updateShortcut = (key: "generalAssist" | "quickAsk" | "autocomplete", accelerator: string) => {
|
||||
const updateShortcut = (
|
||||
key: "generalAssist" | "quickAsk" | "autocomplete",
|
||||
accelerator: string
|
||||
) => {
|
||||
setShortcuts((prev) => {
|
||||
const updated = { ...prev, [key]: accelerator };
|
||||
api.setShortcuts?.({ [key]: accelerator }).catch(() => {
|
||||
|
|
@ -110,7 +119,8 @@ export function DesktopContent() {
|
|||
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
|
||||
<CardTitle className="text-base md:text-lg">Default Search Space</CardTitle>
|
||||
<CardDescription className="text-xs md:text-sm">
|
||||
Choose which search space General Assist, Quick Assist, and Extreme Assist operate against.
|
||||
Choose which search space General Assist, Quick Assist, and Extreme Assist operate
|
||||
against.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||
|
|
@ -128,7 +138,9 @@ export function DesktopContent() {
|
|||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No search spaces found. Create one first.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No search spaces found. Create one first.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -143,34 +155,34 @@ export function DesktopContent() {
|
|||
</CardHeader>
|
||||
<CardContent className="px-3 md:px-6 pb-3 md:pb-6">
|
||||
{shortcutsLoaded ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.generalAssist}
|
||||
onChange={(accel) => updateShortcut("generalAssist", accel)}
|
||||
onReset={() => resetShortcut("generalAssist")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.generalAssist}
|
||||
label="General Assist"
|
||||
description="Launch SurfSense instantly from any application"
|
||||
icon={Rocket}
|
||||
/>
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.quickAsk}
|
||||
onChange={(accel) => updateShortcut("quickAsk", accel)}
|
||||
onReset={() => resetShortcut("quickAsk")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.quickAsk}
|
||||
label="Quick Assist"
|
||||
description="Select text anywhere, then ask AI to explain, rewrite, or act on it"
|
||||
icon={Zap}
|
||||
/>
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.autocomplete}
|
||||
onChange={(accel) => updateShortcut("autocomplete", accel)}
|
||||
onReset={() => resetShortcut("autocomplete")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.autocomplete}
|
||||
label="Extreme Assist"
|
||||
description="AI drafts text using your screen context and knowledge base"
|
||||
icon={BrainCog}
|
||||
/>
|
||||
<div className="flex flex-col gap-3">
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.generalAssist}
|
||||
onChange={(accel) => updateShortcut("generalAssist", accel)}
|
||||
onReset={() => resetShortcut("generalAssist")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.generalAssist}
|
||||
label="General Assist"
|
||||
description="Launch SurfSense instantly from any application"
|
||||
icon={Rocket}
|
||||
/>
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.quickAsk}
|
||||
onChange={(accel) => updateShortcut("quickAsk", accel)}
|
||||
onReset={() => resetShortcut("quickAsk")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.quickAsk}
|
||||
label="Quick Assist"
|
||||
description="Select text anywhere, then ask AI to explain, rewrite, or act on it"
|
||||
icon={Zap}
|
||||
/>
|
||||
<ShortcutRecorder
|
||||
value={shortcuts.autocomplete}
|
||||
onChange={(accel) => updateShortcut("autocomplete", accel)}
|
||||
onReset={() => resetShortcut("autocomplete")}
|
||||
defaultValue={DEFAULT_SHORTCUTS.autocomplete}
|
||||
label="Extreme Assist"
|
||||
description="AI drafts text using your screen context and knowledge base"
|
||||
icon={BrainCog}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Click a shortcut and press a new key combination to change it.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -139,9 +139,7 @@ export default function DesktopLoginPage() {
|
|||
height={48}
|
||||
priority
|
||||
/>
|
||||
<h1 className="text-lg font-semibold tracking-tight">
|
||||
Welcome to SurfSense Desktop
|
||||
</h1>
|
||||
<h1 className="text-lg font-semibold tracking-tight">Welcome to SurfSense Desktop</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Configure shortcuts, then sign in to get started.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ type SSEEvent =
|
|||
| {
|
||||
type: "data-thinking-step";
|
||||
data: { id: string; title: string; status: string; items: string[] };
|
||||
}
|
||||
| {
|
||||
type: "data-suggestions";
|
||||
data: { options: string[] };
|
||||
};
|
||||
|
||||
interface AgentStep {
|
||||
|
|
@ -23,24 +27,32 @@ interface AgentStep {
|
|||
items: string[];
|
||||
}
|
||||
|
||||
function friendlyError(raw: string | number): string {
|
||||
type FriendlyError = { message: string; isSetup?: boolean };
|
||||
|
||||
function friendlyError(raw: string | number): FriendlyError {
|
||||
if (typeof raw === "number") {
|
||||
if (raw === 401) return "Please sign in to use suggestions.";
|
||||
if (raw === 403) return "You don\u2019t have permission for this.";
|
||||
if (raw === 404) return "Suggestion service not found. Is the backend running?";
|
||||
if (raw >= 500) return "Something went wrong on the server. Try again.";
|
||||
return "Something went wrong. Try again.";
|
||||
if (raw === 401) return { message: "Please sign in to use suggestions." };
|
||||
if (raw === 403) return { message: "You don\u2019t have permission for this." };
|
||||
if (raw === 404) return { message: "Suggestion service not found. Is the backend running?" };
|
||||
if (raw >= 500) return { message: "Something went wrong on the server. Try again." };
|
||||
return { message: "Something went wrong. Try again." };
|
||||
}
|
||||
const lower = raw.toLowerCase();
|
||||
if (lower.includes("not authenticated") || lower.includes("unauthorized"))
|
||||
return "Please sign in to use suggestions.";
|
||||
return { message: "Please sign in to use suggestions." };
|
||||
if (lower.includes("no vision llm configured") || lower.includes("no llm configured"))
|
||||
return "No Vision LLM configured. Set one in search space settings.";
|
||||
return {
|
||||
message: "Configure a vision-capable model (e.g. GPT-4o, Gemini) to enable autocomplete.",
|
||||
isSetup: true,
|
||||
};
|
||||
if (lower.includes("does not support vision"))
|
||||
return "Selected model doesn\u2019t support vision. Set a vision-capable model in settings.";
|
||||
return {
|
||||
message: "The selected model doesn\u2019t support vision. Choose a vision-capable model.",
|
||||
isSetup: true,
|
||||
};
|
||||
if (lower.includes("fetch") || lower.includes("network") || lower.includes("econnrefused"))
|
||||
return "Can\u2019t reach the server. Check your connection.";
|
||||
return "Something went wrong. Try again.";
|
||||
return { message: "Can\u2019t reach the server. Check your connection." };
|
||||
return { message: "Something went wrong. Try again." };
|
||||
}
|
||||
|
||||
const AUTO_DISMISS_MS = 3000;
|
||||
|
|
@ -70,10 +82,11 @@ function StepIcon({ status }: { status: string }) {
|
|||
|
||||
export default function SuggestionPage() {
|
||||
const api = useElectronAPI();
|
||||
const [suggestion, setSuggestion] = useState("");
|
||||
const [options, setOptions] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<FriendlyError | null>(null);
|
||||
const [steps, setSteps] = useState<AgentStep[]>([]);
|
||||
const [expandedOption, setExpandedOption] = useState<number | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const isDesktop = !!api?.onAutocompleteContext;
|
||||
|
|
@ -85,13 +98,21 @@ export default function SuggestionPage() {
|
|||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!error) return;
|
||||
if (!error || error.isSetup) return;
|
||||
const timer = setTimeout(() => {
|
||||
api?.dismissSuggestion?.();
|
||||
}, AUTO_DISMISS_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [error, api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || error || options.length > 0) return;
|
||||
const timer = setTimeout(() => {
|
||||
api?.dismissSuggestion?.();
|
||||
}, AUTO_DISMISS_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isLoading, error, options, api]);
|
||||
|
||||
const fetchSuggestion = useCallback(
|
||||
async (screenshot: string, searchSpaceId: string, appName?: string, windowTitle?: string) => {
|
||||
abortRef.current?.abort();
|
||||
|
|
@ -99,9 +120,10 @@ export default function SuggestionPage() {
|
|||
abortRef.current = controller;
|
||||
|
||||
setIsLoading(true);
|
||||
setSuggestion("");
|
||||
setOptions([]);
|
||||
setError(null);
|
||||
setSteps([]);
|
||||
setExpandedOption(null);
|
||||
|
||||
let token = getBearerToken();
|
||||
if (!token) {
|
||||
|
|
@ -165,8 +187,8 @@ export default function SuggestionPage() {
|
|||
|
||||
try {
|
||||
const parsed: SSEEvent = JSON.parse(data);
|
||||
if (parsed.type === "text-delta") {
|
||||
setSuggestion((prev) => prev + parsed.delta);
|
||||
if (parsed.type === "data-suggestions") {
|
||||
setOptions(parsed.data.options);
|
||||
} else if (parsed.type === "error") {
|
||||
setError(friendlyError(parsed.errorText));
|
||||
} else if (parsed.type === "data-thinking-step") {
|
||||
|
|
@ -219,14 +241,52 @@ export default function SuggestionPage() {
|
|||
}
|
||||
|
||||
if (error) {
|
||||
if (error.isSetup) {
|
||||
return (
|
||||
<div className="suggestion-tooltip suggestion-setup">
|
||||
<div className="setup-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" width="28" height="28" aria-hidden="true">
|
||||
<path
|
||||
d="M1 12C1 12 5 4 12 4C19 4 23 12 23 12C23 12 19 20 12 20C5 20 1 12 1 12Z"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="3"
|
||||
stroke="#a78bfa"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="setup-content">
|
||||
<span className="setup-title">Vision Model Required</span>
|
||||
<span className="setup-message">{error.message}</span>
|
||||
<span className="setup-hint">Settings → Vision Models</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="setup-dismiss"
|
||||
onClick={() => api?.dismissSuggestion?.()}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="suggestion-tooltip suggestion-error">
|
||||
<span className="suggestion-error-text">{error}</span>
|
||||
<span className="suggestion-error-text">{error.message}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const showLoading = isLoading && !suggestion;
|
||||
const showLoading = isLoading && options.length === 0;
|
||||
|
||||
if (showLoading) {
|
||||
return (
|
||||
|
|
@ -258,29 +318,63 @@ export default function SuggestionPage() {
|
|||
);
|
||||
}
|
||||
|
||||
const handleAccept = () => {
|
||||
if (suggestion) {
|
||||
api?.acceptSuggestion?.(suggestion);
|
||||
}
|
||||
const handleSelect = (text: string) => {
|
||||
api?.acceptSuggestion?.(text);
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
api?.dismissSuggestion?.();
|
||||
};
|
||||
|
||||
if (!suggestion) return null;
|
||||
const TRUNCATE_LENGTH = 120;
|
||||
|
||||
if (options.length === 0) {
|
||||
return (
|
||||
<div className="suggestion-tooltip suggestion-error">
|
||||
<span className="suggestion-error-text">No suggestions available.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="suggestion-tooltip">
|
||||
<p className="suggestion-text">{suggestion}</p>
|
||||
<div className="suggestion-options">
|
||||
{options.map((option, index) => {
|
||||
const isExpanded = expandedOption === index;
|
||||
const needsTruncation = option.length > TRUNCATE_LENGTH;
|
||||
const displayText =
|
||||
needsTruncation && !isExpanded ? option.slice(0, TRUNCATE_LENGTH) + "…" : option;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="suggestion-option"
|
||||
onClick={() => handleSelect(option)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleSelect(option);
|
||||
}}
|
||||
>
|
||||
<span className="option-number">{index + 1}</span>
|
||||
<span className="option-text">{displayText}</span>
|
||||
{needsTruncation && (
|
||||
<button
|
||||
type="button"
|
||||
className="option-expand"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpandedOption(isExpanded ? null : index);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? "less" : "more"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="suggestion-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="suggestion-btn suggestion-btn-accept"
|
||||
onClick={handleAccept}
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="suggestion-btn suggestion-btn-dismiss"
|
||||
|
|
|
|||
|
|
@ -117,6 +117,66 @@ body:has(.suggestion-body) {
|
|||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* --- Setup prompt (vision model not configured) --- */
|
||||
|
||||
.suggestion-setup {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
border-color: #3b2d6b;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.setup-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.setup-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.setup-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #c4b5fd;
|
||||
}
|
||||
|
||||
.setup-message {
|
||||
font-size: 11.5px;
|
||||
color: #a1a1aa;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.setup-hint {
|
||||
font-size: 10.5px;
|
||||
color: #7c6dac;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.setup-dismiss {
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6b6b7b;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
line-height: 1;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.setup-dismiss:hover {
|
||||
color: #c4b5fd;
|
||||
background: rgba(124, 109, 172, 0.15);
|
||||
}
|
||||
|
||||
/* --- Agent activity indicator --- */
|
||||
|
||||
.agent-activity {
|
||||
|
|
@ -127,6 +187,10 @@ body:has(.suggestion-body) {
|
|||
max-height: 340px;
|
||||
}
|
||||
|
||||
.agent-activity::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.activity-initial {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -191,3 +255,96 @@ body:has(.suggestion-body) {
|
|||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Suggestion option cards --- */
|
||||
|
||||
.suggestion-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow-y: auto;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.suggestion-options::-webkit-scrollbar {
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
.suggestion-options::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.suggestion-options::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.suggestion-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #333;
|
||||
background: #262626;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.suggestion-option:hover {
|
||||
background: #2a2d3a;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.option-number {
|
||||
flex-shrink: 0;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #3f3f46;
|
||||
color: #d4d4d4;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.suggestion-option:hover .option-number {
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.option-text {
|
||||
color: #d4d4d4;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.option-expand {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #71717a;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
font-family: inherit;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.option-expand:hover {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
import { atomWithMutation } from "jotai-tanstack-query";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
CreateVisionLLMConfigRequest,
|
||||
CreateVisionLLMConfigResponse,
|
||||
DeleteVisionLLMConfigResponse,
|
||||
GetVisionLLMConfigsResponse,
|
||||
UpdateVisionLLMConfigRequest,
|
||||
UpdateVisionLLMConfigResponse,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { visionLLMConfigApiService } from "@/lib/apis/vision-llm-config-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
|
||||
|
||||
export const createVisionLLMConfigMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: ["vision-llm-configs", "create"],
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: CreateVisionLLMConfigRequest) => {
|
||||
return visionLLMConfigApiService.createConfig(request);
|
||||
},
|
||||
onSuccess: (_: CreateVisionLLMConfigResponse, request: CreateVisionLLMConfigRequest) => {
|
||||
toast.success(`${request.name} created`);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to create vision model");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const updateVisionLLMConfigMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: ["vision-llm-configs", "update"],
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: UpdateVisionLLMConfigRequest) => {
|
||||
return visionLLMConfigApiService.updateConfig(request);
|
||||
},
|
||||
onSuccess: (_: UpdateVisionLLMConfigResponse, request: UpdateVisionLLMConfigRequest) => {
|
||||
toast.success(`${request.data.name ?? "Configuration"} updated`);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.visionLLMConfigs.byId(request.id),
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update vision model");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const deleteVisionLLMConfigMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: ["vision-llm-configs", "delete"],
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: { id: number; name: string }) => {
|
||||
return visionLLMConfigApiService.deleteConfig(request.id);
|
||||
},
|
||||
onSuccess: (_: DeleteVisionLLMConfigResponse, request: { id: number; name: string }) => {
|
||||
toast.success(`${request.name} deleted`);
|
||||
queryClient.setQueryData(
|
||||
cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
|
||||
(oldData: GetVisionLLMConfigsResponse | undefined) => {
|
||||
if (!oldData) return oldData;
|
||||
return oldData.filter((config) => config.id !== request.id);
|
||||
}
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to delete vision model");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import { visionLLMConfigApiService } from "@/lib/apis/vision-llm-config-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
|
||||
|
||||
export const visionLLMConfigsAtom = atomWithQuery((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
queryKey: cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
|
||||
enabled: !!searchSpaceId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
return visionLLMConfigApiService.getConfigs(Number(searchSpaceId));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const globalVisionLLMConfigsAtom = atomWithQuery(() => {
|
||||
return {
|
||||
queryKey: cacheKeys.visionLLMConfigs.global(),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
return visionLLMConfigApiService.getGlobalConfigs();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -2,8 +2,8 @@
|
|||
|
||||
import { useEffect } from "react";
|
||||
import { useGlobalLoadingEffect } from "@/hooks/use-global-loading";
|
||||
import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils";
|
||||
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
||||
import { getAndClearRedirectPath, setBearerToken, setRefreshToken } from "@/lib/auth-utils";
|
||||
import { trackLoginSuccess } from "@/lib/posthog/events";
|
||||
|
||||
interface TokenHandlerProps {
|
||||
|
|
|
|||
|
|
@ -150,7 +150,9 @@ export function ShortcutRecorder({
|
|||
)}
|
||||
>
|
||||
{recording ? (
|
||||
<span className="text-[11px] text-primary animate-pulse whitespace-nowrap">Press keys…</span>
|
||||
<span className="text-[11px] text-primary animate-pulse whitespace-nowrap">
|
||||
Press keys…
|
||||
</span>
|
||||
) : (
|
||||
<Kbd keys={displayKeys} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@
|
|||
import { useCallback, useState } from "react";
|
||||
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
|
||||
import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
|
||||
import { VisionConfigDialog } from "@/components/shared/vision-config-dialog";
|
||||
import type {
|
||||
GlobalImageGenConfig,
|
||||
GlobalNewLLMConfig,
|
||||
GlobalVisionLLMConfig,
|
||||
ImageGenerationConfig,
|
||||
NewLLMConfigPublic,
|
||||
VisionLLMConfig,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
|
||||
|
|
@ -33,6 +36,14 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
|
|||
const [isImageGlobal, setIsImageGlobal] = useState(false);
|
||||
const [imageDialogMode, setImageDialogMode] = useState<"create" | "edit" | "view">("view");
|
||||
|
||||
// Vision config dialog state
|
||||
const [visionDialogOpen, setVisionDialogOpen] = useState(false);
|
||||
const [selectedVisionConfig, setSelectedVisionConfig] = useState<
|
||||
VisionLLMConfig | GlobalVisionLLMConfig | null
|
||||
>(null);
|
||||
const [isVisionGlobal, setIsVisionGlobal] = useState(false);
|
||||
const [visionDialogMode, setVisionDialogMode] = useState<"create" | "edit" | "view">("view");
|
||||
|
||||
// LLM handlers
|
||||
const handleEditLLMConfig = useCallback(
|
||||
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
|
||||
|
|
@ -79,6 +90,29 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
|
|||
if (!open) setSelectedImageConfig(null);
|
||||
}, []);
|
||||
|
||||
// Vision model handlers
|
||||
const handleAddVisionModel = useCallback(() => {
|
||||
setSelectedVisionConfig(null);
|
||||
setIsVisionGlobal(false);
|
||||
setVisionDialogMode("create");
|
||||
setVisionDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditVisionConfig = useCallback(
|
||||
(config: VisionLLMConfig | GlobalVisionLLMConfig, global: boolean) => {
|
||||
setSelectedVisionConfig(config);
|
||||
setIsVisionGlobal(global);
|
||||
setVisionDialogMode(global ? "view" : "edit");
|
||||
setVisionDialogOpen(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleVisionDialogClose = useCallback((open: boolean) => {
|
||||
setVisionDialogOpen(open);
|
||||
if (!open) setSelectedVisionConfig(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelSelector
|
||||
|
|
@ -86,6 +120,8 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
|
|||
onAddNewLLM={handleAddNewLLM}
|
||||
onEditImage={handleEditImageConfig}
|
||||
onAddNewImage={handleAddImageModel}
|
||||
onEditVision={handleEditVisionConfig}
|
||||
onAddNewVision={handleAddVisionModel}
|
||||
className={className}
|
||||
/>
|
||||
<ModelConfigDialog
|
||||
|
|
@ -104,6 +140,14 @@ export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
|
|||
searchSpaceId={searchSpaceId}
|
||||
mode={imageDialogMode}
|
||||
/>
|
||||
<VisionConfigDialog
|
||||
open={visionDialogOpen}
|
||||
onOpenChange={handleVisionDialogClose}
|
||||
config={selectedVisionConfig}
|
||||
isGlobal={isVisionGlobal}
|
||||
searchSpaceId={searchSpaceId}
|
||||
mode={visionDialogMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Bot, Check, ChevronDown, Edit3, ImageIcon, Plus, Search, Zap } from "lucide-react";
|
||||
import { Bot, Check, ChevronDown, Edit3, Eye, ImageIcon, Plus, Search, Zap } from "lucide-react";
|
||||
import { type UIEvent, useCallback, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
|
@ -15,6 +15,10 @@ import {
|
|||
newLLMConfigsAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import {
|
||||
globalVisionLLMConfigsAtom,
|
||||
visionLLMConfigsAtom,
|
||||
} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -32,8 +36,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||
import type {
|
||||
GlobalImageGenConfig,
|
||||
GlobalNewLLMConfig,
|
||||
GlobalVisionLLMConfig,
|
||||
ImageGenerationConfig,
|
||||
NewLLMConfigPublic,
|
||||
VisionLLMConfig,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -43,6 +49,8 @@ interface ModelSelectorProps {
|
|||
onAddNewLLM: () => void;
|
||||
onEditImage?: (config: ImageGenerationConfig | GlobalImageGenConfig, isGlobal: boolean) => void;
|
||||
onAddNewImage?: () => void;
|
||||
onEditVision?: (config: VisionLLMConfig | GlobalVisionLLMConfig, isGlobal: boolean) => void;
|
||||
onAddNewVision?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -51,14 +59,18 @@ export function ModelSelector({
|
|||
onAddNewLLM,
|
||||
onEditImage,
|
||||
onAddNewImage,
|
||||
onEditVision,
|
||||
onAddNewVision,
|
||||
className,
|
||||
}: ModelSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<"llm" | "image">("llm");
|
||||
const [activeTab, setActiveTab] = useState<"llm" | "image" | "vision">("llm");
|
||||
const [llmSearchQuery, setLlmSearchQuery] = useState("");
|
||||
const [imageSearchQuery, setImageSearchQuery] = useState("");
|
||||
const [visionSearchQuery, setVisionSearchQuery] = useState("");
|
||||
const [llmScrollPos, setLlmScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const [imageScrollPos, setImageScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const [visionScrollPos, setVisionScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const handleListScroll = useCallback(
|
||||
(setter: typeof setLlmScrollPos) => (e: UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
|
|
@ -82,8 +94,21 @@ export function ModelSelector({
|
|||
useAtomValue(globalImageGenConfigsAtom);
|
||||
const { data: imageUserConfigs, isLoading: imageUserLoading } = useAtomValue(imageGenConfigsAtom);
|
||||
|
||||
// Vision data
|
||||
const { data: visionGlobalConfigs, isLoading: visionGlobalLoading } = useAtomValue(
|
||||
globalVisionLLMConfigsAtom
|
||||
);
|
||||
const { data: visionUserConfigs, isLoading: visionUserLoading } =
|
||||
useAtomValue(visionLLMConfigsAtom);
|
||||
|
||||
const isLoading =
|
||||
llmUserLoading || llmGlobalLoading || prefsLoading || imageGlobalLoading || imageUserLoading;
|
||||
llmUserLoading ||
|
||||
llmGlobalLoading ||
|
||||
prefsLoading ||
|
||||
imageGlobalLoading ||
|
||||
imageUserLoading ||
|
||||
visionGlobalLoading ||
|
||||
visionUserLoading;
|
||||
|
||||
// ─── LLM current config ───
|
||||
const currentLLMConfig = useMemo(() => {
|
||||
|
|
@ -116,6 +141,24 @@ export function ModelSelector({
|
|||
);
|
||||
}, [currentImageConfig]);
|
||||
|
||||
// ─── Vision current config ───
|
||||
const currentVisionConfig = useMemo(() => {
|
||||
if (!preferences) return null;
|
||||
const id = preferences.vision_llm_config_id;
|
||||
if (id === null || id === undefined) return null;
|
||||
const globalMatch = visionGlobalConfigs?.find((c) => c.id === id);
|
||||
if (globalMatch) return globalMatch;
|
||||
return visionUserConfigs?.find((c) => c.id === id) ?? null;
|
||||
}, [preferences, visionGlobalConfigs, visionUserConfigs]);
|
||||
|
||||
const isVisionAutoMode = useMemo(() => {
|
||||
return (
|
||||
currentVisionConfig &&
|
||||
"is_auto_mode" in currentVisionConfig &&
|
||||
currentVisionConfig.is_auto_mode
|
||||
);
|
||||
}, [currentVisionConfig]);
|
||||
|
||||
// ─── LLM filtering ───
|
||||
const filteredLLMGlobal = useMemo(() => {
|
||||
if (!llmGlobalConfigs) return [];
|
||||
|
|
@ -170,6 +213,33 @@ export function ModelSelector({
|
|||
|
||||
const totalImageModels = (imageGlobalConfigs?.length ?? 0) + (imageUserConfigs?.length ?? 0);
|
||||
|
||||
// ─── Vision filtering ───
|
||||
const filteredVisionGlobal = useMemo(() => {
|
||||
if (!visionGlobalConfigs) return [];
|
||||
if (!visionSearchQuery) return visionGlobalConfigs;
|
||||
const q = visionSearchQuery.toLowerCase();
|
||||
return visionGlobalConfigs.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.model_name.toLowerCase().includes(q) ||
|
||||
c.provider.toLowerCase().includes(q)
|
||||
);
|
||||
}, [visionGlobalConfigs, visionSearchQuery]);
|
||||
|
||||
const filteredVisionUser = useMemo(() => {
|
||||
if (!visionUserConfigs) return [];
|
||||
if (!visionSearchQuery) return visionUserConfigs;
|
||||
const q = visionSearchQuery.toLowerCase();
|
||||
return visionUserConfigs.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.model_name.toLowerCase().includes(q) ||
|
||||
c.provider.toLowerCase().includes(q)
|
||||
);
|
||||
}, [visionUserConfigs, visionSearchQuery]);
|
||||
|
||||
const totalVisionModels = (visionGlobalConfigs?.length ?? 0) + (visionUserConfigs?.length ?? 0);
|
||||
|
||||
// ─── Handlers ───
|
||||
const handleSelectLLM = useCallback(
|
||||
async (config: NewLLMConfigPublic | GlobalNewLLMConfig) => {
|
||||
|
|
@ -229,6 +299,30 @@ export function ModelSelector({
|
|||
[currentImageConfig, searchSpaceId, updatePreferences]
|
||||
);
|
||||
|
||||
const handleSelectVision = useCallback(
|
||||
async (configId: number) => {
|
||||
if (currentVisionConfig?.id === configId) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
if (!searchSpaceId) {
|
||||
toast.error("No search space selected");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
data: { vision_llm_config_id: configId },
|
||||
});
|
||||
toast.success("Vision model updated");
|
||||
setOpen(false);
|
||||
} catch {
|
||||
toast.error("Failed to switch vision model");
|
||||
}
|
||||
},
|
||||
[currentVisionConfig, searchSpaceId, updatePreferences]
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
|
|
@ -282,6 +376,23 @@ export function ModelSelector({
|
|||
) : (
|
||||
<ImageIcon className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-4 w-px bg-border/60 dark:bg-white/10 mx-0.5" />
|
||||
|
||||
{/* Vision section */}
|
||||
{currentVisionConfig ? (
|
||||
<>
|
||||
{getProviderIcon(currentVisionConfig.provider, {
|
||||
isAutoMode: isVisionAutoMode ?? false,
|
||||
})}
|
||||
<span className="max-w-[80px] md:max-w-[100px] truncate hidden md:inline">
|
||||
{currentVisionConfig.name}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<Eye className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground ml-1 shrink-0" />
|
||||
|
|
@ -295,25 +406,32 @@ export function ModelSelector({
|
|||
>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "llm" | "image")}
|
||||
onValueChange={(v) => setActiveTab(v as "llm" | "image" | "vision")}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="border-b border-border/80 dark:border-neutral-800">
|
||||
<TabsList className="w-full grid grid-cols-2 rounded-none rounded-t-lg bg-transparent h-11 p-0 gap-0">
|
||||
<TabsList className="w-full grid grid-cols-3 rounded-none rounded-t-lg bg-transparent h-11 p-0 gap-0">
|
||||
<TabsTrigger
|
||||
value="llm"
|
||||
className="gap-2 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
|
||||
className="gap-1.5 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
|
||||
>
|
||||
<Zap className="size-4" />
|
||||
<Zap className="size-3.5" />
|
||||
LLM
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="image"
|
||||
className="gap-2 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
|
||||
className="gap-1.5 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
|
||||
>
|
||||
<ImageIcon className="size-4" />
|
||||
<ImageIcon className="size-3.5" />
|
||||
Image
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="vision"
|
||||
className="gap-1.5 text-sm font-medium rounded-none text-muted-foreground transition-all duration-200 h-full bg-transparent data-[state=active]:bg-transparent shadow-none data-[state=active]:shadow-none border-b-[1.5px] border-transparent data-[state=active]:border-foreground dark:data-[state=active]:border-white data-[state=active]:text-foreground"
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
Vision
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
|
|
@ -676,6 +794,174 @@ export function ModelSelector({
|
|||
</CommandList>
|
||||
</Command>
|
||||
</TabsContent>
|
||||
|
||||
{/* ─── Vision Tab ─── */}
|
||||
<TabsContent value="vision" className="mt-0">
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
className="rounded-none rounded-b-lg dark:bg-neutral-900 [&_[data-slot=command-input-wrapper]]:border-0 [&_[data-slot=command-input-wrapper]]:px-0 [&_[data-slot=command-input-wrapper]]:gap-2"
|
||||
>
|
||||
{totalVisionModels > 3 && (
|
||||
<div className="px-2 md:px-3 py-1.5 md:py-2">
|
||||
<CommandInput
|
||||
placeholder="Search vision models"
|
||||
value={visionSearchQuery}
|
||||
onValueChange={setVisionSearchQuery}
|
||||
className="h-7 md:h-8 w-full text-xs md:text-sm border-0 bg-transparent focus:ring-0 placeholder:text-muted-foreground/60"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CommandList
|
||||
className="max-h-[300px] md:max-h-[400px] overflow-y-auto"
|
||||
onScroll={handleListScroll(setVisionScrollPos)}
|
||||
style={{
|
||||
maskImage: `linear-gradient(to bottom, ${visionScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${visionScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${visionScrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${visionScrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
<CommandEmpty className="py-8 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Search className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No vision models found</p>
|
||||
<p className="text-xs text-muted-foreground/60">Try a different search term</p>
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
|
||||
{filteredVisionGlobal.length > 0 && (
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||
Global Vision Models
|
||||
</div>
|
||||
{filteredVisionGlobal.map((config) => {
|
||||
const isSelected = currentVisionConfig?.id === config.id;
|
||||
const isAuto = "is_auto_mode" in config && config.is_auto_mode;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`vis-g-${config.id}`}
|
||||
value={`vis-g-${config.id}`}
|
||||
onSelect={() => handleSelectVision(config.id)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]",
|
||||
isSelected && "bg-accent/80 dark:bg-white/[0.06]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(config.provider, { isAutoMode: isAuto })}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{config.name}</span>
|
||||
{isAuto && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[9px] px-1 py-0 h-3.5 bg-violet-800 text-white dark:bg-violet-800 dark:text-white border-0"
|
||||
>
|
||||
Recommended
|
||||
</Badge>
|
||||
)}
|
||||
{isSelected && <Check className="size-3.5 text-primary shrink-0" />}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{isAuto ? "Auto Mode" : config.model_name}
|
||||
</span>
|
||||
</div>
|
||||
{onEditVision && !isAuto && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 shrink-0 rounded-md hover:bg-muted opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
onEditVision(config as VisionLLMConfig, true);
|
||||
}}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{filteredVisionUser.length > 0 && (
|
||||
<>
|
||||
{filteredVisionGlobal.length > 0 && (
|
||||
<CommandSeparator className="my-1 mx-4 bg-border/60" />
|
||||
)}
|
||||
<CommandGroup>
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs font-semibold text-muted-foreground tracking-wider">
|
||||
Your Vision Models
|
||||
</div>
|
||||
{filteredVisionUser.map((config) => {
|
||||
const isSelected = currentVisionConfig?.id === config.id;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`vis-u-${config.id}`}
|
||||
value={`vis-u-${config.id}`}
|
||||
onSelect={() => handleSelectVision(config.id)}
|
||||
className={cn(
|
||||
"mx-2 rounded-lg mb-1 cursor-pointer group transition-all hover:bg-accent/50 dark:hover:bg-white/[0.06]",
|
||||
isSelected && "bg-accent/80 dark:bg-white/[0.06]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="shrink-0">{getProviderIcon(config.provider)}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium truncate">{config.name}</span>
|
||||
{isSelected && (
|
||||
<Check className="size-3.5 text-primary shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground truncate block">
|
||||
{config.model_name}
|
||||
</span>
|
||||
</div>
|
||||
{onEditVision && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpen(false);
|
||||
onEditVision(config, false);
|
||||
}}
|
||||
>
|
||||
<Edit3 className="size-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onAddNewVision && (
|
||||
<div className="p-2 bg-muted/20 dark:bg-neutral-900">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 h-9 rounded-lg hover:bg-accent/50 dark:hover:bg-white/[0.06]"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onAddNewVision();
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4 text-primary" />
|
||||
<span className="text-sm font-medium">Add Vision Model</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ import {
|
|||
llmPreferencesAtom,
|
||||
newLLMConfigsAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import {
|
||||
globalVisionLLMConfigsAtom,
|
||||
visionLLMConfigsAtom,
|
||||
} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -77,8 +81,8 @@ const ROLE_DESCRIPTIONS = {
|
|||
description: "Vision-capable model for screenshot analysis and context extraction",
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
bgColor: "bg-amber-500/10",
|
||||
prefKey: "vision_llm_id" as const,
|
||||
configType: "llm" as const,
|
||||
prefKey: "vision_llm_config_id" as const,
|
||||
configType: "vision" as const,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -112,6 +116,18 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
error: globalImageConfigsError,
|
||||
} = useAtomValue(globalImageGenConfigsAtom);
|
||||
|
||||
// Vision LLM configs
|
||||
const {
|
||||
data: userVisionConfigs = [],
|
||||
isFetching: visionConfigsLoading,
|
||||
error: visionConfigsError,
|
||||
} = useAtomValue(visionLLMConfigsAtom);
|
||||
const {
|
||||
data: globalVisionConfigs = [],
|
||||
isFetching: globalVisionConfigsLoading,
|
||||
error: globalVisionConfigsError,
|
||||
} = useAtomValue(globalVisionLLMConfigsAtom);
|
||||
|
||||
// Preferences
|
||||
const {
|
||||
data: preferences = {},
|
||||
|
|
@ -125,7 +141,7 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
agent_llm_id: preferences.agent_llm_id ?? "",
|
||||
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? "",
|
||||
vision_llm_id: preferences.vision_llm_id ?? "",
|
||||
vision_llm_config_id: preferences.vision_llm_config_id ?? "",
|
||||
}));
|
||||
|
||||
const [savingRole, setSavingRole] = useState<string | null>(null);
|
||||
|
|
@ -137,14 +153,14 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
agent_llm_id: preferences.agent_llm_id ?? "",
|
||||
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? "",
|
||||
vision_llm_id: preferences.vision_llm_id ?? "",
|
||||
vision_llm_config_id: preferences.vision_llm_config_id ?? "",
|
||||
});
|
||||
}
|
||||
}, [
|
||||
preferences?.agent_llm_id,
|
||||
preferences?.document_summary_llm_id,
|
||||
preferences?.image_generation_config_id,
|
||||
preferences?.vision_llm_id,
|
||||
preferences?.vision_llm_config_id,
|
||||
]);
|
||||
|
||||
const handleRoleAssignment = useCallback(
|
||||
|
|
@ -181,6 +197,14 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
...(userImageConfigs ?? []).filter((config) => config.id && config.id.toString().trim() !== ""),
|
||||
];
|
||||
|
||||
// Combine global and custom vision LLM configs
|
||||
const allVisionConfigs = [
|
||||
...globalVisionConfigs.map((config) => ({ ...config, is_global: true })),
|
||||
...(userVisionConfigs ?? []).filter(
|
||||
(config) => config.id && config.id.toString().trim() !== ""
|
||||
),
|
||||
];
|
||||
|
||||
const isAssignmentComplete =
|
||||
allLLMConfigs.some((c) => c.id === assignments.agent_llm_id) &&
|
||||
allLLMConfigs.some((c) => c.id === assignments.document_summary_llm_id) &&
|
||||
|
|
@ -191,13 +215,17 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
preferencesLoading ||
|
||||
globalConfigsLoading ||
|
||||
imageConfigsLoading ||
|
||||
globalImageConfigsLoading;
|
||||
globalImageConfigsLoading ||
|
||||
visionConfigsLoading ||
|
||||
globalVisionConfigsLoading;
|
||||
const hasError =
|
||||
configsError ||
|
||||
preferencesError ||
|
||||
globalConfigsError ||
|
||||
imageConfigsError ||
|
||||
globalImageConfigsError;
|
||||
globalImageConfigsError ||
|
||||
visionConfigsError ||
|
||||
globalVisionConfigsError;
|
||||
const hasAnyConfigs = allLLMConfigs.length > 0 || allImageConfigs.length > 0;
|
||||
|
||||
return (
|
||||
|
|
@ -291,15 +319,27 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
|||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
|
||||
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
|
||||
const IconComponent = role.icon;
|
||||
const isImageRole = role.configType === "image";
|
||||
const currentAssignment = assignments[role.prefKey as keyof typeof assignments];
|
||||
|
||||
// Pick the right config lists based on role type
|
||||
const roleGlobalConfigs = isImageRole ? globalImageConfigs : globalConfigs;
|
||||
const roleUserConfigs = isImageRole
|
||||
? (userImageConfigs ?? []).filter((c) => c.id && c.id.toString().trim() !== "")
|
||||
: newLLMConfigs.filter((c) => c.id && c.id.toString().trim() !== "");
|
||||
const roleAllConfigs = isImageRole ? allImageConfigs : allLLMConfigs;
|
||||
const roleGlobalConfigs =
|
||||
role.configType === "image"
|
||||
? globalImageConfigs
|
||||
: role.configType === "vision"
|
||||
? globalVisionConfigs
|
||||
: globalConfigs;
|
||||
const roleUserConfigs =
|
||||
role.configType === "image"
|
||||
? (userImageConfigs ?? []).filter((c) => c.id && c.id.toString().trim() !== "")
|
||||
: role.configType === "vision"
|
||||
? (userVisionConfigs ?? []).filter((c) => c.id && c.id.toString().trim() !== "")
|
||||
: newLLMConfigs.filter((c) => c.id && c.id.toString().trim() !== "");
|
||||
const roleAllConfigs =
|
||||
role.configType === "image"
|
||||
? allImageConfigs
|
||||
: role.configType === "vision"
|
||||
? allVisionConfigs
|
||||
: allLLMConfigs;
|
||||
|
||||
const assignedConfig = roleAllConfigs.find((config) => config.id === currentAssignment);
|
||||
const isAssigned = !!assignedConfig;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { Bot, Brain, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
|
||||
import { Bot, Brain, Eye, FileText, Globe, ImageIcon, MessageSquare, Shield } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type React from "react";
|
||||
import { searchSpaceSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
|
||||
|
|
@ -13,6 +13,7 @@ import { ModelConfigManager } from "@/components/settings/model-config-manager";
|
|||
import { PromptConfigManager } from "@/components/settings/prompt-config-manager";
|
||||
import { RolesManager } from "@/components/settings/roles-manager";
|
||||
import { SettingsDialog } from "@/components/settings/settings-dialog";
|
||||
import { VisionModelManager } from "@/components/settings/vision-model-manager";
|
||||
|
||||
interface SearchSpaceSettingsDialogProps {
|
||||
searchSpaceId: number;
|
||||
|
|
@ -31,6 +32,11 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
|
|||
label: t("nav_image_models"),
|
||||
icon: <ImageIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "vision-models",
|
||||
label: t("nav_vision_models"),
|
||||
icon: <Eye className="h-4 w-4" />,
|
||||
},
|
||||
{ value: "team-roles", label: t("nav_team_roles"), icon: <Shield className="h-4 w-4" /> },
|
||||
{
|
||||
value: "prompts",
|
||||
|
|
@ -45,6 +51,7 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
|
|||
models: <ModelConfigManager searchSpaceId={searchSpaceId} />,
|
||||
roles: <LLMRoleManager searchSpaceId={searchSpaceId} />,
|
||||
"image-models": <ImageModelManager searchSpaceId={searchSpaceId} />,
|
||||
"vision-models": <VisionModelManager searchSpaceId={searchSpaceId} />,
|
||||
"team-roles": <RolesManager searchSpaceId={searchSpaceId} />,
|
||||
prompts: <PromptConfigManager searchSpaceId={searchSpaceId} />,
|
||||
"public-links": <PublicChatSnapshotsManager searchSpaceId={searchSpaceId} />,
|
||||
|
|
|
|||
401
surfsense_web/components/settings/vision-model-manager.tsx
Normal file
401
surfsense_web/components/settings/vision-model-manager.tsx
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Dot, Edit3, Info, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { deleteVisionLLMConfigMutationAtom } from "@/atoms/vision-llm-config/vision-llm-config-mutation.atoms";
|
||||
import {
|
||||
globalVisionLLMConfigsAtom,
|
||||
visionLLMConfigsAtom,
|
||||
} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
|
||||
import { VisionConfigDialog } from "@/components/shared/vision-config-dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { VisionLLMConfig } from "@/contracts/types/new-llm-config.types";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface VisionModelManagerProps {
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
const {
|
||||
mutateAsync: deleteConfig,
|
||||
isPending: isDeleting,
|
||||
error: deleteError,
|
||||
} = useAtomValue(deleteVisionLLMConfigMutationAtom);
|
||||
|
||||
const {
|
||||
data: userConfigs,
|
||||
isFetching: configsLoading,
|
||||
error: fetchError,
|
||||
refetch: refreshConfigs,
|
||||
} = useAtomValue(visionLLMConfigsAtom);
|
||||
const { data: globalConfigs = [], isFetching: globalLoading } = useAtomValue(
|
||||
globalVisionLLMConfigsAtom
|
||||
);
|
||||
|
||||
const { data: members } = useAtomValue(membersAtom);
|
||||
const memberMap = useMemo(() => {
|
||||
const map = new Map<string, { name: string; email?: string; avatarUrl?: string }>();
|
||||
if (members) {
|
||||
for (const m of members) {
|
||||
map.set(m.user_id, {
|
||||
name: m.user_display_name || m.user_email || "Unknown",
|
||||
email: m.user_email || undefined,
|
||||
avatarUrl: m.user_avatar_url || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [members]);
|
||||
|
||||
const { data: access } = useAtomValue(myAccessAtom);
|
||||
const canCreate = useMemo(() => {
|
||||
if (!access) return false;
|
||||
if (access.is_owner) return true;
|
||||
return access.permissions?.includes("vision_configs:create") ?? false;
|
||||
}, [access]);
|
||||
const canDelete = useMemo(() => {
|
||||
if (!access) return false;
|
||||
if (access.is_owner) return true;
|
||||
return access.permissions?.includes("vision_configs:delete") ?? false;
|
||||
}, [access]);
|
||||
const canUpdate = canCreate;
|
||||
const isReadOnly = !canCreate && !canDelete;
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<VisionLLMConfig | null>(null);
|
||||
const [configToDelete, setConfigToDelete] = useState<VisionLLMConfig | null>(null);
|
||||
|
||||
const isLoading = configsLoading || globalLoading;
|
||||
const errors = [deleteError, fetchError].filter(Boolean) as Error[];
|
||||
|
||||
const openEditDialog = (config: VisionLLMConfig) => {
|
||||
setEditingConfig(config);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const openNewDialog = () => {
|
||||
setEditingConfig(null);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!configToDelete) return;
|
||||
try {
|
||||
await deleteConfig({ id: configToDelete.id, name: configToDelete.name });
|
||||
setConfigToDelete(null);
|
||||
} catch {
|
||||
// Error handled by mutation
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => refreshConfigs()}
|
||||
disabled={isLoading}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", configsLoading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
{canCreate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={openNewDialog}
|
||||
className="gap-2 bg-white text-black hover:bg-neutral-100 dark:bg-white dark:text-black dark:hover:bg-neutral-200"
|
||||
>
|
||||
Add Vision Model
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errors.map((err) => (
|
||||
<div key={err?.message}>
|
||||
<Alert variant="destructive" className="py-3">
|
||||
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{access && !isLoading && isReadOnly && (
|
||||
<div>
|
||||
<Alert className="bg-muted/50 py-3 md:py-4">
|
||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
You have <span className="font-medium">read-only</span> access to vision model
|
||||
configurations. Contact a space owner to request additional permissions.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
{access && !isLoading && !isReadOnly && (!canCreate || !canDelete) && (
|
||||
<div>
|
||||
<Alert className="bg-muted/50 py-3 md:py-4">
|
||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
You can{" "}
|
||||
{[canCreate && "create and edit", canDelete && "delete"]
|
||||
.filter(Boolean)
|
||||
.join(" and ")}{" "}
|
||||
vision model configurations
|
||||
{!canDelete && ", but cannot delete them"}.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
|
||||
<Alert className="bg-muted/50 py-3">
|
||||
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length}{" "}
|
||||
global vision{" "}
|
||||
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length === 1
|
||||
? "model"
|
||||
: "models"}
|
||||
</span>{" "}
|
||||
available from your administrator. Use the model selector to view and select them.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 md:h-7 w-40 md:w-48" />
|
||||
<Skeleton className="h-8 md:h-9 w-32 md:w-36 rounded-md" />
|
||||
</div>
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||
<Card key={key} className="border-border/60">
|
||||
<CardContent className="p-4 flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="space-y-1.5 flex-1 min-w-0">
|
||||
<Skeleton className="h-4 w-28 md:w-32" />
|
||||
<Skeleton className="h-3 w-40 md:w-48" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-16 rounded-full" />
|
||||
<Skeleton className="h-5 w-24 rounded-md" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{(userConfigs?.length ?? 0) === 0 ? (
|
||||
<Card className="border-0 bg-transparent shadow-none">
|
||||
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
|
||||
<h3 className="text-sm md:text-base font-semibold mb-2">No Vision Models Yet</h3>
|
||||
<p className="text-[11px] md:text-xs text-muted-foreground max-w-sm mb-4">
|
||||
{canCreate
|
||||
? "Add your own vision-capable model (GPT-4o, Claude, Gemini, etc.)"
|
||||
: "No vision models have been added to this space yet. Contact a space owner to add one."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{userConfigs?.map((config) => {
|
||||
const member = config.user_id ? memberMap.get(config.user_id) : null;
|
||||
|
||||
return (
|
||||
<div key={config.id}>
|
||||
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
|
||||
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-sm font-semibold tracking-tight truncate">
|
||||
{config.name}
|
||||
</h4>
|
||||
{config.description && (
|
||||
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{(canUpdate || canDelete) && (
|
||||
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
|
||||
{canUpdate && (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEditDialog(config)}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{canDelete && (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setConfigToDelete(config)}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{getProviderIcon(config.provider, {
|
||||
className: "size-3.5 shrink-0",
|
||||
})}
|
||||
<code className="text-[11px] font-mono text-muted-foreground bg-muted/60 px-2 py-0.5 rounded-md truncate max-w-[160px]">
|
||||
{config.model_name}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border/40 mt-auto">
|
||||
<span className="text-[11px] text-muted-foreground/60">
|
||||
{new Date(config.created_at).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
{member && (
|
||||
<>
|
||||
<Dot className="h-4 w-4 text-muted-foreground/30" />
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-1.5 cursor-default">
|
||||
<Avatar className="size-4.5 shrink-0">
|
||||
{member.avatarUrl && (
|
||||
<AvatarImage src={member.avatarUrl} alt={member.name} />
|
||||
)}
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{getInitials(member.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
|
||||
{member.name}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{member.email || member.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VisionConfigDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDialogOpen(open);
|
||||
if (!open) setEditingConfig(null);
|
||||
}}
|
||||
config={editingConfig}
|
||||
isGlobal={false}
|
||||
searchSpaceId={searchSpaceId}
|
||||
mode={editingConfig ? "edit" : "create"}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={!!configToDelete}
|
||||
onOpenChange={(open) => !open && setConfigToDelete(null)}
|
||||
>
|
||||
<AlertDialogContent className="select-none">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Vision Model</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-foreground">{configToDelete?.name}</span>?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
<span className={isDeleting ? "opacity-0" : ""}>Delete</span>
|
||||
{isDeleting && <Spinner size="sm" className="absolute" />}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
381
surfsense_web/components/shared/vision-config-dialog.tsx
Normal file
381
surfsense_web/components/shared/vision-config-dialog.tsx
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import {
|
||||
createVisionLLMConfigMutationAtom,
|
||||
updateVisionLLMConfigMutationAtom,
|
||||
} from "@/atoms/vision-llm-config/vision-llm-config-mutation.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { VISION_PROVIDERS } from "@/contracts/enums/vision-providers";
|
||||
import type {
|
||||
GlobalVisionLLMConfig,
|
||||
VisionLLMConfig,
|
||||
VisionProvider,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
|
||||
interface VisionConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
config: VisionLLMConfig | GlobalVisionLLMConfig | null;
|
||||
isGlobal: boolean;
|
||||
searchSpaceId: number;
|
||||
mode: "create" | "edit" | "view";
|
||||
}
|
||||
|
||||
const INITIAL_FORM = {
|
||||
name: "",
|
||||
description: "",
|
||||
provider: "",
|
||||
model_name: "",
|
||||
api_key: "",
|
||||
api_base: "",
|
||||
api_version: "",
|
||||
};
|
||||
|
||||
export function VisionConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
config,
|
||||
isGlobal,
|
||||
searchSpaceId,
|
||||
mode,
|
||||
}: VisionConfigDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState(INITIAL_FORM);
|
||||
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (mode === "edit" && config && !isGlobal) {
|
||||
setFormData({
|
||||
name: config.name || "",
|
||||
description: config.description || "",
|
||||
provider: config.provider || "",
|
||||
model_name: config.model_name || "",
|
||||
api_key: (config as VisionLLMConfig).api_key || "",
|
||||
api_base: config.api_base || "",
|
||||
api_version: (config as VisionLLMConfig).api_version || "",
|
||||
});
|
||||
} else if (mode === "create") {
|
||||
setFormData(INITIAL_FORM);
|
||||
}
|
||||
setScrollPos("top");
|
||||
}
|
||||
}, [open, mode, config, isGlobal]);
|
||||
|
||||
const { mutateAsync: createConfig } = useAtomValue(createVisionLLMConfigMutationAtom);
|
||||
const { mutateAsync: updateConfig } = useAtomValue(updateVisionLLMConfigMutationAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}, []);
|
||||
|
||||
const getTitle = () => {
|
||||
if (mode === "create") return "Add Vision Model";
|
||||
if (isGlobal) return "View Global Vision Model";
|
||||
return "Edit Vision Model";
|
||||
};
|
||||
|
||||
const getSubtitle = () => {
|
||||
if (mode === "create") return "Set up a new vision-capable LLM provider";
|
||||
if (isGlobal) return "Read-only global configuration";
|
||||
return "Update your vision model settings";
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const result = await createConfig({
|
||||
name: formData.name,
|
||||
provider: formData.provider as VisionProvider,
|
||||
model_name: formData.model_name,
|
||||
api_key: formData.api_key,
|
||||
api_base: formData.api_base || undefined,
|
||||
api_version: formData.api_version || undefined,
|
||||
description: formData.description || undefined,
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
if (result?.id) {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: { vision_llm_config_id: result.id },
|
||||
});
|
||||
}
|
||||
onOpenChange(false);
|
||||
} else if (!isGlobal && config) {
|
||||
await updateConfig({
|
||||
id: config.id,
|
||||
data: {
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
provider: formData.provider as VisionProvider,
|
||||
model_name: formData.model_name,
|
||||
api_key: formData.api_key,
|
||||
api_base: formData.api_base || undefined,
|
||||
api_version: formData.api_version || undefined,
|
||||
},
|
||||
});
|
||||
onOpenChange(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save vision config:", error);
|
||||
toast.error("Failed to save vision model");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [
|
||||
mode,
|
||||
isGlobal,
|
||||
config,
|
||||
formData,
|
||||
searchSpaceId,
|
||||
createConfig,
|
||||
updateConfig,
|
||||
updatePreferences,
|
||||
onOpenChange,
|
||||
]);
|
||||
|
||||
const handleUseGlobalConfig = useCallback(async () => {
|
||||
if (!config || !isGlobal) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: { vision_llm_config_id: config.id },
|
||||
});
|
||||
toast.success(`Now using ${config.name}`);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to set vision model:", error);
|
||||
toast.error("Failed to set vision model");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
|
||||
|
||||
const isFormValid = formData.name && formData.provider && formData.model_name && formData.api_key;
|
||||
const selectedProvider = VISION_PROVIDERS.find((p) => p.value === formData.provider);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-lg h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogTitle className="sr-only">{getTitle()}</DialogTitle>
|
||||
|
||||
<div className="flex items-start justify-between px-6 pt-6 pb-4 pr-14">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
|
||||
{isGlobal && mode !== "create" && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Global
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
||||
{config && mode !== "create" && (
|
||||
<p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto px-6 py-5"
|
||||
style={{
|
||||
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
{isGlobal && config && (
|
||||
<>
|
||||
<Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
|
||||
<AlertCircle className="size-4 text-amber-500" />
|
||||
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
|
||||
Global configurations are read-only. To customize, create a new model.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Name
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.name}</p>
|
||||
</div>
|
||||
{config.description && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Description
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{config.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Provider
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.provider}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Model
|
||||
</div>
|
||||
<p className="text-sm font-medium font-mono">{config.model_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Name *</Label>
|
||||
<Input
|
||||
placeholder="e.g., My GPT-4o Vision"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Description</Label>
|
||||
<Input
|
||||
placeholder="Optional description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Provider *</Label>
|
||||
<Select
|
||||
value={formData.provider}
|
||||
onValueChange={(val) =>
|
||||
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VISION_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value} description={p.example}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Model Name *</Label>
|
||||
<Input
|
||||
placeholder={selectedProvider?.example?.split(",")[0]?.trim() || "e.g., gpt-4o"}
|
||||
value={formData.model_name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, model_name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Key *</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
value={formData.api_key}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, api_key: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Base URL</Label>
|
||||
<Input
|
||||
placeholder={selectedProvider?.apiBase || "Optional"}
|
||||
value={formData.api_base}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, api_base: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.provider === "AZURE_OPENAI" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Version (Azure)</Label>
|
||||
<Input
|
||||
placeholder="2024-02-15-preview"
|
||||
value={formData.api_version}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
className="text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{mode === "create" || (mode === "edit" && !isGlobal) ? (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !isFormValid}
|
||||
className="relative text-sm h-9 min-w-[120px]"
|
||||
>
|
||||
<span className={isSubmitting ? "opacity-0" : ""}>
|
||||
{mode === "edit" ? "Save Changes" : "Add Model"}
|
||||
</span>
|
||||
{isSubmitting && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
) : isGlobal && config ? (
|
||||
<Button
|
||||
className="relative text-sm h-9"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span>
|
||||
{isSubmitting && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
102
surfsense_web/contracts/enums/vision-providers.ts
Normal file
102
surfsense_web/contracts/enums/vision-providers.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
export interface VisionProviderInfo {
|
||||
value: string;
|
||||
label: string;
|
||||
example: string;
|
||||
description: string;
|
||||
apiBase?: string;
|
||||
}
|
||||
|
||||
export const VISION_PROVIDERS: VisionProviderInfo[] = [
|
||||
{
|
||||
value: "OPENAI",
|
||||
label: "OpenAI",
|
||||
example: "gpt-4o, gpt-4o-mini",
|
||||
description: "GPT-4o vision models",
|
||||
},
|
||||
{
|
||||
value: "ANTHROPIC",
|
||||
label: "Anthropic",
|
||||
example: "claude-sonnet-4-20250514",
|
||||
description: "Claude vision models",
|
||||
},
|
||||
{
|
||||
value: "GOOGLE",
|
||||
label: "Google AI Studio",
|
||||
example: "gemini-2.5-flash, gemini-2.0-flash",
|
||||
description: "Gemini vision models",
|
||||
},
|
||||
{
|
||||
value: "AZURE_OPENAI",
|
||||
label: "Azure OpenAI",
|
||||
example: "azure/gpt-4o",
|
||||
description: "OpenAI vision models on Azure",
|
||||
},
|
||||
{
|
||||
value: "VERTEX_AI",
|
||||
label: "Google Vertex AI",
|
||||
example: "vertex_ai/gemini-2.5-flash",
|
||||
description: "Gemini vision models on Vertex AI",
|
||||
},
|
||||
{
|
||||
value: "BEDROCK",
|
||||
label: "AWS Bedrock",
|
||||
example: "bedrock/anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
description: "Vision models on AWS Bedrock",
|
||||
},
|
||||
{
|
||||
value: "XAI",
|
||||
label: "xAI",
|
||||
example: "grok-2-vision",
|
||||
description: "Grok vision models",
|
||||
},
|
||||
{
|
||||
value: "OPENROUTER",
|
||||
label: "OpenRouter",
|
||||
example: "openrouter/openai/gpt-4o",
|
||||
description: "Vision models via OpenRouter",
|
||||
},
|
||||
{
|
||||
value: "OLLAMA",
|
||||
label: "Ollama",
|
||||
example: "llava, bakllava",
|
||||
description: "Local vision models via Ollama",
|
||||
apiBase: "http://localhost:11434",
|
||||
},
|
||||
{
|
||||
value: "GROQ",
|
||||
label: "Groq",
|
||||
example: "llama-4-scout-17b-16e-instruct",
|
||||
description: "Vision models on Groq",
|
||||
},
|
||||
{
|
||||
value: "TOGETHER_AI",
|
||||
label: "Together AI",
|
||||
example: "meta-llama/Llama-4-Scout-17B-16E-Instruct",
|
||||
description: "Vision models on Together AI",
|
||||
},
|
||||
{
|
||||
value: "FIREWORKS_AI",
|
||||
label: "Fireworks AI",
|
||||
example: "fireworks_ai/phi-3-vision-128k-instruct",
|
||||
description: "Vision models on Fireworks AI",
|
||||
},
|
||||
{
|
||||
value: "DEEPSEEK",
|
||||
label: "DeepSeek",
|
||||
example: "deepseek-chat",
|
||||
description: "DeepSeek vision models",
|
||||
apiBase: "https://api.deepseek.com",
|
||||
},
|
||||
{
|
||||
value: "MISTRAL",
|
||||
label: "Mistral",
|
||||
example: "pixtral-large-latest",
|
||||
description: "Pixtral vision models",
|
||||
},
|
||||
{
|
||||
value: "CUSTOM",
|
||||
label: "Custom Provider",
|
||||
example: "custom/my-vision-model",
|
||||
description: "Custom OpenAI-compatible vision endpoint",
|
||||
},
|
||||
];
|
||||
|
|
@ -252,23 +252,99 @@ export const globalImageGenConfig = z.object({
|
|||
|
||||
export const getGlobalImageGenConfigsResponse = z.array(globalImageGenConfig);
|
||||
|
||||
// =============================================================================
|
||||
// Vision LLM Config (separate table for vision-capable models)
|
||||
// =============================================================================
|
||||
|
||||
export const visionProviderEnum = z.enum([
|
||||
"OPENAI",
|
||||
"ANTHROPIC",
|
||||
"GOOGLE",
|
||||
"AZURE_OPENAI",
|
||||
"VERTEX_AI",
|
||||
"BEDROCK",
|
||||
"XAI",
|
||||
"OPENROUTER",
|
||||
"OLLAMA",
|
||||
"GROQ",
|
||||
"TOGETHER_AI",
|
||||
"FIREWORKS_AI",
|
||||
"DEEPSEEK",
|
||||
"MISTRAL",
|
||||
"CUSTOM",
|
||||
]);
|
||||
|
||||
export type VisionProvider = z.infer<typeof visionProviderEnum>;
|
||||
|
||||
export const visionLLMConfig = z.object({
|
||||
id: z.number(),
|
||||
name: z.string().max(100),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
provider: visionProviderEnum,
|
||||
custom_provider: z.string().max(100).nullable().optional(),
|
||||
model_name: z.string().max(100),
|
||||
api_key: z.string(),
|
||||
api_base: z.string().max(500).nullable().optional(),
|
||||
api_version: z.string().max(50).nullable().optional(),
|
||||
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
|
||||
created_at: z.string(),
|
||||
search_space_id: z.number(),
|
||||
user_id: z.string(),
|
||||
});
|
||||
|
||||
export const createVisionLLMConfigRequest = visionLLMConfig.omit({
|
||||
id: true,
|
||||
created_at: true,
|
||||
user_id: true,
|
||||
});
|
||||
|
||||
export const createVisionLLMConfigResponse = visionLLMConfig;
|
||||
|
||||
export const getVisionLLMConfigsResponse = z.array(visionLLMConfig);
|
||||
|
||||
export const updateVisionLLMConfigRequest = z.object({
|
||||
id: z.number(),
|
||||
data: visionLLMConfig
|
||||
.omit({ id: true, created_at: true, search_space_id: true, user_id: true })
|
||||
.partial(),
|
||||
});
|
||||
|
||||
export const updateVisionLLMConfigResponse = visionLLMConfig;
|
||||
|
||||
export const deleteVisionLLMConfigResponse = z.object({
|
||||
message: z.string(),
|
||||
id: z.number(),
|
||||
});
|
||||
|
||||
export const globalVisionLLMConfig = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
provider: z.string(),
|
||||
custom_provider: z.string().nullable().optional(),
|
||||
model_name: z.string(),
|
||||
api_base: z.string().nullable().optional(),
|
||||
api_version: z.string().nullable().optional(),
|
||||
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
|
||||
is_global: z.literal(true),
|
||||
is_auto_mode: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export const getGlobalVisionLLMConfigsResponse = z.array(globalVisionLLMConfig);
|
||||
|
||||
// =============================================================================
|
||||
// LLM Preferences (Role Assignments)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* LLM Preferences schemas - for role assignments
|
||||
* image_generation uses image_generation_config_id (not llm_id)
|
||||
*/
|
||||
export const llmPreferences = z.object({
|
||||
agent_llm_id: z.union([z.number(), z.null()]).optional(),
|
||||
document_summary_llm_id: z.union([z.number(), z.null()]).optional(),
|
||||
image_generation_config_id: z.union([z.number(), z.null()]).optional(),
|
||||
vision_llm_id: z.union([z.number(), z.null()]).optional(),
|
||||
vision_llm_config_id: z.union([z.number(), z.null()]).optional(),
|
||||
agent_llm: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||
document_summary_llm: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||
image_generation_config: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||
vision_llm: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||
vision_llm_config: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -289,7 +365,7 @@ export const updateLLMPreferencesRequest = z.object({
|
|||
agent_llm_id: true,
|
||||
document_summary_llm_id: true,
|
||||
image_generation_config_id: true,
|
||||
vision_llm_id: true,
|
||||
vision_llm_config_id: true,
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
@ -341,6 +417,15 @@ export type UpdateImageGenConfigResponse = z.infer<typeof updateImageGenConfigRe
|
|||
export type DeleteImageGenConfigResponse = z.infer<typeof deleteImageGenConfigResponse>;
|
||||
export type GlobalImageGenConfig = z.infer<typeof globalImageGenConfig>;
|
||||
export type GetGlobalImageGenConfigsResponse = z.infer<typeof getGlobalImageGenConfigsResponse>;
|
||||
export type VisionLLMConfig = z.infer<typeof visionLLMConfig>;
|
||||
export type CreateVisionLLMConfigRequest = z.infer<typeof createVisionLLMConfigRequest>;
|
||||
export type CreateVisionLLMConfigResponse = z.infer<typeof createVisionLLMConfigResponse>;
|
||||
export type GetVisionLLMConfigsResponse = z.infer<typeof getVisionLLMConfigsResponse>;
|
||||
export type UpdateVisionLLMConfigRequest = z.infer<typeof updateVisionLLMConfigRequest>;
|
||||
export type UpdateVisionLLMConfigResponse = z.infer<typeof updateVisionLLMConfigResponse>;
|
||||
export type DeleteVisionLLMConfigResponse = z.infer<typeof deleteVisionLLMConfigResponse>;
|
||||
export type GlobalVisionLLMConfig = z.infer<typeof globalVisionLLMConfig>;
|
||||
export type GetGlobalVisionLLMConfigsResponse = z.infer<typeof getGlobalVisionLLMConfigsResponse>;
|
||||
export type LLMPreferences = z.infer<typeof llmPreferences>;
|
||||
export type GetLLMPreferencesRequest = z.infer<typeof getLLMPreferencesRequest>;
|
||||
export type GetLLMPreferencesResponse = z.infer<typeof getLLMPreferencesResponse>;
|
||||
|
|
|
|||
58
surfsense_web/lib/apis/vision-llm-config-api.service.ts
Normal file
58
surfsense_web/lib/apis/vision-llm-config-api.service.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import {
|
||||
type CreateVisionLLMConfigRequest,
|
||||
createVisionLLMConfigRequest,
|
||||
createVisionLLMConfigResponse,
|
||||
deleteVisionLLMConfigResponse,
|
||||
getGlobalVisionLLMConfigsResponse,
|
||||
getVisionLLMConfigsResponse,
|
||||
type UpdateVisionLLMConfigRequest,
|
||||
updateVisionLLMConfigRequest,
|
||||
updateVisionLLMConfigResponse,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { ValidationError } from "../error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
class VisionLLMConfigApiService {
|
||||
getGlobalConfigs = async () => {
|
||||
return baseApiService.get(
|
||||
`/api/v1/global-vision-llm-configs`,
|
||||
getGlobalVisionLLMConfigsResponse
|
||||
);
|
||||
};
|
||||
|
||||
createConfig = async (request: CreateVisionLLMConfigRequest) => {
|
||||
const parsed = createVisionLLMConfigRequest.safeParse(request);
|
||||
if (!parsed.success) {
|
||||
const msg = parsed.error.issues.map((i) => i.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${msg}`);
|
||||
}
|
||||
return baseApiService.post(`/api/v1/vision-llm-configs`, createVisionLLMConfigResponse, {
|
||||
body: parsed.data,
|
||||
});
|
||||
};
|
||||
|
||||
getConfigs = async (searchSpaceId: number) => {
|
||||
const params = new URLSearchParams({
|
||||
search_space_id: String(searchSpaceId),
|
||||
}).toString();
|
||||
return baseApiService.get(`/api/v1/vision-llm-configs?${params}`, getVisionLLMConfigsResponse);
|
||||
};
|
||||
|
||||
updateConfig = async (request: UpdateVisionLLMConfigRequest) => {
|
||||
const parsed = updateVisionLLMConfigRequest.safeParse(request);
|
||||
if (!parsed.success) {
|
||||
const msg = parsed.error.issues.map((i) => i.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${msg}`);
|
||||
}
|
||||
const { id, data } = parsed.data;
|
||||
return baseApiService.put(`/api/v1/vision-llm-configs/${id}`, updateVisionLLMConfigResponse, {
|
||||
body: data,
|
||||
});
|
||||
};
|
||||
|
||||
deleteConfig = async (id: number) => {
|
||||
return baseApiService.delete(`/api/v1/vision-llm-configs/${id}`, deleteVisionLLMConfigResponse);
|
||||
};
|
||||
}
|
||||
|
||||
export const visionLLMConfigApiService = new VisionLLMConfigApiService();
|
||||
|
|
@ -39,6 +39,11 @@ export const cacheKeys = {
|
|||
byId: (configId: number) => ["image-gen-configs", "detail", configId] as const,
|
||||
global: () => ["image-gen-configs", "global"] as const,
|
||||
},
|
||||
visionLLMConfigs: {
|
||||
all: (searchSpaceId: number) => ["vision-llm-configs", searchSpaceId] as const,
|
||||
byId: (configId: number) => ["vision-llm-configs", "detail", configId] as const,
|
||||
global: () => ["vision-llm-configs", "global"] as const,
|
||||
},
|
||||
auth: {
|
||||
user: ["auth", "user"] as const,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -738,6 +738,8 @@
|
|||
"nav_role_assignments_desc": "Assign configs to agent roles",
|
||||
"nav_image_models": "Image Models",
|
||||
"nav_image_models_desc": "Configure image generation models",
|
||||
"nav_vision_models": "Vision Models",
|
||||
"nav_vision_models_desc": "Configure vision-capable LLM models",
|
||||
"nav_system_instructions": "System Instructions",
|
||||
"nav_system_instructions_desc": "SearchSpace-wide AI instructions",
|
||||
"nav_public_links": "Public Chat Links",
|
||||
|
|
|
|||
|
|
@ -738,6 +738,8 @@
|
|||
"nav_role_assignments_desc": "Asignar configuraciones a roles de agente",
|
||||
"nav_image_models": "Modelos de imagen",
|
||||
"nav_image_models_desc": "Configurar modelos de generación de imágenes",
|
||||
"nav_vision_models": "Modelos de visión",
|
||||
"nav_vision_models_desc": "Configurar modelos LLM con capacidad de visión",
|
||||
"nav_system_instructions": "Instrucciones del sistema",
|
||||
"nav_system_instructions_desc": "Instrucciones de IA a nivel del espacio de búsqueda",
|
||||
"nav_public_links": "Enlaces de chat públicos",
|
||||
|
|
|
|||
|
|
@ -738,6 +738,8 @@
|
|||
"nav_role_assignments_desc": "एजेंट भूमिकाओं को कॉन्फ़िगरेशन असाइन करें",
|
||||
"nav_image_models": "इमेज मॉडल",
|
||||
"nav_image_models_desc": "इमेज जनरेशन मॉडल कॉन्फ़िगर करें",
|
||||
"nav_vision_models": "विज़न मॉडल",
|
||||
"nav_vision_models_desc": "विज़न-सक्षम LLM मॉडल कॉन्फ़िगर करें",
|
||||
"nav_system_instructions": "सिस्टम निर्देश",
|
||||
"nav_system_instructions_desc": "सर्च स्पेस-व्यापी AI निर्देश",
|
||||
"nav_public_links": "सार्वजनिक चैट लिंक",
|
||||
|
|
|
|||
|
|
@ -738,6 +738,8 @@
|
|||
"nav_role_assignments_desc": "Atribuir configurações a funções do agente",
|
||||
"nav_image_models": "Modelos de imagem",
|
||||
"nav_image_models_desc": "Configurar modelos de geração de imagens",
|
||||
"nav_vision_models": "Modelos de visão",
|
||||
"nav_vision_models_desc": "Configurar modelos LLM com capacidade de visão",
|
||||
"nav_system_instructions": "Instruções do sistema",
|
||||
"nav_system_instructions_desc": "Instruções de IA em nível do espaço de pesquisa",
|
||||
"nav_public_links": "Links de chat públicos",
|
||||
|
|
|
|||
|
|
@ -722,6 +722,8 @@
|
|||
"nav_role_assignments_desc": "为代理角色分配配置",
|
||||
"nav_image_models": "图像模型",
|
||||
"nav_image_models_desc": "配置图像生成模型",
|
||||
"nav_vision_models": "视觉模型",
|
||||
"nav_vision_models_desc": "配置具有视觉能力的LLM模型",
|
||||
"nav_system_instructions": "系统指令",
|
||||
"nav_system_instructions_desc": "搜索空间级别的 AI 指令",
|
||||
"nav_public_links": "公开聊天链接",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue