feat(open-webui): searchable model picker in floating header
Replace the inline Select with a Combobox-based ModelSelect that filters models via an in-dropdown search field, and move the model picker, new chat and history controls into a floating header above the messages. Assistant avatar now uses the Open WebUI brand logo.
This commit is contained in:
1 parent
8e0f8cd46a
commit
41b75ef5a0
4 files changed
+183
-47
No files matched your search
@@ -3554,6 +3554,7 @@
|
||||
}
|
||||
},
|
||||
"selectModel": "Select a model",
|
||||
"searchModel": "Search models…",
|
||||
"noModels": "No models available",
|
||||
"emptyState": "Start a conversation",
|
||||
"messagePlaceholder": "Type a message…",
|
||||
|
||||
@@ -7,8 +7,11 @@ import {
|
||||
Button,
|
||||
Checkbox,
|
||||
CloseButton,
|
||||
Combobox,
|
||||
Group,
|
||||
Image,
|
||||
Input,
|
||||
InputBase,
|
||||
Loader,
|
||||
Paper,
|
||||
Popover,
|
||||
@@ -17,10 +20,14 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconCamera,
|
||||
IconCaretDownFilled,
|
||||
IconCaretUpFilled,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconDatabase,
|
||||
@@ -36,12 +43,104 @@ import {
|
||||
import ReactMarkdown from "react-markdown";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { getIconUrl } from "@homarr/definitions";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { AttachView, ChatMessage, FileAttachment } from "./chat-types";
|
||||
import type { useAttachments } from "./use-attachments";
|
||||
import classes from "./chat.module.css";
|
||||
|
||||
// Model picker: a non-editable button target showing the selected model, with a
|
||||
// search field inside the dropdown to filter the list on the frontend.
|
||||
export function ModelSelect({
|
||||
data,
|
||||
value,
|
||||
onChange,
|
||||
loading,
|
||||
disabled,
|
||||
}: {
|
||||
data: { value: string; label: string }[];
|
||||
value: string | null;
|
||||
onChange: (value: string) => void;
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const t = useI18n();
|
||||
const [search, setSearch] = useState("");
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => {
|
||||
combobox.resetSelectedOption();
|
||||
setSearch("");
|
||||
},
|
||||
onDropdownOpen: () => combobox.focusSearchInput(),
|
||||
});
|
||||
|
||||
const selectedLabel = data.find((item) => item.value === value)?.label;
|
||||
const filtered = data.filter((item) => item.label.toLowerCase().includes(search.trim().toLowerCase()));
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal={false}
|
||||
onOptionSubmit={(submitted) => {
|
||||
onChange(submitted);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
component="button"
|
||||
type="button"
|
||||
pointer
|
||||
variant="unstyled"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
w={200}
|
||||
classNames={{ input: classes.modelSelectControl }}
|
||||
rightSection={
|
||||
loading ? (
|
||||
<Loader size="xs" />
|
||||
) : combobox.dropdownOpened ? (
|
||||
<IconCaretUpFilled size={14} />
|
||||
) : (
|
||||
<IconCaretDownFilled size={14} />
|
||||
)
|
||||
}
|
||||
rightSectionPointerEvents="none"
|
||||
onClick={() => combobox.toggleDropdown()}
|
||||
>
|
||||
{selectedLabel ? (
|
||||
<span className={classes.modelSelectLabel}>{selectedLabel}</span>
|
||||
) : (
|
||||
<Input.Placeholder>{t("widget.openWebUi.selectModel")}</Input.Placeholder>
|
||||
)}
|
||||
</InputBase>
|
||||
</Combobox.Target>
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Search
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.currentTarget.value)}
|
||||
placeholder={t("widget.openWebUi.searchModel")}
|
||||
/>
|
||||
<Combobox.Options mah={220} style={{ overflowY: "auto" }}>
|
||||
{filtered.length > 0 ? (
|
||||
filtered.map((item) => (
|
||||
<Combobox.Option value={item.value} key={item.value} active={item.value === value}>
|
||||
<Group gap="xs" justify="space-between" wrap="nowrap">
|
||||
<span className={classes.modelOptionLabel}>{item.label}</span>
|
||||
{item.value === value ? <IconCheck size={14} /> : null}
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<Combobox.Empty>{t("widget.openWebUi.noModels")}</Combobox.Empty>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuRow({
|
||||
icon,
|
||||
label,
|
||||
@@ -226,7 +325,6 @@ export function AttachPopover({
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
loading={attachments.isUploading}
|
||||
onClick={() => attachments.openAttach(!attachments.attachOpened)}
|
||||
aria-label={t("widget.openWebUi.attach")}
|
||||
>
|
||||
@@ -443,9 +541,15 @@ export function MessageBubble({
|
||||
loading?: boolean;
|
||||
}) {
|
||||
const isUser = from === "user";
|
||||
const avatar = (
|
||||
<Avatar size={22} radius="xl" variant="filled" color={isUser ? "gray" : "blue"} className={classes.avatar}>
|
||||
{isUser ? <IconUser size={14} /> : <IconRobot size={14} />}
|
||||
// The assistant shows the Open WebUI brand logo (IconRobot is the fallback if
|
||||
// the image fails to load); the user keeps a plain icon avatar.
|
||||
const avatar = isUser ? (
|
||||
<Avatar size={22} radius="xl" variant="filled" color="gray" className={classes.avatar}>
|
||||
<IconUser size={14} />
|
||||
</Avatar>
|
||||
) : (
|
||||
<Avatar size={22} radius="xl" src={getIconUrl("openWebUi")} alt="Open WebUI" className={classes.avatar}>
|
||||
<IconRobot size={14} />
|
||||
</Avatar>
|
||||
);
|
||||
return (
|
||||
|
||||
@@ -12,6 +12,16 @@
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 3;
|
||||
background-color: var(--mantine-color-body);
|
||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.composer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
@@ -23,10 +33,24 @@
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
}
|
||||
|
||||
.modelSelect input {
|
||||
.modelSelectControl {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Truncate the selected model name once it hits the control's max width. */
|
||||
.modelSelectLabel {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modelOptionLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Markdown-rendered assistant replies: keep block spacing tight inside the bubble. */
|
||||
@@ -189,7 +213,7 @@
|
||||
.historyOverlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--mantine-color-body);
|
||||
|
||||
@@ -8,12 +8,10 @@ import {
|
||||
CloseButton,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Modal,
|
||||
NavLink,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
@@ -43,7 +41,7 @@ import { clientApi } from "@homarr/api/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { AttachmentBadge, AttachPopover, MessageBubble, RecordingWaveform } from "./chat-components";
|
||||
import { AttachmentBadge, AttachPopover, MessageBubble, ModelSelect, RecordingWaveform } from "./chat-components";
|
||||
import classes from "./chat.module.css";
|
||||
import { VISION_MODEL_PATTERN, formatSeconds, hostnameOf } from "./chat-utils";
|
||||
import { useAttachments } from "./use-attachments";
|
||||
@@ -60,9 +58,11 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [historyOpened, setHistoryOpened] = useState(false);
|
||||
const [composerHeight, setComposerHeight] = useState(72);
|
||||
const [headerHeight, setHeaderHeight] = useState(0);
|
||||
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const composerRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data: models = [], isLoading: modelsLoading } = clientApi.widget.openWebUi.getModels.useQuery(
|
||||
@@ -128,6 +128,16 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Track the floating header's height so messages can pad above it.
|
||||
useEffect(() => {
|
||||
const element = headerRef.current;
|
||||
if (!element) return;
|
||||
const observer = new ResizeObserver(() => setHeaderHeight(element.offsetHeight));
|
||||
observer.observe(element);
|
||||
setHeaderHeight(element.offsetHeight);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to the bottom as messages or streamed tokens arrive.
|
||||
useEffect(() => {
|
||||
viewportRef.current?.scrollTo({ top: viewportRef.current.scrollHeight, behavior: "smooth" });
|
||||
@@ -175,8 +185,42 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
|
||||
|
||||
return (
|
||||
<Box className={classes.root} style={isEditMode ? { pointerEvents: "none" } : undefined}>
|
||||
<Group ref={headerRef} className={classes.header} justify="space-between" gap="xs" wrap="nowrap" px="xs" py={6}>
|
||||
<ModelSelect
|
||||
data={modelData}
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
loading={modelsLoading}
|
||||
disabled={stream.isStreaming}
|
||||
/>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Tooltip label={t("widget.openWebUi.newChat")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleNewChat}
|
||||
aria-label={t("widget.openWebUi.newChat")}
|
||||
>
|
||||
<IconPlus size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{options.showHistory ? (
|
||||
<Tooltip label={t("widget.openWebUi.history")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setHistoryOpened(true)}
|
||||
aria-label={t("widget.openWebUi.history")}
|
||||
>
|
||||
<IconHistory size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<ScrollArea className={classes.messages} viewportRef={viewportRef} type="auto">
|
||||
<Stack gap="xs" p="xs" pb={composerHeight + 24}>
|
||||
<Stack gap="xs" p="xs" pt={headerHeight + 8} pb={composerHeight + 24}>
|
||||
{stream.messages.length === 0 && !stream.isStreaming ? (
|
||||
<Stack align="center" justify="center" gap="xs" mt="xl" c="dimmed">
|
||||
<IconRobot size={32} />
|
||||
@@ -337,16 +381,6 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
|
||||
/>
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap" mt={4}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Tooltip label={t("widget.openWebUi.newChat")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={handleNewChat}
|
||||
aria-label={t("widget.openWebUi.newChat")}
|
||||
>
|
||||
<IconPlus size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<AttachPopover
|
||||
attachments={attachments}
|
||||
chats={chats}
|
||||
@@ -357,35 +391,8 @@ export function Chat({ options, integrationIds, isEditMode }: WidgetComponentPro
|
||||
void camera.openCapture();
|
||||
}}
|
||||
/>
|
||||
{options.showHistory ? (
|
||||
<Tooltip label={t("widget.openWebUi.history")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setHistoryOpened(true)}
|
||||
aria-label={t("widget.openWebUi.history")}
|
||||
>
|
||||
<IconHistory size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Select
|
||||
variant="unstyled"
|
||||
size="sm"
|
||||
searchable
|
||||
data={modelData}
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={stream.isStreaming}
|
||||
placeholder={t("widget.openWebUi.selectModel")}
|
||||
nothingFoundMessage={t("widget.openWebUi.noModels")}
|
||||
rightSection={modelsLoading ? <Loader size="xs" /> : undefined}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
maw={200}
|
||||
className={classes.modelSelect}
|
||||
/>
|
||||
{!stream.isStreaming && composerEmpty ? (
|
||||
<Tooltip label={t("widget.openWebUi.record")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
|
||||
Reference in new issue
Block a user