refactor: simplify assistant message controls

This commit is contained in:
ajnart committed 2026-08-14 11:32:10 +02:00
1 parent 6eb75ca559
commit 0c5fc1d331
5 files changed
+310 -197

No files matched your search

+5 -4
View File
@@ -70,7 +70,7 @@ page layout until it is opened. Users can start, revisit, and delete conversatio
conversation menu in the floating panel.
Messages support copy, edit-and-resend, response regeneration and branch navigation, Markdown export,
helpful/not-helpful feedback, text-to-speech, and browser speech dictation when the browser supports it.
and helpful/not-helpful feedback.
Selecting text in a message exposes a quote action that carries the selected passage into the next prompt.
Files returned by a compatible model can be downloaded directly from the message.
Assistant responses render GitHub-flavored Markdown, including headings, links, lists, task lists, tables,
@@ -85,9 +85,10 @@ Type `@` in the composer to add permission-checked context. The nested menu cont
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.
Each completed response includes expandable request details when the provider reports them: input,
output, cached-input and reasoning tokens, context-window utilization, time to first output, total
duration, output speed, agent-step timing, and cost. OpenRouter-reported cost is labeled as reported.
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,
time to first output, total duration, output speed, agent-step timing, and cost. OpenRouter-reported cost
is labeled as reported.
OpenRouter responses also show the routed provider, generation identifier, and upstream inference cost when
the endpoint supplies those fields.
When a provider omits cost but model discovery includes prompt and completion pricing, Homarr labels the
@@ -405,14 +405,12 @@
border-inline-start: 2px solid var(--mantine-color-default-border);
}
.reasoning summary,
.telemetry summary {
.reasoning summary {
cursor: pointer;
list-style: none;
}
.reasoning summary::-webkit-details-marker,
.telemetry summary::-webkit-details-marker {
.reasoning summary::-webkit-details-marker {
display: none;
}
@@ -507,15 +505,72 @@
}
.telemetry {
margin-top: 0.65rem;
padding: 0.5rem 0.6rem;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: color-mix(in srgb, var(--mantine-color-default) 60%, transparent);
margin-top: 0.45rem;
padding-inline: 0.15rem;
}
.telemetrySummary {
user-select: none;
.telemetryFacts {
flex: 1 1 auto;
min-width: 0;
}
.modelBadge {
max-width: min(100%, 14rem);
}
.contextMeter {
display: grid;
width: 2.75rem;
height: 2.75rem;
flex: 0 0 auto;
place-items: center;
border-radius: 50%;
color: var(--mantine-color-text);
transition:
background-color 160ms ease,
transform 160ms ease;
}
.contextMeter:hover {
background: var(--mantine-color-default-hover);
}
.contextMeter:active {
transform: scale(0.96);
}
.contextMeter:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
}
.contextMeterLabel {
font-size: 0.55rem;
line-height: 1;
}
.telemetryPopover {
max-height: min(32rem, calc(100dvh - 2rem));
overflow-y: auto;
}
.contextStats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.contextStats > div {
min-width: 0;
padding: 0.45rem 0.5rem;
border-radius: var(--mantine-radius-sm);
background: var(--mantine-color-body);
}
.contextStats :global(.mantine-Text-root) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.usageGrid {
@@ -813,8 +868,10 @@
@media (prefers-reduced-motion: reduce) {
.activityBar,
.floatingPanel {
.floatingPanel,
.contextMeter {
animation: none;
transition: none;
}
.viewport {
@@ -41,7 +41,7 @@ import {
Group,
Loader,
Popover,
Progress,
RingProgress,
ScrollArea,
Stack,
Text,
@@ -68,7 +68,6 @@ import {
IconFileExport,
IconLink,
IconMessage,
IconMicrophone,
IconPaperclip,
IconPencil,
IconHistory,
@@ -78,7 +77,6 @@ import {
IconRefresh,
IconRobot,
IconSearch,
IconSpeakerphone,
IconThumbDown,
IconThumbUp,
IconTool,
@@ -462,20 +460,6 @@ const AssistantMessageActions = () => {
</ActionIcon>
</ActionBarPrimitive.Copy>
</Tooltip>
<Tooltip label={t("speak")}>
<ActionBarPrimitive.Speak asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("speak")}>
<IconSpeakerphone size={14} />
</ActionIcon>
</ActionBarPrimitive.Speak>
</Tooltip>
<Tooltip label={t("stopSpeaking")}>
<ActionBarPrimitive.StopSpeaking asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("stopSpeaking")}>
<IconPlayerStop size={14} />
</ActionIcon>
</ActionBarPrimitive.StopSpeaking>
</Tooltip>
<Tooltip label={t("helpful")}>
<ActionBarPrimitive.FeedbackPositive asChild>
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("helpful")}>
@@ -586,18 +570,39 @@ const formatCost = (cost: number) => {
return `$${cost.toFixed(cost < 0.01 ? 4 : 3)}`;
};
const getContextColor = (percentage: number) => {
if (percentage >= 90) return "red";
if (percentage >= 75) return "orange";
return "blue";
};
const RequestTelemetry = () => {
const t = useScopedI18n("common.assistant");
const [opened, setOpened] = useState(false);
const metadata = useAuiState((state) => state.message.metadata);
const telemetry = getAssistantTelemetry(metadata);
const usage = getAssistantUsage(metadata);
if (!telemetry) return null;
const contextLength = telemetry.contextLength ?? 0;
const contextUsed = telemetry.contextUsed ?? 0;
const hasContextWindow = telemetry.contextLength !== undefined && telemetry.contextUsed !== undefined;
const contextPercentage = hasContextWindow
? Math.min(
100,
Math.max(0, Math.round((telemetry.contextUtilization ?? contextUsed / Math.max(contextLength, 1)) * 100)),
)
: 0;
const contextRemaining = hasContextWindow ? Math.max(contextLength - contextUsed, 0) : undefined;
const contextLabel = hasContextWindow
? `${t("usage.contextWindow")}: ${contextUsed.toLocaleString()} / ${contextLength.toLocaleString()} (${contextPercentage}%)`
: `${t("usage.contextWindow")}: ${t("usage.notReported")}`;
return (
<details className={classes.telemetry}>
<summary className={classes.telemetrySummary}>
<Group gap="xs" wrap="wrap">
<Badge size="xs" variant="light" color="gray">
<Box className={classes.telemetry}>
<Group justify="space-between" align="center" gap="xs" wrap="nowrap">
<Group className={classes.telemetryFacts} gap="xs" wrap="wrap">
<Badge className={classes.modelBadge} size="xs" variant="light" color="gray">
{telemetry.modelId}
</Badge>
<Badge size="xs" variant="outline" color="gray">
@@ -618,147 +623,218 @@ const RequestTelemetry = () => {
{formatCost(telemetry.cost)}
</Text>
)}
{usage?.totalTokens !== undefined && (
<Text size="xs" c="dimmed">
{usage.totalTokens.toLocaleString()} {t("usage.tokens")}
</Text>
)}
<IconChevronDown size={13} />
</Group>
</summary>
<Stack gap="sm" mt="xs">
{telemetry.contextLength !== undefined && telemetry.contextUsed !== undefined && (
<Box>
<Group justify="space-between" gap="xs">
<Text size="xs" fw={600}>
{t("usage.contextWindow")}
</Text>
<Text size="xs" c="dimmed">
{telemetry.contextUsed.toLocaleString()} / {telemetry.contextLength.toLocaleString()}
</Text>
</Group>
<Progress value={(telemetry.contextUtilization ?? 0) * 100} size="sm" mt={5} />
</Box>
)}
<Box className={classes.usageGrid}>
<div>
<Text size="xs" c="dimmed">
{t("usage.input")}
</Text>
<Text size="sm" fw={600}>
{usage?.inputTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.output")}
</Text>
<Text size="sm" fw={600}>
{usage?.outputTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.cached")}
</Text>
<Text size="sm" fw={600}>
{usage?.cachedInputTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.reasoning")}
</Text>
<Text size="sm" fw={600}>
{usage?.reasoningTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.cacheWrite")}
</Text>
<Text size="sm" fw={600}>
{usage?.cacheWriteTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
</Box>
<Divider />
<Group gap="lg" align="flex-start">
<div>
<Text size="xs" c="dimmed">
{t("usage.firstOutput")}
</Text>
<Text size="sm" fw={600}>
{telemetry.timeToFirstOutputMs !== undefined
? formatDuration(telemetry.timeToFirstOutputMs)
: t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.cost")}
</Text>
<Text size="sm" fw={600}>
{telemetry.cost !== undefined ? formatCost(telemetry.cost) : t("usage.notReported")}
</Text>
{telemetry.costType && (
<Text size="xs" c="dimmed">
{t(`usage.${telemetry.costType}`)}
</Text>
)}
</div>
{telemetry.upstreamCost !== undefined && (
<div>
<Text size="xs" c="dimmed">
{t("usage.upstreamCost")}
</Text>
<Text size="sm" fw={600}>
{formatCost(telemetry.upstreamCost)}
</Text>
</div>
)}
{telemetry.finishReason && (
<div>
<Text size="xs" c="dimmed">
{t("usage.finishReason")}
</Text>
<Text size="sm" fw={600}>
{telemetry.finishReason}
</Text>
</div>
)}
</Group>
{telemetry.steps.length > 0 && (
<Stack gap={4}>
<Text size="xs" fw={600}>
{t("usage.agentSteps")}
</Text>
{telemetry.steps.map((step) => (
<Box key={step.index} className={classes.stepRow}>
<Group justify="space-between" gap="xs">
<Text size="xs">
{t("usage.step", { number: step.index })}
{step.routedProvider ? ` · ${step.routedProvider}` : ""}
<Popover
opened={opened}
onChange={setOpened}
width="min(20rem, calc(100vw - 1.5rem))"
position="bottom-end"
shadow="md"
withArrow
trapFocus
returnFocus
>
<Popover.Target>
<UnstyledButton
className={classes.contextMeter}
aria-label={contextLabel}
aria-expanded={opened}
aria-haspopup="dialog"
title={contextLabel}
onClick={() => setOpened((value) => !value)}
>
<RingProgress
size={40}
thickness={4}
roundCaps
sections={
hasContextWindow ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []
}
label={
<Text className={classes.contextMeterLabel} ta="center" fw={700}>
{hasContextWindow ? `${contextPercentage}%` : "–"}
</Text>
}
/>
</UnstyledButton>
</Popover.Target>
<Popover.Dropdown className={classes.telemetryPopover}>
<Stack gap="sm">
<Group justify="space-between" gap="xs">
<div>
<Text size="sm" fw={700}>
{t("usage.contextWindow")}
</Text>
<Text size="xs" c="dimmed">
{formatDuration(step.durationMs)}
{step.outputTokensPerSecond !== undefined
? ` · ${step.outputTokensPerSecond.toFixed(1)} tok/s`
: ""}
{step.cost !== undefined ? ` · ${formatCost(step.cost)}` : ""}
{t("usage.requestDetails")}
</Text>
</Group>
{step.generationId && (
<Text size="xs" c="dimmed" className={classes.generationId} title={step.generationId}>
{t("usage.generation")}: {step.generationId}
</div>
<Badge size="sm" variant="light" color={getContextColor(contextPercentage)}>
{hasContextWindow ? `${contextPercentage}%` : t("usage.notReported")}
</Badge>
</Group>
<Box className={classes.contextStats}>
<div>
<Text size="xs" c="dimmed">
{t("usage.used")}
</Text>
)}
<Text size="sm" fw={600}>
{telemetry.contextUsed?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.remaining")}
</Text>
<Text size="sm" fw={600}>
{contextRemaining?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.capacity")}
</Text>
<Text size="sm" fw={600}>
{telemetry.contextLength?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
</Box>
))}
</Stack>
)}
</Stack>
</details>
<Divider />
<Box className={classes.usageGrid}>
<div>
<Text size="xs" c="dimmed">
{t("usage.input")}
</Text>
<Text size="sm" fw={600}>
{usage?.inputTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.output")}
</Text>
<Text size="sm" fw={600}>
{usage?.outputTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.cached")}
</Text>
<Text size="sm" fw={600}>
{usage?.cachedInputTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.reasoning")}
</Text>
<Text size="sm" fw={600}>
{usage?.reasoningTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.cacheWrite")}
</Text>
<Text size="sm" fw={600}>
{usage?.cacheWriteTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.tokens")}
</Text>
<Text size="sm" fw={600}>
{usage?.totalTokens?.toLocaleString() ?? t("usage.notReported")}
</Text>
</div>
</Box>
<Divider />
<Group gap="lg" align="flex-start">
<div>
<Text size="xs" c="dimmed">
{t("usage.firstOutput")}
</Text>
<Text size="sm" fw={600}>
{telemetry.timeToFirstOutputMs !== undefined
? formatDuration(telemetry.timeToFirstOutputMs)
: t("usage.notReported")}
</Text>
</div>
<div>
<Text size="xs" c="dimmed">
{t("usage.cost")}
</Text>
<Text size="sm" fw={600}>
{telemetry.cost !== undefined ? formatCost(telemetry.cost) : t("usage.notReported")}
</Text>
{telemetry.costType && (
<Text size="xs" c="dimmed">
{t(`usage.${telemetry.costType}`)}
</Text>
)}
</div>
{telemetry.upstreamCost !== undefined && (
<div>
<Text size="xs" c="dimmed">
{t("usage.upstreamCost")}
</Text>
<Text size="sm" fw={600}>
{formatCost(telemetry.upstreamCost)}
</Text>
</div>
)}
{telemetry.finishReason && (
<div>
<Text size="xs" c="dimmed">
{t("usage.finishReason")}
</Text>
<Text size="sm" fw={600}>
{telemetry.finishReason}
</Text>
</div>
)}
</Group>
{telemetry.steps.length > 0 && (
<Stack gap={4}>
<Text size="xs" fw={600}>
{t("usage.agentSteps")}
</Text>
{telemetry.steps.map((step) => (
<Box key={step.index} className={classes.stepRow}>
<Group justify="space-between" gap="xs">
<Text size="xs">
{t("usage.step", { number: step.index })}
{step.routedProvider ? ` · ${step.routedProvider}` : ""}
</Text>
<Text size="xs" c="dimmed">
{formatDuration(step.durationMs)}
{step.outputTokensPerSecond !== undefined
? ` · ${step.outputTokensPerSecond.toFixed(1)} tok/s`
: ""}
{step.cost !== undefined ? ` · ${formatCost(step.cost)}` : ""}
</Text>
</Group>
{step.generationId && (
<Text size="xs" c="dimmed" className={classes.generationId} title={step.generationId}>
{t("usage.generation")}: {step.generationId}
</Text>
)}
</Box>
))}
</Stack>
)}
</Stack>
</Popover.Dropdown>
</Popover>
</Group>
</Box>
);
};
@@ -1123,18 +1199,6 @@ const Composer = () => {
</ActionIcon>
</ComposerPrimitive.AddAttachment>
</Tooltip>
<Tooltip label={t("dictate")}>
<ComposerPrimitive.Dictate asChild>
<ActionIcon variant="subtle" color="gray" size="lg" aria-label={t("dictate")}>
<IconMicrophone size={17} />
</ActionIcon>
</ComposerPrimitive.Dictate>
</Tooltip>
<ComposerPrimitive.StopDictation asChild>
<ActionIcon variant="light" color="red" size="lg" aria-label={t("stopDictation")}>
<IconPlayerStop size={17} />
</ActionIcon>
</ComposerPrimitive.StopDictation>
</Group>
<ComposerPrimitive.Input
className={classes.composerInput}
@@ -13,8 +13,6 @@ import type {
} from "@assistant-ui/react";
import {
AssistantRuntimeProvider,
WebSpeechDictationAdapter,
WebSpeechSynthesisAdapter,
defineToolkit,
Tools,
useAui,
@@ -278,11 +276,6 @@ const AssistantThreadRuntime = () => {
() => createAssistantAttachmentAdapter(modelCapabilities?.imageInput !== "unsupported"),
[modelCapabilities?.imageInput],
);
const speech = useMemo(() => new WebSpeechSynthesisAdapter(), []);
const dictation = useMemo(
() => (WebSpeechDictationAdapter.isSupported() ? new WebSpeechDictationAdapter() : undefined),
[],
);
const feedback = useMemo(
() => ({
submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => {
@@ -311,8 +304,6 @@ const AssistantThreadRuntime = () => {
adapters: {
history,
attachments,
speech,
...(dictation ? { dictation } : {}),
feedback,
},
});
+4 -4
View File
@@ -2032,14 +2032,10 @@
"cancelEdit": "Cancel",
"updateMessage": "Update message",
"regenerate": "Regenerate response",
"speak": "Read response aloud",
"stopSpeaking": "Stop reading",
"helpful": "Helpful",
"notHelpful": "Not helpful",
"feedbackError": "Your feedback could not be saved. Try again.",
"exportMarkdown": "Export as Markdown",
"dictate": "Dictate message",
"stopDictation": "Stop dictation",
"reasoning": "Reasoning",
"previousResponse": "Previous response",
"nextResponse": "Next response",
@@ -2072,6 +2068,10 @@
"usage": {
"tokens": "tokens",
"contextWindow": "Context window",
"requestDetails": "Request details",
"used": "Used",
"remaining": "Remaining",
"capacity": "Capacity",
"input": "Input",
"output": "Output",
"cached": "Cached input",