refactor: group assistant runtime controls
This commit is contained in:
1 parent
e6981a7f44
commit
1d15b614f3
3 files changed
+192
-64
No files matched your search
@@ -95,11 +95,11 @@ as part of the prompt.
|
||||
|
||||
Type `@` in the composer to add permission-checked context. The nested menu contains apps, integrations,
|
||||
boards, widgets, and tools available to the current user. Homarr validates every referenced ID again on
|
||||
the server before adding it to the model context. Type `/` for common prompt shortcuts. The compact
|
||||
controls below the composer can switch between models discovered from the configured endpoint. Model
|
||||
selection is stored on the conversation after a completed request. Thinking mode can be left on
|
||||
**Auto**, disabled, or set from minimal through high; explicit levels are sent using the AI SDK's
|
||||
provider-neutral reasoning setting and still depend on support from the chosen model and endpoint.
|
||||
the server before adding it to the model context. Type `/` for common prompt shortcuts. The joined
|
||||
controls below the composer open a searchable model picker and the thinking-level menu. Model selection
|
||||
is stored on the conversation after a completed request. Thinking mode can be left on **Auto**, disabled,
|
||||
or set from minimal through high; explicit levels are sent using the AI SDK's provider-neutral reasoning
|
||||
setting and still depend on support from the chosen model and endpoint.
|
||||
|
||||
Each completed response includes a circular context-window meter. Select it to open request details when
|
||||
the provider reports them: input, output, cached-input and reasoning tokens, used and remaining context,
|
||||
|
||||
@@ -740,30 +740,70 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.modelSelect {
|
||||
width: min(15rem, 52%);
|
||||
min-width: 8rem;
|
||||
.runtimeButtonGroup {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.reasoningSelect {
|
||||
width: 8.75rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.runtimeSelectInput {
|
||||
border-color: transparent;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
.runtimeButton {
|
||||
min-width: 0;
|
||||
border-color: var(--mantine-color-default-border);
|
||||
color: var(--mantine-color-text);
|
||||
background: var(--mantine-color-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.runtimeSelectInput:hover {
|
||||
.runtimeButton:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.runtimeSelectInput:focus,
|
||||
.runtimeSelectInput:focus-within {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
.runtimeButtonLabel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.modelButton {
|
||||
min-width: 8rem;
|
||||
max-width: 15rem;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.reasoningButton {
|
||||
min-width: 7.5rem;
|
||||
max-width: 10.5rem;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.modelDropdown {
|
||||
max-width: calc(100vw - 2rem);
|
||||
}
|
||||
|
||||
.modelOptions {
|
||||
max-height: min(18rem, 42dvh);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.modelOption {
|
||||
padding-block: 0.45rem;
|
||||
}
|
||||
|
||||
.modelOptionText {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.runtimeOptionCheck {
|
||||
flex: 0 0 auto;
|
||||
color: var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.runtimeMenuItem[data-selected] {
|
||||
color: var(--mantine-primary-color-filled);
|
||||
background: var(--mantine-primary-color-light);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.triggerPopover {
|
||||
@@ -918,10 +958,17 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modelSelect {
|
||||
width: auto;
|
||||
.runtimeButtonGroup {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.modelButton {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.reasoningButton {
|
||||
max-width: 9rem;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
@@ -970,8 +1017,9 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.reasoningSelect {
|
||||
width: 7.75rem;
|
||||
.reasoningButton {
|
||||
min-width: 2.5rem;
|
||||
max-width: 7.75rem;
|
||||
}
|
||||
|
||||
.approvalActions {
|
||||
|
||||
@@ -36,19 +36,21 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Collapse,
|
||||
Combobox,
|
||||
Divider,
|
||||
FocusTrap,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Popover,
|
||||
RingProgress,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
useCombobox,
|
||||
} from "@mantine/core";
|
||||
import { useWindowEvent } from "@mantine/hooks";
|
||||
import {
|
||||
@@ -1212,45 +1214,123 @@ const RuntimeControls = ({
|
||||
onReasoningChange,
|
||||
}: ComposerProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const [modelSearch, setModelSearch] = useState("");
|
||||
const selectedModel = models.find((model) => model.id === modelId);
|
||||
const normalizedModelSearch = modelSearch.trim().toLocaleLowerCase();
|
||||
const visibleModels =
|
||||
normalizedModelSearch.length === 0
|
||||
? models
|
||||
: models.filter(
|
||||
(model) =>
|
||||
model.name.toLocaleLowerCase().includes(normalizedModelSearch) ||
|
||||
model.id.toLocaleLowerCase().includes(normalizedModelSearch),
|
||||
);
|
||||
const modelCombobox = useCombobox({
|
||||
onDropdownOpen: () => {
|
||||
modelCombobox.selectActiveOption();
|
||||
requestAnimationFrame(() => modelCombobox.focusSearchInput());
|
||||
},
|
||||
onDropdownClose: () => {
|
||||
modelCombobox.resetSelectedOption();
|
||||
setModelSearch("");
|
||||
},
|
||||
});
|
||||
|
||||
const selectModel = (value: string) => {
|
||||
if (!models.some((model) => model.id === value)) return;
|
||||
onModelChange(value);
|
||||
modelCombobox.closeDropdown();
|
||||
};
|
||||
|
||||
return (
|
||||
<Group className={classes.runtimeControls} gap={6} wrap="nowrap">
|
||||
<Select
|
||||
className={classes.modelSelect}
|
||||
classNames={{ input: classes.runtimeSelectInput }}
|
||||
size="xs"
|
||||
value={modelId}
|
||||
onChange={(value) => {
|
||||
if (value) onModelChange(value);
|
||||
}}
|
||||
data={models.map((model) => ({ value: model.id, label: model.name }))}
|
||||
searchable
|
||||
allowDeselect={false}
|
||||
disabled={modelOptionsLoading || models.length === 0}
|
||||
leftSection={modelOptionsLoading ? <Loader size={13} /> : <IconSparkles size={14} />}
|
||||
aria-label={t("runtime.model")}
|
||||
placeholder={t("runtime.model")}
|
||||
nothingFoundMessage={t("runtime.noModels")}
|
||||
comboboxProps={{ withinPortal: true, position: "top-start" }}
|
||||
/>
|
||||
<Select
|
||||
className={classes.reasoningSelect}
|
||||
classNames={{ input: classes.runtimeSelectInput }}
|
||||
size="xs"
|
||||
value={reasoning}
|
||||
onChange={(value) => {
|
||||
if (value && assistantReasoningModes.includes(value as AssistantReasoningMode)) {
|
||||
onReasoningChange(value as AssistantReasoningMode);
|
||||
}
|
||||
}}
|
||||
data={assistantReasoningModes.map((mode) => ({
|
||||
value: mode,
|
||||
label: t(`runtime.reasoning.${mode}`),
|
||||
}))}
|
||||
allowDeselect={false}
|
||||
leftSection={<IconBrain size={14} />}
|
||||
aria-label={t("runtime.thinking")}
|
||||
comboboxProps={{ withinPortal: true, position: "top-end" }}
|
||||
/>
|
||||
<Group className={classes.runtimeControls} gap={0} wrap="nowrap">
|
||||
<Button.Group
|
||||
className={classes.runtimeButtonGroup}
|
||||
aria-label={`${t("runtime.model")}, ${t("runtime.thinking")}`}
|
||||
>
|
||||
<Combobox store={modelCombobox} onOptionSubmit={selectModel} withinPortal position="top-start" width={320}>
|
||||
<Combobox.Target>
|
||||
<Button
|
||||
className={classes.modelButton}
|
||||
classNames={{ root: classes.runtimeButton, label: classes.runtimeButtonLabel }}
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
disabled={modelOptionsLoading || models.length === 0}
|
||||
leftSection={modelOptionsLoading ? <Loader size={13} /> : <IconSparkles size={14} />}
|
||||
rightSection={<Combobox.Chevron size="xs" />}
|
||||
onClick={() => modelCombobox.toggleDropdown()}
|
||||
aria-label={`${t("runtime.model")}: ${selectedModel?.name ?? t("runtime.noModels")}`}
|
||||
title={selectedModel?.name}
|
||||
>
|
||||
{selectedModel?.name ?? t("runtime.model")}
|
||||
</Button>
|
||||
</Combobox.Target>
|
||||
<Combobox.Dropdown className={classes.modelDropdown}>
|
||||
<Combobox.Search
|
||||
value={modelSearch}
|
||||
onChange={(event) => {
|
||||
setModelSearch(event.currentTarget.value);
|
||||
modelCombobox.updateSelectedOptionIndex();
|
||||
}}
|
||||
placeholder={t("runtime.model")}
|
||||
aria-label={t("runtime.model")}
|
||||
/>
|
||||
<Combobox.Options className={classes.modelOptions}>
|
||||
{visibleModels.map((model) => (
|
||||
<Combobox.Option
|
||||
className={classes.modelOption}
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
active={model.id === modelId}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Stack gap={0} className={classes.modelOptionText}>
|
||||
<Text size="sm" fw={model.id === modelId ? 650 : 500} lineClamp={1}>
|
||||
{model.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{model.id}
|
||||
</Text>
|
||||
</Stack>
|
||||
{model.id === modelId && <IconCheck size={15} className={classes.runtimeOptionCheck} />}
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
{visibleModels.length === 0 && <Combobox.Empty>{t("runtime.noModels")}</Combobox.Empty>}
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
<Menu position="top-end" width={210} withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
className={classes.reasoningButton}
|
||||
classNames={{ root: classes.runtimeButton, label: classes.runtimeButtonLabel }}
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
leftSection={<IconBrain size={14} />}
|
||||
rightSection={<IconChevronUp size={14} />}
|
||||
aria-label={`${t("runtime.thinking")}: ${t(`runtime.reasoning.${reasoning}`)}`}
|
||||
title={t(`runtime.reasoning.${reasoning}`)}
|
||||
>
|
||||
{t(`runtime.reasoning.${reasoning}`)}
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>{t("runtime.thinking")}</Menu.Label>
|
||||
{assistantReasoningModes.map((mode) => (
|
||||
<Menu.Item
|
||||
key={mode}
|
||||
className={classes.runtimeMenuItem}
|
||||
data-selected={mode === reasoning || undefined}
|
||||
rightSection={mode === reasoning ? <IconCheck size={15} /> : null}
|
||||
onClick={() => onReasoningChange(mode)}
|
||||
>
|
||||
{t(`runtime.reasoning.${mode}`)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Button.Group>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user