mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-04 14:32:41 +00:00
Compare commits
17 Commits
cli/v0.2.1
...
multi-mode
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c11d5fb62a | ||
|
|
005c27975f | ||
|
|
c7c689f6cc | ||
|
|
c9f124b109 | ||
|
|
f6a821b66d | ||
|
|
7272a1b4c4 | ||
|
|
503fab60dc | ||
|
|
4b174712cc | ||
|
|
327b0ff4a1 | ||
|
|
5a8b6f0217 | ||
|
|
9b2f8c8016 | ||
|
|
ec5eb75722 | ||
|
|
3ca304d228 | ||
|
|
a5c982fb75 | ||
|
|
2d3c838707 | ||
|
|
d936efeffd | ||
|
|
e1e7693870 |
126
web/src/app/app/message/MultiModelPanel.tsx
Normal file
126
web/src/app/app/message/MultiModelPanel.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Button } from "@opal/components";
|
||||
import { Text } from "@opal/components";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { SvgEyeOff, SvgX } from "@opal/icons";
|
||||
import { getProviderIcon } from "@/app/admin/configuration/llm/utils";
|
||||
import AgentMessage, {
|
||||
AgentMessageProps,
|
||||
} from "@/app/app/message/messageComponents/AgentMessage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { markdown } from "@opal/utils";
|
||||
|
||||
export interface MultiModelPanelProps {
|
||||
/** Provider name for icon lookup */
|
||||
provider: string;
|
||||
/** Model name for icon lookup and display */
|
||||
modelName: string;
|
||||
/** Display-friendly model name */
|
||||
displayName: string;
|
||||
/** Whether this panel is the preferred/selected response */
|
||||
isPreferred: boolean;
|
||||
/** Whether this panel is currently hidden */
|
||||
isHidden: boolean;
|
||||
/** Whether this is a non-preferred panel in selection mode (pushed off-screen) */
|
||||
isNonPreferredInSelection: boolean;
|
||||
/** Callback when user clicks this panel to select as preferred */
|
||||
onSelect: () => void;
|
||||
/** Callback to hide/show this panel */
|
||||
onToggleVisibility: () => void;
|
||||
/** Props to pass through to AgentMessage */
|
||||
agentMessageProps: AgentMessageProps;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single model's response panel within the multi-model view.
|
||||
*
|
||||
* Renders in two states:
|
||||
* - **Hidden** — compact header strip only (provider icon + strikethrough name + show button).
|
||||
* - **Visible** — full header plus `AgentMessage` body. Clicking anywhere on a
|
||||
* visible non-preferred panel marks it as preferred.
|
||||
*
|
||||
* The `isNonPreferredInSelection` flag disables pointer events on the body and
|
||||
* hides the footer so the panel acts as a passive comparison surface.
|
||||
*/
|
||||
export default function MultiModelPanel({
|
||||
provider,
|
||||
modelName,
|
||||
displayName,
|
||||
isPreferred,
|
||||
isHidden,
|
||||
isNonPreferredInSelection,
|
||||
onSelect,
|
||||
onToggleVisibility,
|
||||
agentMessageProps,
|
||||
}: MultiModelPanelProps) {
|
||||
const ProviderIcon = getProviderIcon(provider, modelName);
|
||||
|
||||
const handlePanelClick = useCallback(() => {
|
||||
if (!isHidden) onSelect();
|
||||
}, [isHidden, onSelect]);
|
||||
|
||||
const header = (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-12",
|
||||
isPreferred ? "bg-background-tint-02" : "bg-background-tint-00"
|
||||
)}
|
||||
>
|
||||
<ContentAction
|
||||
sizePreset="main-ui"
|
||||
variant="body"
|
||||
paddingVariant="lg"
|
||||
icon={ProviderIcon}
|
||||
title={isHidden ? markdown(`~~${displayName}~~`) : displayName}
|
||||
rightChildren={
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
{isPreferred && (
|
||||
<span className="text-action-link-05 shrink-0">
|
||||
<Text font="secondary-body" color="inherit" nowrap>
|
||||
Preferred Response
|
||||
</Text>
|
||||
</span>
|
||||
)}
|
||||
{!isPreferred && (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={isHidden ? SvgEyeOff : SvgX}
|
||||
size="md"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleVisibility();
|
||||
}}
|
||||
tooltip={isHidden ? "Show response" : "Hide response"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Hidden/collapsed panel — just the header row
|
||||
if (isHidden) {
|
||||
return header;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-3 min-w-0 cursor-pointer rounded-16 transition-colors",
|
||||
!isPreferred && "hover:bg-background-tint-02"
|
||||
)}
|
||||
onClick={handlePanelClick}
|
||||
>
|
||||
{header}
|
||||
<div className={cn(isNonPreferredInSelection && "pointer-events-none")}>
|
||||
<AgentMessage
|
||||
{...agentMessageProps}
|
||||
hideFooter={isNonPreferredInSelection}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
372
web/src/app/app/message/MultiModelResponseView.tsx
Normal file
372
web/src/app/app/message/MultiModelResponseView.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
|
||||
import { FullChatState } from "@/app/app/message/messageComponents/interfaces";
|
||||
import { Message } from "@/app/app/interfaces";
|
||||
import { LlmManager } from "@/lib/hooks";
|
||||
import { RegenerationFactory } from "@/app/app/message/messageComponents/AgentMessage";
|
||||
import MultiModelPanel from "@/app/app/message/MultiModelPanel";
|
||||
import { MultiModelResponse } from "@/app/app/message/interfaces";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface MultiModelResponseViewProps {
|
||||
responses: MultiModelResponse[];
|
||||
chatState: FullChatState;
|
||||
llmManager: LlmManager | null;
|
||||
onRegenerate?: RegenerationFactory;
|
||||
parentMessage?: Message | null;
|
||||
otherMessagesCanSwitchTo?: number[];
|
||||
onMessageSelection?: (nodeId: number) => void;
|
||||
/** Called whenever the set of hidden panel indices changes */
|
||||
onHiddenPanelsChange?: (hidden: Set<number>) => void;
|
||||
}
|
||||
|
||||
// How many pixels of a non-preferred panel are visible at the viewport edge
|
||||
const PEEK_W = 64;
|
||||
// Uniform panel width used in the selection-mode carousel
|
||||
const SELECTION_PANEL_W = 400;
|
||||
// Compact width for hidden panels in the carousel track
|
||||
const HIDDEN_PANEL_W = 220;
|
||||
// Generation-mode panel widths (from Figma)
|
||||
const GEN_PANEL_W_2 = 640; // 2 panels side-by-side
|
||||
const GEN_PANEL_W_3 = 436; // 3 panels side-by-side
|
||||
// Gap between panels — matches CSS gap-6 (24px)
|
||||
const PANEL_GAP = 24;
|
||||
// Minimum panel width before horizontal scroll kicks in
|
||||
const MIN_PANEL_W = 300;
|
||||
|
||||
/**
|
||||
* Renders N model responses side-by-side with two layout modes:
|
||||
*
|
||||
* **Generation mode** — equal-width panels in a horizontally-scrollable row.
|
||||
* Panel width is determined by the number of visible (non-hidden) panels.
|
||||
*
|
||||
* **Selection mode** — activated when the user clicks a panel to mark it as
|
||||
* preferred. All panels (including hidden ones) sit in a fixed-width carousel
|
||||
* track. A CSS `translateX` transform slides the track so the preferred panel
|
||||
* is centered in the viewport; the other panels peek in from the edges through
|
||||
* a mask gradient. Non-preferred visible panels are height-capped to the
|
||||
* preferred panel's measured height, dimmed at 50% opacity, and receive a
|
||||
* bottom fade-out overlay.
|
||||
*
|
||||
* Hidden panels render as a compact header-only strip at `HIDDEN_PANEL_W` in
|
||||
* both modes and are excluded from layout width calculations.
|
||||
*/
|
||||
export default function MultiModelResponseView({
|
||||
responses,
|
||||
chatState,
|
||||
llmManager,
|
||||
onRegenerate,
|
||||
parentMessage,
|
||||
otherMessagesCanSwitchTo,
|
||||
onMessageSelection,
|
||||
onHiddenPanelsChange,
|
||||
}: MultiModelResponseViewProps) {
|
||||
const [preferredIndex, setPreferredIndex] = useState<number | null>(null);
|
||||
const [hiddenPanels, setHiddenPanels] = useState<Set<number>>(new Set());
|
||||
// Controls animation: false = panels at start position, true = panels at peek position
|
||||
const [selectionEntered, setSelectionEntered] = useState(false);
|
||||
// Measures the overflow-hidden carousel container for responsive preferred-panel sizing.
|
||||
const [trackContainerW, setTrackContainerW] = useState(0);
|
||||
const roRef = useRef<ResizeObserver | null>(null);
|
||||
const trackContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
if (roRef.current) {
|
||||
roRef.current.disconnect();
|
||||
roRef.current = null;
|
||||
}
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setTrackContainerW(entry?.contentRect.width ?? 0);
|
||||
});
|
||||
ro.observe(el);
|
||||
setTrackContainerW(el.offsetWidth);
|
||||
roRef.current = ro;
|
||||
}, []);
|
||||
|
||||
// Measures the preferred panel's height to cap non-preferred panels in selection mode.
|
||||
const [preferredPanelHeight, setPreferredPanelHeight] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const preferredRoRef = useRef<ResizeObserver | null>(null);
|
||||
// Tracks which non-preferred panels overflow the preferred height cap
|
||||
const [overflowingPanels, setOverflowingPanels] = useState<Set<number>>(
|
||||
new Set()
|
||||
);
|
||||
|
||||
const preferredPanelRef = useCallback((el: HTMLDivElement | null) => {
|
||||
if (preferredRoRef.current) {
|
||||
preferredRoRef.current.disconnect();
|
||||
preferredRoRef.current = null;
|
||||
}
|
||||
if (!el) {
|
||||
setPreferredPanelHeight(null);
|
||||
return;
|
||||
}
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setPreferredPanelHeight(entry?.contentRect.height ?? 0);
|
||||
});
|
||||
ro.observe(el);
|
||||
setPreferredPanelHeight(el.offsetHeight);
|
||||
preferredRoRef.current = ro;
|
||||
}, []);
|
||||
|
||||
const isGenerating = useMemo(
|
||||
() => responses.some((r) => r.isGenerating),
|
||||
[responses]
|
||||
);
|
||||
|
||||
// Non-hidden responses — used for layout width decisions and selection-mode gating
|
||||
const visibleResponses = useMemo(
|
||||
() => responses.filter((r) => !hiddenPanels.has(r.modelIndex)),
|
||||
[responses, hiddenPanels]
|
||||
);
|
||||
|
||||
const toggleVisibility = useCallback(
|
||||
(modelIndex: number) => {
|
||||
setHiddenPanels((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(modelIndex)) {
|
||||
next.delete(modelIndex);
|
||||
} else {
|
||||
// Don't hide the last visible panel
|
||||
const visibleCount = responses.length - next.size;
|
||||
if (visibleCount <= 1) return prev;
|
||||
next.add(modelIndex);
|
||||
}
|
||||
onHiddenPanelsChange?.(next);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[responses.length, onHiddenPanelsChange]
|
||||
);
|
||||
|
||||
const handleSelectPreferred = useCallback(
|
||||
(modelIndex: number) => {
|
||||
if (isGenerating) return;
|
||||
setPreferredIndex(modelIndex);
|
||||
const response = responses.find((r) => r.modelIndex === modelIndex);
|
||||
if (!response) return;
|
||||
if (onMessageSelection) {
|
||||
onMessageSelection(response.nodeId);
|
||||
}
|
||||
},
|
||||
[isGenerating, responses, onMessageSelection]
|
||||
);
|
||||
|
||||
// Clear preferred selection when generation starts
|
||||
useEffect(() => {
|
||||
if (isGenerating) {
|
||||
setPreferredIndex(null);
|
||||
}
|
||||
}, [isGenerating]);
|
||||
|
||||
// Find preferred panel position — used for both the selection guard and carousel layout
|
||||
const preferredIdx = responses.findIndex(
|
||||
(r) => r.modelIndex === preferredIndex
|
||||
);
|
||||
|
||||
// Selection mode when preferred is set, found in responses, not generating, and at least 2 visible panels
|
||||
const showSelectionMode =
|
||||
preferredIndex !== null &&
|
||||
preferredIdx !== -1 &&
|
||||
!isGenerating &&
|
||||
visibleResponses.length > 1;
|
||||
|
||||
// Trigger the slide-out animation one frame after entering selection mode
|
||||
useEffect(() => {
|
||||
if (!showSelectionMode) {
|
||||
setSelectionEntered(false);
|
||||
return;
|
||||
}
|
||||
const raf = requestAnimationFrame(() => setSelectionEntered(true));
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [showSelectionMode]);
|
||||
|
||||
// Build panel props — isHidden reflects actual hidden state
|
||||
const buildPanelProps = useCallback(
|
||||
(response: MultiModelResponse, isNonPreferred: boolean) => ({
|
||||
provider: response.provider,
|
||||
modelName: response.modelName,
|
||||
displayName: response.displayName,
|
||||
isPreferred: preferredIndex === response.modelIndex,
|
||||
isHidden: hiddenPanels.has(response.modelIndex),
|
||||
isNonPreferredInSelection: isNonPreferred,
|
||||
onSelect: () => handleSelectPreferred(response.modelIndex),
|
||||
onToggleVisibility: () => toggleVisibility(response.modelIndex),
|
||||
agentMessageProps: {
|
||||
rawPackets: response.packets,
|
||||
packetCount: response.packetCount,
|
||||
chatState,
|
||||
nodeId: response.nodeId,
|
||||
messageId: response.messageId,
|
||||
currentFeedback: response.currentFeedback,
|
||||
llmManager,
|
||||
otherMessagesCanSwitchTo,
|
||||
onMessageSelection,
|
||||
onRegenerate,
|
||||
parentMessage,
|
||||
},
|
||||
}),
|
||||
[
|
||||
preferredIndex,
|
||||
hiddenPanels,
|
||||
handleSelectPreferred,
|
||||
toggleVisibility,
|
||||
chatState,
|
||||
llmManager,
|
||||
otherMessagesCanSwitchTo,
|
||||
onMessageSelection,
|
||||
onRegenerate,
|
||||
parentMessage,
|
||||
]
|
||||
);
|
||||
|
||||
if (showSelectionMode) {
|
||||
// ── Selection Layout (transform-based carousel) ──
|
||||
//
|
||||
// All panels (including hidden) sit in the track at their original A/B/C positions.
|
||||
// Hidden panels use HIDDEN_PANEL_W; non-preferred use SELECTION_PANEL_W;
|
||||
// preferred uses dynamicPrefW (up to GEN_PANEL_W_2).
|
||||
const n = responses.length;
|
||||
|
||||
const dynamicPrefW =
|
||||
trackContainerW > 0
|
||||
? Math.min(trackContainerW - 2 * (PEEK_W + PANEL_GAP), GEN_PANEL_W_2)
|
||||
: GEN_PANEL_W_2;
|
||||
|
||||
const selectionWidths = responses.map((r, i) => {
|
||||
if (hiddenPanels.has(r.modelIndex)) return HIDDEN_PANEL_W;
|
||||
if (i === preferredIdx) return dynamicPrefW;
|
||||
return SELECTION_PANEL_W;
|
||||
});
|
||||
|
||||
const panelLeftEdges = selectionWidths.reduce<number[]>((acc, w, i) => {
|
||||
acc.push(i === 0 ? 0 : acc[i - 1]! + selectionWidths[i - 1]! + PANEL_GAP);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
const preferredCenterInTrack =
|
||||
panelLeftEdges[preferredIdx]! + selectionWidths[preferredIdx]! / 2;
|
||||
|
||||
// Start position: hidden panels at HIDDEN_PANEL_W, visible at SELECTION_PANEL_W
|
||||
const uniformTrackW =
|
||||
responses.reduce(
|
||||
(sum, r) =>
|
||||
sum +
|
||||
(hiddenPanels.has(r.modelIndex) ? HIDDEN_PANEL_W : SELECTION_PANEL_W),
|
||||
0
|
||||
) +
|
||||
(n - 1) * PANEL_GAP;
|
||||
|
||||
const trackTransform = selectionEntered
|
||||
? `translateX(${trackContainerW / 2 - preferredCenterInTrack}px)`
|
||||
: `translateX(${(trackContainerW - uniformTrackW) / 2}px)`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={trackContainerRef}
|
||||
className="w-full overflow-hidden"
|
||||
style={{
|
||||
maskImage: `linear-gradient(to right, transparent 0px, black ${PEEK_W}px, black calc(100% - ${PEEK_W}px), transparent 100%)`,
|
||||
WebkitMaskImage: `linear-gradient(to right, transparent 0px, black ${PEEK_W}px, black calc(100% - ${PEEK_W}px), transparent 100%)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-start"
|
||||
style={{
|
||||
gap: `${PANEL_GAP}px`,
|
||||
transition: selectionEntered
|
||||
? "transform 0.45s cubic-bezier(0.2, 0, 0, 1)"
|
||||
: "none",
|
||||
transform: trackTransform,
|
||||
}}
|
||||
>
|
||||
{responses.map((r, i) => {
|
||||
const isHidden = hiddenPanels.has(r.modelIndex);
|
||||
const isPref = r.modelIndex === preferredIndex;
|
||||
const isNonPref = !isHidden && !isPref;
|
||||
const finalW = selectionWidths[i]!;
|
||||
const startW = isHidden ? HIDDEN_PANEL_W : SELECTION_PANEL_W;
|
||||
const capped = isNonPref && preferredPanelHeight != null;
|
||||
const overflows = capped && overflowingPanels.has(r.modelIndex);
|
||||
return (
|
||||
<div
|
||||
key={r.modelIndex}
|
||||
ref={(el) => {
|
||||
if (isPref) preferredPanelRef(el);
|
||||
if (capped && el) {
|
||||
const doesOverflow = el.scrollHeight > el.clientHeight;
|
||||
setOverflowingPanels((prev) => {
|
||||
const had = prev.has(r.modelIndex);
|
||||
if (doesOverflow === had) return prev;
|
||||
const next = new Set(prev);
|
||||
if (doesOverflow) next.add(r.modelIndex);
|
||||
else next.delete(r.modelIndex);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: `${selectionEntered ? finalW : startW}px`,
|
||||
flexShrink: 0,
|
||||
transition: selectionEntered
|
||||
? "width 0.45s cubic-bezier(0.2, 0, 0, 1)"
|
||||
: "none",
|
||||
maxHeight: capped ? preferredPanelHeight : undefined,
|
||||
overflow: capped ? "hidden" : undefined,
|
||||
position: capped ? "relative" : undefined,
|
||||
}}
|
||||
>
|
||||
<div className={cn(isNonPref && "opacity-50")}>
|
||||
<MultiModelPanel {...buildPanelProps(r, isNonPref)} />
|
||||
</div>
|
||||
{overflows && (
|
||||
<div
|
||||
className="absolute inset-x-0 bottom-0 h-24 pointer-events-none"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to top, var(--background-tint-01) 0%, transparent 100%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Generation Layout (equal panels side-by-side) ──
|
||||
// Panel width based on number of visible (non-hidden) panels.
|
||||
const panelWidth =
|
||||
visibleResponses.length <= 2 ? GEN_PANEL_W_2 : GEN_PANEL_W_3;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="flex gap-6 items-start w-fit mx-auto">
|
||||
{responses.map((r) => {
|
||||
const isHidden = hiddenPanels.has(r.modelIndex);
|
||||
return (
|
||||
<div
|
||||
key={r.modelIndex}
|
||||
style={
|
||||
isHidden
|
||||
? {
|
||||
width: HIDDEN_PANEL_W,
|
||||
minWidth: HIDDEN_PANEL_W,
|
||||
maxWidth: HIDDEN_PANEL_W,
|
||||
flexShrink: 0,
|
||||
overflow: "hidden" as const,
|
||||
}
|
||||
: { width: panelWidth, minWidth: MIN_PANEL_W }
|
||||
}
|
||||
>
|
||||
<MultiModelPanel {...buildPanelProps(r, false)} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
web/src/app/app/message/interfaces.ts
Normal file
16
web/src/app/app/message/interfaces.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Packet } from "@/app/app/services/streamingModels";
|
||||
import { FeedbackType } from "@/app/app/interfaces";
|
||||
|
||||
export interface MultiModelResponse {
|
||||
modelIndex: number;
|
||||
provider: string;
|
||||
modelName: string;
|
||||
displayName: string;
|
||||
packets: Packet[];
|
||||
packetCount: number;
|
||||
nodeId: number;
|
||||
messageId?: number;
|
||||
|
||||
currentFeedback?: FeedbackType | null;
|
||||
isGenerating?: boolean;
|
||||
}
|
||||
@@ -49,6 +49,8 @@ export interface AgentMessageProps {
|
||||
parentMessage?: Message | null;
|
||||
// Duration in seconds for processing this message (agent messages only)
|
||||
processingDurationSeconds?: number;
|
||||
/** Hide the feedback/toolbar footer (used in multi-model non-preferred panels) */
|
||||
hideFooter?: boolean;
|
||||
}
|
||||
|
||||
// TODO: Consider more robust comparisons:
|
||||
@@ -76,7 +78,8 @@ function arePropsEqual(
|
||||
prev.parentMessage?.messageId === next.parentMessage?.messageId &&
|
||||
prev.llmManager?.isLoadingProviders ===
|
||||
next.llmManager?.isLoadingProviders &&
|
||||
prev.processingDurationSeconds === next.processingDurationSeconds
|
||||
prev.processingDurationSeconds === next.processingDurationSeconds &&
|
||||
prev.hideFooter === next.hideFooter
|
||||
// Skip: chatState.regenerate, chatState.setPresentingDocument,
|
||||
// most of llmManager, onMessageSelection (function/object props)
|
||||
);
|
||||
@@ -95,6 +98,7 @@ const AgentMessage = React.memo(function AgentMessage({
|
||||
onRegenerate,
|
||||
parentMessage,
|
||||
processingDurationSeconds,
|
||||
hideFooter,
|
||||
}: AgentMessageProps) {
|
||||
const markdownRef = useRef<HTMLDivElement>(null);
|
||||
const finalAnswerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -326,7 +330,7 @@ const AgentMessage = React.memo(function AgentMessage({
|
||||
</div>
|
||||
|
||||
{/* Feedback buttons - only show when streaming and rendering complete */}
|
||||
{isComplete && (
|
||||
{isComplete && !hideFooter && (
|
||||
<MessageToolbar
|
||||
nodeId={nodeId}
|
||||
messageId={messageId}
|
||||
|
||||
192
web/src/hooks/useMultiModelChat.ts
Normal file
192
web/src/hooks/useMultiModelChat.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
MAX_MODELS,
|
||||
SelectedModel,
|
||||
} from "@/refresh-components/popovers/ModelSelector";
|
||||
import { LLMOverride } from "@/app/app/services/lib";
|
||||
import { LlmManager } from "@/lib/hooks";
|
||||
import { buildLlmOptions } from "@/refresh-components/popovers/LLMPopover";
|
||||
|
||||
export interface UseMultiModelChatReturn {
|
||||
/** Currently selected models for multi-model comparison. */
|
||||
selectedModels: SelectedModel[];
|
||||
/** Whether multi-model mode is active (>1 model selected). */
|
||||
isMultiModelActive: boolean;
|
||||
/** Add a model to the selection. */
|
||||
addModel: (model: SelectedModel) => void;
|
||||
/** Remove a model by index. */
|
||||
removeModel: (index: number) => void;
|
||||
/** Replace a model at a specific index with a new one. */
|
||||
replaceModel: (index: number, model: SelectedModel) => void;
|
||||
/** Clear all selected models. */
|
||||
clearModels: () => void;
|
||||
/** Build the LLMOverride[] array from selectedModels. */
|
||||
buildLlmOverrides: () => LLMOverride[];
|
||||
/**
|
||||
* Restore multi-model selection from model version strings (e.g. from chat history).
|
||||
* Matches against available llmOptions to reconstruct full SelectedModel objects.
|
||||
*/
|
||||
restoreFromModelNames: (modelNames: string[]) => void;
|
||||
/**
|
||||
* Switch to a single model by name (after user picks a preferred response).
|
||||
* Matches against llmOptions to find the full SelectedModel.
|
||||
*/
|
||||
selectSingleModel: (modelName: string) => void;
|
||||
}
|
||||
|
||||
export default function useMultiModelChat(
|
||||
llmManager: LlmManager
|
||||
): UseMultiModelChatReturn {
|
||||
const [selectedModels, setSelectedModels] = useState<SelectedModel[]>([]);
|
||||
const [defaultInitialized, setDefaultInitialized] = useState(false);
|
||||
|
||||
// Initialize with the default model from llmManager once providers load
|
||||
const llmOptions = useMemo(
|
||||
() =>
|
||||
llmManager.llmProviders ? buildLlmOptions(llmManager.llmProviders) : [],
|
||||
[llmManager.llmProviders]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultInitialized) return;
|
||||
if (llmOptions.length === 0) return;
|
||||
const { currentLlm } = llmManager;
|
||||
// Don't initialize if currentLlm hasn't loaded yet
|
||||
if (!currentLlm.modelName) return;
|
||||
const match = llmOptions.find(
|
||||
(opt) =>
|
||||
opt.provider === currentLlm.provider &&
|
||||
opt.modelName === currentLlm.modelName
|
||||
);
|
||||
if (match) {
|
||||
setSelectedModels([
|
||||
{
|
||||
name: match.name,
|
||||
provider: match.provider,
|
||||
modelName: match.modelName,
|
||||
displayName: match.displayName,
|
||||
},
|
||||
]);
|
||||
setDefaultInitialized(true);
|
||||
}
|
||||
}, [llmOptions, llmManager.currentLlm, defaultInitialized]);
|
||||
|
||||
const isMultiModelActive = selectedModels.length > 1;
|
||||
|
||||
const addModel = useCallback((model: SelectedModel) => {
|
||||
setSelectedModels((prev) => {
|
||||
if (prev.length >= MAX_MODELS) return prev;
|
||||
if (
|
||||
prev.some(
|
||||
(m) =>
|
||||
m.provider === model.provider && m.modelName === model.modelName
|
||||
)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return [...prev, model];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const removeModel = useCallback((index: number) => {
|
||||
setSelectedModels((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const replaceModel = useCallback((index: number, model: SelectedModel) => {
|
||||
setSelectedModels((prev) => {
|
||||
// Don't replace with a model that's already selected elsewhere
|
||||
if (
|
||||
prev.some(
|
||||
(m, i) =>
|
||||
i !== index &&
|
||||
m.provider === model.provider &&
|
||||
m.modelName === model.modelName
|
||||
)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
const next = [...prev];
|
||||
next[index] = model;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearModels = useCallback(() => {
|
||||
setSelectedModels([]);
|
||||
}, []);
|
||||
|
||||
const restoreFromModelNames = useCallback(
|
||||
(modelNames: string[]) => {
|
||||
if (modelNames.length < 2 || llmOptions.length === 0) return;
|
||||
const restored: SelectedModel[] = [];
|
||||
for (const name of modelNames) {
|
||||
// Try matching by modelName (raw version string like "claude-opus-4-6")
|
||||
// or by displayName (friendly name like "Claude Opus 4.6")
|
||||
const match = llmOptions.find(
|
||||
(opt) =>
|
||||
opt.modelName === name ||
|
||||
opt.displayName === name ||
|
||||
opt.name === name
|
||||
);
|
||||
if (match) {
|
||||
restored.push({
|
||||
name: match.name,
|
||||
provider: match.provider,
|
||||
modelName: match.modelName,
|
||||
displayName: match.displayName,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (restored.length >= 2) {
|
||||
setSelectedModels(restored.slice(0, MAX_MODELS));
|
||||
setDefaultInitialized(true);
|
||||
}
|
||||
},
|
||||
[llmOptions]
|
||||
);
|
||||
|
||||
const selectSingleModel = useCallback(
|
||||
(modelName: string) => {
|
||||
if (llmOptions.length === 0) return;
|
||||
const match = llmOptions.find(
|
||||
(opt) =>
|
||||
opt.modelName === modelName ||
|
||||
opt.displayName === modelName ||
|
||||
opt.name === modelName
|
||||
);
|
||||
if (match) {
|
||||
setSelectedModels([
|
||||
{
|
||||
name: match.name,
|
||||
provider: match.provider,
|
||||
modelName: match.modelName,
|
||||
displayName: match.displayName,
|
||||
},
|
||||
]);
|
||||
}
|
||||
},
|
||||
[llmOptions]
|
||||
);
|
||||
|
||||
const buildLlmOverrides = useCallback((): LLMOverride[] => {
|
||||
return selectedModels.map((m) => ({
|
||||
model_provider: m.provider,
|
||||
model_version: m.modelName,
|
||||
display_name: m.displayName,
|
||||
}));
|
||||
}, [selectedModels]);
|
||||
|
||||
return {
|
||||
selectedModels,
|
||||
isMultiModelActive,
|
||||
addModel,
|
||||
removeModel,
|
||||
replaceModel,
|
||||
clearModels,
|
||||
buildLlmOverrides,
|
||||
restoreFromModelNames,
|
||||
selectSingleModel,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import Popover, { PopoverMenu } from "@/refresh-components/Popover";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import { LlmDescriptor, LlmManager } from "@/lib/hooks";
|
||||
import { structureValue } from "@/lib/llmConfig/utils";
|
||||
import {
|
||||
@@ -11,26 +11,12 @@ import {
|
||||
import { LLMProviderDescriptor } from "@/interfaces/llm";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { useUser } from "@/providers/UserProvider";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import {
|
||||
SvgCheck,
|
||||
SvgChevronDown,
|
||||
SvgChevronRight,
|
||||
SvgRefreshCw,
|
||||
} from "@opal/icons";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { SvgRefreshCw } from "@opal/icons";
|
||||
import { OpenButton } from "@opal/components";
|
||||
import { Disabled } from "@opal/core";
|
||||
import { LLMOption, LLMOptionGroup } from "./interfaces";
|
||||
import ModelListContent from "./ModelListContent";
|
||||
|
||||
export interface LLMPopoverProps {
|
||||
llmManager: LlmManager;
|
||||
@@ -151,7 +137,6 @@ export default function LLMPopover({
|
||||
const isLoadingProviders = llmManager.isLoadingProviders;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const { user } = useUser();
|
||||
|
||||
const [localTemperature, setLocalTemperature] = useState(
|
||||
@@ -162,9 +147,7 @@ export default function LLMPopover({
|
||||
setLocalTemperature(llmManager.temperature ?? 0.5);
|
||||
}, [llmManager.temperature]);
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const selectedItemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleGlobalTemperatureChange = useCallback((value: number[]) => {
|
||||
const value_0 = value[0];
|
||||
@@ -183,39 +166,28 @@ export default function LLMPopover({
|
||||
[llmManager]
|
||||
);
|
||||
|
||||
const llmOptions = useMemo(
|
||||
() => buildLlmOptions(llmProviders, currentModelName),
|
||||
[llmProviders, currentModelName]
|
||||
const isSelected = useCallback(
|
||||
(option: LLMOption) =>
|
||||
option.modelName === llmManager.currentLlm.modelName &&
|
||||
option.provider === llmManager.currentLlm.provider,
|
||||
[llmManager.currentLlm.modelName, llmManager.currentLlm.provider]
|
||||
);
|
||||
|
||||
// Filter options by vision capability (when images are uploaded) and search query
|
||||
const filteredOptions = useMemo(() => {
|
||||
let result = llmOptions;
|
||||
if (requiresImageInput) {
|
||||
result = result.filter((opt) => opt.supportsImageInput);
|
||||
}
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(opt) =>
|
||||
opt.displayName.toLowerCase().includes(query) ||
|
||||
opt.modelName.toLowerCase().includes(query) ||
|
||||
(opt.vendor && opt.vendor.toLowerCase().includes(query))
|
||||
const handleSelectModel = useCallback(
|
||||
(option: LLMOption) => {
|
||||
llmManager.updateCurrentLlm({
|
||||
modelName: option.modelName,
|
||||
provider: option.provider,
|
||||
name: option.name,
|
||||
} as LlmDescriptor);
|
||||
onSelect?.(
|
||||
structureValue(option.name, option.provider, option.modelName)
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [llmOptions, searchQuery, requiresImageInput]);
|
||||
|
||||
// Group options by provider using backend-provided display names and ordering
|
||||
// For aggregator providers (bedrock, openrouter, vertex_ai), flatten to "Provider/Vendor" format
|
||||
const groupedOptions = useMemo(
|
||||
() => groupLlmOptions(filteredOptions),
|
||||
[filteredOptions]
|
||||
setOpen(false);
|
||||
},
|
||||
[llmManager, onSelect]
|
||||
);
|
||||
|
||||
// Get display name for the model to show in the button
|
||||
// Use currentModelName prop if provided (e.g., for regenerate showing the model used),
|
||||
// otherwise fall back to the globally selected model
|
||||
const currentLlmDisplayName = useMemo(() => {
|
||||
// Only use currentModelName if it's a non-empty string
|
||||
const currentModel =
|
||||
@@ -235,122 +207,30 @@ export default function LLMPopover({
|
||||
return currentModel;
|
||||
}, [llmProviders, currentModelName, llmManager.currentLlm.modelName]);
|
||||
|
||||
// Determine which group the current model belongs to (for auto-expand)
|
||||
const currentGroupKey = useMemo(() => {
|
||||
const currentModel = llmManager.currentLlm.modelName;
|
||||
const currentProvider = llmManager.currentLlm.provider;
|
||||
// Match by both modelName AND provider to handle same model name across providers
|
||||
const option = llmOptions.find(
|
||||
(o) => o.modelName === currentModel && o.provider === currentProvider
|
||||
);
|
||||
if (!option) return "openai";
|
||||
|
||||
const provider = option.provider.toLowerCase();
|
||||
const isAggregator = AGGREGATOR_PROVIDERS.has(provider);
|
||||
|
||||
if (isAggregator && option.vendor) {
|
||||
return `${provider}/${option.vendor.toLowerCase()}`;
|
||||
}
|
||||
return provider;
|
||||
}, [
|
||||
llmOptions,
|
||||
llmManager.currentLlm.modelName,
|
||||
llmManager.currentLlm.provider,
|
||||
]);
|
||||
|
||||
// Track expanded groups - initialize with current model's group
|
||||
const [expandedGroups, setExpandedGroups] = useState<string[]>([
|
||||
currentGroupKey,
|
||||
]);
|
||||
|
||||
// Reset state when popover closes/opens
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearchQuery("");
|
||||
} else {
|
||||
// Reset expanded groups to only show the selected model's group
|
||||
setExpandedGroups([currentGroupKey]);
|
||||
}
|
||||
}, [open, currentGroupKey]);
|
||||
|
||||
// Auto-scroll to selected model when popover opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Small delay to let accordion content render
|
||||
const timer = setTimeout(() => {
|
||||
selectedItemRef.current?.scrollIntoView({
|
||||
behavior: "instant",
|
||||
block: "center",
|
||||
});
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const isSearching = searchQuery.trim().length > 0;
|
||||
|
||||
// Compute final expanded groups
|
||||
const effectiveExpandedGroups = useMemo(() => {
|
||||
if (isSearching) {
|
||||
// Force expand all when searching
|
||||
return groupedOptions.map((g) => g.key);
|
||||
}
|
||||
return expandedGroups;
|
||||
}, [isSearching, groupedOptions, expandedGroups]);
|
||||
|
||||
// Handler for accordion changes
|
||||
const handleAccordionChange = (value: string[]) => {
|
||||
// Only update state when not searching (force-expanding)
|
||||
if (!isSearching) {
|
||||
setExpandedGroups(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectModel = (option: LLMOption) => {
|
||||
llmManager.updateCurrentLlm({
|
||||
modelName: option.modelName,
|
||||
provider: option.provider,
|
||||
name: option.name,
|
||||
} as LlmDescriptor);
|
||||
onSelect?.(structureValue(option.name, option.provider, option.modelName));
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const renderModelItem = (option: LLMOption) => {
|
||||
const isSelected =
|
||||
option.modelName === llmManager.currentLlm.modelName &&
|
||||
option.provider === llmManager.currentLlm.provider;
|
||||
|
||||
const capabilities: string[] = [];
|
||||
if (option.supportsReasoning) {
|
||||
capabilities.push("Reasoning");
|
||||
}
|
||||
if (option.supportsImageInput) {
|
||||
capabilities.push("Vision");
|
||||
}
|
||||
const description =
|
||||
capabilities.length > 0 ? capabilities.join(", ") : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${option.name}-${option.modelName}`}
|
||||
ref={isSelected ? selectedItemRef : undefined}
|
||||
>
|
||||
<LineItem
|
||||
selected={isSelected}
|
||||
description={description}
|
||||
onClick={() => handleSelectModel(option)}
|
||||
rightChildren={
|
||||
isSelected ? (
|
||||
<SvgCheck className="h-4 w-4 stroke-action-link-05 shrink-0" />
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{option.displayName}
|
||||
</LineItem>
|
||||
const temperatureFooter = user?.preferences?.temperature_override_enabled ? (
|
||||
<>
|
||||
<div className="border-t border-border-02 mx-2" />
|
||||
<div className="flex flex-col w-full py-2 gap-2">
|
||||
<Slider
|
||||
value={[localTemperature]}
|
||||
max={llmManager.maxTemperature}
|
||||
min={0}
|
||||
step={0.01}
|
||||
onValueChange={handleGlobalTemperatureChange}
|
||||
onValueCommit={handleGlobalTemperatureCommit}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<Text secondaryBody text03>
|
||||
Temperature (creativity)
|
||||
</Text>
|
||||
<Text secondaryBody text03>
|
||||
{localTemperature.toFixed(1)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -375,129 +255,16 @@ export default function LLMPopover({
|
||||
</div>
|
||||
|
||||
<Popover.Content side="top" align="end" width="xl">
|
||||
<Section gap={0.5}>
|
||||
{/* Search Input */}
|
||||
<InputTypeIn
|
||||
ref={searchInputRef}
|
||||
leftSearchIcon
|
||||
variant="internal"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search models..."
|
||||
/>
|
||||
|
||||
{/* Model List with Vendor Groups */}
|
||||
<PopoverMenu scrollContainerRef={scrollContainerRef}>
|
||||
{isLoadingProviders
|
||||
? [
|
||||
<div key="loading" className="flex items-center gap-2 py-3">
|
||||
<SimpleLoader />
|
||||
<Text secondaryBody text03>
|
||||
Loading models...
|
||||
</Text>
|
||||
</div>,
|
||||
]
|
||||
: groupedOptions.length === 0
|
||||
? [
|
||||
<div key="empty" className="py-3">
|
||||
<Text secondaryBody text03>
|
||||
No models found
|
||||
</Text>
|
||||
</div>,
|
||||
]
|
||||
: groupedOptions.length === 1
|
||||
? // Single provider - show models directly without accordion
|
||||
[
|
||||
<div
|
||||
key="single-provider"
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
{groupedOptions[0]!.options.map(renderModelItem)}
|
||||
</div>,
|
||||
]
|
||||
: // Multiple providers - show accordion with groups
|
||||
[
|
||||
<Accordion
|
||||
key="accordion"
|
||||
type="multiple"
|
||||
value={effectiveExpandedGroups}
|
||||
onValueChange={handleAccordionChange}
|
||||
className="w-full flex flex-col"
|
||||
>
|
||||
{groupedOptions.map((group) => {
|
||||
const isExpanded = effectiveExpandedGroups.includes(
|
||||
group.key
|
||||
);
|
||||
return (
|
||||
<AccordionItem
|
||||
key={group.key}
|
||||
value={group.key}
|
||||
className="border-none pt-1"
|
||||
>
|
||||
{/* Group Header */}
|
||||
<AccordionTrigger className="flex items-center rounded-08 hover:no-underline hover:bg-background-tint-02 group [&>svg]:hidden w-full py-1">
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<div className="flex items-center justify-center size-5 shrink-0">
|
||||
<group.Icon size={16} />
|
||||
</div>
|
||||
<Text
|
||||
secondaryBody
|
||||
text03
|
||||
nowrap
|
||||
className="px-0.5"
|
||||
>
|
||||
{group.displayName}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center justify-center size-6 shrink-0">
|
||||
{isExpanded ? (
|
||||
<SvgChevronDown className="h-4 w-4 stroke-text-04 shrink-0" />
|
||||
) : (
|
||||
<SvgChevronRight className="h-4 w-4 stroke-text-04 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
{/* Model Items - full width highlight */}
|
||||
<AccordionContent className="pb-0 pt-0">
|
||||
<div className="flex flex-col gap-1">
|
||||
{group.options.map(renderModelItem)}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
})}
|
||||
</Accordion>,
|
||||
]}
|
||||
</PopoverMenu>
|
||||
|
||||
{/* Global Temperature Slider (shown if enabled in user prefs) */}
|
||||
{user?.preferences?.temperature_override_enabled && (
|
||||
<>
|
||||
<div className="border-t border-border-02 mx-2" />
|
||||
<div className="flex flex-col w-full py-2 gap-2">
|
||||
<Slider
|
||||
value={[localTemperature]}
|
||||
max={llmManager.maxTemperature}
|
||||
min={0}
|
||||
step={0.01}
|
||||
onValueChange={handleGlobalTemperatureChange}
|
||||
onValueCommit={handleGlobalTemperatureCommit}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<Text secondaryBody text03>
|
||||
Temperature (creativity)
|
||||
</Text>
|
||||
<Text secondaryBody text03>
|
||||
{localTemperature.toFixed(1)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
<ModelListContent
|
||||
llmProviders={llmProviders}
|
||||
currentModelName={currentModelName}
|
||||
requiresImageInput={requiresImageInput}
|
||||
isLoading={isLoadingProviders}
|
||||
onSelect={handleSelectModel}
|
||||
isSelected={isSelected}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
footer={temperatureFooter}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
200
web/src/refresh-components/popovers/ModelListContent.tsx
Normal file
200
web/src/refresh-components/popovers/ModelListContent.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef, useEffect } from "react";
|
||||
import { PopoverMenu } from "@/refresh-components/Popover";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import { Text } from "@opal/components";
|
||||
import { SvgCheck, SvgChevronDown, SvgChevronRight } from "@opal/icons";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import { LLMOption } from "./interfaces";
|
||||
import { buildLlmOptions, groupLlmOptions } from "./LLMPopover";
|
||||
import LineItem from "@/refresh-components/buttons/LineItem";
|
||||
import { LLMProviderDescriptor } from "@/interfaces/llm";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/refresh-components/Collapsible";
|
||||
|
||||
export interface ModelListContentProps {
|
||||
llmProviders: LLMProviderDescriptor[] | undefined;
|
||||
currentModelName?: string;
|
||||
requiresImageInput?: boolean;
|
||||
onSelect: (option: LLMOption) => void;
|
||||
isSelected: (option: LLMOption) => boolean;
|
||||
isDisabled?: (option: LLMOption) => boolean;
|
||||
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
isLoading?: boolean;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function ModelListContent({
|
||||
llmProviders,
|
||||
currentModelName,
|
||||
requiresImageInput,
|
||||
onSelect,
|
||||
isSelected,
|
||||
isDisabled,
|
||||
scrollContainerRef: externalScrollRef,
|
||||
isLoading,
|
||||
footer,
|
||||
}: ModelListContentProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const internalScrollRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
|
||||
|
||||
const llmOptions = useMemo(
|
||||
() => buildLlmOptions(llmProviders, currentModelName),
|
||||
[llmProviders, currentModelName]
|
||||
);
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
let result = llmOptions;
|
||||
if (requiresImageInput) {
|
||||
result = result.filter((opt) => opt.supportsImageInput);
|
||||
}
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(opt) =>
|
||||
opt.displayName.toLowerCase().includes(query) ||
|
||||
opt.modelName.toLowerCase().includes(query) ||
|
||||
(opt.vendor && opt.vendor.toLowerCase().includes(query))
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [llmOptions, searchQuery, requiresImageInput]);
|
||||
|
||||
const groupedOptions = useMemo(
|
||||
() => groupLlmOptions(filteredOptions),
|
||||
[filteredOptions]
|
||||
);
|
||||
|
||||
// Find which group contains a currently-selected model (for auto-expand)
|
||||
const defaultGroupKey = useMemo(() => {
|
||||
for (const group of groupedOptions) {
|
||||
if (group.options.some((opt) => isSelected(opt))) {
|
||||
return group.key;
|
||||
}
|
||||
}
|
||||
return groupedOptions[0]?.key ?? "";
|
||||
}, [groupedOptions, isSelected]);
|
||||
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
|
||||
new Set([defaultGroupKey])
|
||||
);
|
||||
|
||||
// Reset expanded groups when default changes (e.g. popover re-opens)
|
||||
useEffect(() => {
|
||||
setExpandedGroups(new Set([defaultGroupKey]));
|
||||
}, [defaultGroupKey]);
|
||||
|
||||
const isSearching = searchQuery.trim().length > 0;
|
||||
|
||||
const toggleGroup = (key: string) => {
|
||||
if (isSearching) return;
|
||||
setExpandedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isGroupOpen = (key: string) => isSearching || expandedGroups.has(key);
|
||||
|
||||
const renderModelItem = (option: LLMOption) => {
|
||||
const selected = isSelected(option);
|
||||
const disabled = isDisabled?.(option) ?? false;
|
||||
|
||||
const capabilities: string[] = [];
|
||||
if (option.supportsReasoning) capabilities.push("Reasoning");
|
||||
if (option.supportsImageInput) capabilities.push("Vision");
|
||||
const description =
|
||||
capabilities.length > 0 ? capabilities.join(", ") : undefined;
|
||||
|
||||
return (
|
||||
<LineItem
|
||||
key={`${option.provider}:${option.modelName}`}
|
||||
selected={selected}
|
||||
disabled={disabled}
|
||||
description={description}
|
||||
onClick={() => onSelect(option)}
|
||||
rightChildren={
|
||||
selected ? (
|
||||
<SvgCheck className="h-4 w-4 stroke-action-link-05 shrink-0" />
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{option.displayName}
|
||||
</LineItem>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Section gap={0.5}>
|
||||
<InputTypeIn
|
||||
leftSearchIcon
|
||||
variant="internal"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search models..."
|
||||
/>
|
||||
|
||||
<PopoverMenu scrollContainerRef={scrollContainerRef}>
|
||||
{isLoading
|
||||
? [
|
||||
<Text key="loading" font="secondary-body" color="text-03">
|
||||
Loading models...
|
||||
</Text>,
|
||||
]
|
||||
: groupedOptions.length === 0
|
||||
? [
|
||||
<Text key="empty" font="secondary-body" color="text-03">
|
||||
No models found
|
||||
</Text>,
|
||||
]
|
||||
: groupedOptions.length === 1
|
||||
? [
|
||||
<Section key="single-provider" gap={0.25}>
|
||||
{groupedOptions[0]!.options.map(renderModelItem)}
|
||||
</Section>,
|
||||
]
|
||||
: groupedOptions.map((group) => {
|
||||
const open = isGroupOpen(group.key);
|
||||
return (
|
||||
<Collapsible
|
||||
key={group.key}
|
||||
open={open}
|
||||
onOpenChange={() => toggleGroup(group.key)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<LineItem
|
||||
muted
|
||||
icon={group.Icon}
|
||||
rightChildren={
|
||||
open ? (
|
||||
<SvgChevronDown className="h-4 w-4 stroke-text-04 shrink-0" />
|
||||
) : (
|
||||
<SvgChevronRight className="h-4 w-4 stroke-text-04 shrink-0" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{group.displayName}
|
||||
</LineItem>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
<CollapsibleContent>
|
||||
<Section gap={0.25}>
|
||||
{group.options.map(renderModelItem)}
|
||||
</Section>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
})}
|
||||
</PopoverMenu>
|
||||
|
||||
{footer}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
230
web/src/refresh-components/popovers/ModelSelector.tsx
Normal file
230
web/src/refresh-components/popovers/ModelSelector.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useRef } from "react";
|
||||
import Popover from "@/refresh-components/Popover";
|
||||
import { LlmManager } from "@/lib/hooks";
|
||||
import { getProviderIcon } from "@/app/admin/configuration/llm/utils";
|
||||
import { Button, SelectButton, OpenButton } from "@opal/components";
|
||||
import { SvgPlusCircle, SvgX } from "@opal/icons";
|
||||
import { LLMOption } from "@/refresh-components/popovers/interfaces";
|
||||
import ModelListContent from "@/refresh-components/popovers/ModelListContent";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
|
||||
export const MAX_MODELS = 3;
|
||||
|
||||
export interface SelectedModel {
|
||||
name: string;
|
||||
provider: string;
|
||||
modelName: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export interface ModelSelectorProps {
|
||||
llmManager: LlmManager;
|
||||
selectedModels: SelectedModel[];
|
||||
onAdd: (model: SelectedModel) => void;
|
||||
onRemove: (index: number) => void;
|
||||
onReplace: (index: number, model: SelectedModel) => void;
|
||||
}
|
||||
|
||||
function modelKey(provider: string, modelName: string): string {
|
||||
return `${provider}:${modelName}`;
|
||||
}
|
||||
|
||||
export default function ModelSelector({
|
||||
llmManager,
|
||||
selectedModels,
|
||||
onAdd,
|
||||
onRemove,
|
||||
onReplace,
|
||||
}: ModelSelectorProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// null = add mode (via + button), number = replace mode (via pill click)
|
||||
const [replacingIndex, setReplacingIndex] = useState<number | null>(null);
|
||||
// Virtual anchor ref — points to the clicked pill so the popover positions above it
|
||||
const anchorRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const isMultiModel = selectedModels.length > 1;
|
||||
const atMax = selectedModels.length >= MAX_MODELS;
|
||||
|
||||
const selectedKeys = useMemo(
|
||||
() => new Set(selectedModels.map((m) => modelKey(m.provider, m.modelName))),
|
||||
[selectedModels]
|
||||
);
|
||||
|
||||
const otherSelectedKeys = useMemo(() => {
|
||||
if (replacingIndex === null) return new Set<string>();
|
||||
return new Set(
|
||||
selectedModels
|
||||
.filter((_, i) => i !== replacingIndex)
|
||||
.map((m) => modelKey(m.provider, m.modelName))
|
||||
);
|
||||
}, [selectedModels, replacingIndex]);
|
||||
|
||||
const replacingKey =
|
||||
replacingIndex !== null
|
||||
? (() => {
|
||||
const m = selectedModels[replacingIndex];
|
||||
return m ? modelKey(m.provider, m.modelName) : null;
|
||||
})()
|
||||
: null;
|
||||
|
||||
const isSelected = (option: LLMOption) => {
|
||||
const key = modelKey(option.provider, option.modelName);
|
||||
if (replacingIndex !== null) return key === replacingKey;
|
||||
return selectedKeys.has(key);
|
||||
};
|
||||
|
||||
const isDisabled = (option: LLMOption) => {
|
||||
const key = modelKey(option.provider, option.modelName);
|
||||
if (replacingIndex !== null) return otherSelectedKeys.has(key);
|
||||
return !selectedKeys.has(key) && atMax;
|
||||
};
|
||||
|
||||
const handleSelect = (option: LLMOption) => {
|
||||
const model: SelectedModel = {
|
||||
name: option.name,
|
||||
provider: option.provider,
|
||||
modelName: option.modelName,
|
||||
displayName: option.displayName,
|
||||
};
|
||||
|
||||
if (replacingIndex !== null) {
|
||||
onReplace(replacingIndex, model);
|
||||
setOpen(false);
|
||||
setReplacingIndex(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const key = modelKey(option.provider, option.modelName);
|
||||
const existingIndex = selectedModels.findIndex(
|
||||
(m) => modelKey(m.provider, m.modelName) === key
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
onRemove(existingIndex);
|
||||
} else if (!atMax) {
|
||||
onAdd(model);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setReplacingIndex(null);
|
||||
};
|
||||
|
||||
const handlePillClick = (index: number, element: HTMLElement) => {
|
||||
anchorRef.current = element;
|
||||
setReplacingIndex(index);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<div className="flex items-center justify-end gap-1 p-1">
|
||||
{!atMax && (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
icon={SvgPlusCircle}
|
||||
size="sm"
|
||||
tooltip="Add Model"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
anchorRef.current = e.currentTarget as HTMLElement;
|
||||
setReplacingIndex(null);
|
||||
setOpen(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Popover.Anchor
|
||||
virtualRef={anchorRef as React.RefObject<HTMLElement>}
|
||||
/>
|
||||
{selectedModels.length > 0 && (
|
||||
<>
|
||||
{!atMax && (
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
paddingXRem={0.5}
|
||||
paddingYRem={0.5}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
{selectedModels.map((model, index) => {
|
||||
const ProviderIcon = getProviderIcon(
|
||||
model.provider,
|
||||
model.modelName
|
||||
);
|
||||
|
||||
if (!isMultiModel) {
|
||||
return (
|
||||
<OpenButton
|
||||
key={modelKey(model.provider, model.modelName)}
|
||||
icon={ProviderIcon}
|
||||
onClick={(e: React.MouseEvent) =>
|
||||
handlePillClick(index, e.currentTarget as HTMLElement)
|
||||
}
|
||||
>
|
||||
{model.displayName}
|
||||
</OpenButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={modelKey(model.provider, model.modelName)}
|
||||
className="flex items-center"
|
||||
>
|
||||
{index > 0 && (
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
paddingXRem={0.5}
|
||||
className="h-5"
|
||||
/>
|
||||
)}
|
||||
<SelectButton
|
||||
icon={ProviderIcon}
|
||||
rightIcon={SvgX}
|
||||
state="empty"
|
||||
variant="select-tinted"
|
||||
interaction="hover"
|
||||
size="lg"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const btn = e.currentTarget as HTMLElement;
|
||||
const icons = btn.querySelectorAll(
|
||||
".interactive-foreground-icon"
|
||||
);
|
||||
const lastIcon = icons[icons.length - 1];
|
||||
if (lastIcon && lastIcon.contains(target)) {
|
||||
onRemove(index);
|
||||
} else {
|
||||
handlePillClick(index, btn);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{model.displayName}
|
||||
</SelectButton>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Popover.Content
|
||||
side="top"
|
||||
align="start"
|
||||
width="lg"
|
||||
avoidCollisions={false}
|
||||
>
|
||||
<ModelListContent
|
||||
llmProviders={llmManager.llmProviders}
|
||||
isLoading={llmManager.isLoadingProviders}
|
||||
onSelect={handleSelect}
|
||||
isSelected={isSelected}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</Popover.Content>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user