From 0c5fc1d331806e473ffd465c691e3fc764f2a894 Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 30 Jul 2026 17:33:43 +0200 Subject: [PATCH] refactor: simplify assistant message controls --- apps/docs/docs/management/assistant.mdx | 9 +- .../assistant/assistant-panel.module.css | 81 +++- .../components/assistant/assistant-panel.tsx | 400 ++++++++++-------- .../assistant/assistant-provider.tsx | 9 - packages/translation/src/lang/en.json | 8 +- 5 files changed, 310 insertions(+), 197 deletions(-) diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 29d030b8c..0f654c7f7 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -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 diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index 936870e33..05ef455ee 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -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 { diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 13e5c53a5..5e65964f1 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -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 = () => { - - - - - - - - - - - - - - @@ -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 ( -
- - - + + + + {telemetry.modelId} @@ -618,147 +623,218 @@ const RequestTelemetry = () => { {formatCost(telemetry.cost)} )} - {usage?.totalTokens !== undefined && ( - - {usage.totalTokens.toLocaleString()} {t("usage.tokens")} - - )} - - - - {telemetry.contextLength !== undefined && telemetry.contextUsed !== undefined && ( - - - - {t("usage.contextWindow")} - - - {telemetry.contextUsed.toLocaleString()} / {telemetry.contextLength.toLocaleString()} - - - - - )} - -
- - {t("usage.input")} - - - {usage?.inputTokens?.toLocaleString() ?? t("usage.notReported")} - -
-
- - {t("usage.output")} - - - {usage?.outputTokens?.toLocaleString() ?? t("usage.notReported")} - -
-
- - {t("usage.cached")} - - - {usage?.cachedInputTokens?.toLocaleString() ?? t("usage.notReported")} - -
-
- - {t("usage.reasoning")} - - - {usage?.reasoningTokens?.toLocaleString() ?? t("usage.notReported")} - -
-
- - {t("usage.cacheWrite")} - - - {usage?.cacheWriteTokens?.toLocaleString() ?? t("usage.notReported")} - -
-
- - -
- - {t("usage.firstOutput")} - - - {telemetry.timeToFirstOutputMs !== undefined - ? formatDuration(telemetry.timeToFirstOutputMs) - : t("usage.notReported")} - -
-
- - {t("usage.cost")} - - - {telemetry.cost !== undefined ? formatCost(telemetry.cost) : t("usage.notReported")} - - {telemetry.costType && ( - - {t(`usage.${telemetry.costType}`)} - - )} -
- {telemetry.upstreamCost !== undefined && ( -
- - {t("usage.upstreamCost")} - - - {formatCost(telemetry.upstreamCost)} - -
- )} - {telemetry.finishReason && ( -
- - {t("usage.finishReason")} - - - {telemetry.finishReason} - -
- )} -
- {telemetry.steps.length > 0 && ( - - - {t("usage.agentSteps")} - - {telemetry.steps.map((step) => ( - - - - {t("usage.step", { number: step.index })} - {step.routedProvider ? ` · ${step.routedProvider}` : ""} + + + + setOpened((value) => !value)} + > + + {hasContextWindow ? `${contextPercentage}%` : "–"} + + } + /> + + + + + +
+ + {t("usage.contextWindow")} - {formatDuration(step.durationMs)} - {step.outputTokensPerSecond !== undefined - ? ` · ${step.outputTokensPerSecond.toFixed(1)} tok/s` - : ""} - {step.cost !== undefined ? ` · ${formatCost(step.cost)}` : ""} + {t("usage.requestDetails")} - - {step.generationId && ( - - {t("usage.generation")}: {step.generationId} +
+ + {hasContextWindow ? `${contextPercentage}%` : t("usage.notReported")} + +
+ + +
+ + {t("usage.used")} - )} + + {telemetry.contextUsed?.toLocaleString() ?? t("usage.notReported")} + +
+
+ + {t("usage.remaining")} + + + {contextRemaining?.toLocaleString() ?? t("usage.notReported")} + +
+
+ + {t("usage.capacity")} + + + {telemetry.contextLength?.toLocaleString() ?? t("usage.notReported")} + +
- ))} -
- )} -
-
+ + + +
+ + {t("usage.input")} + + + {usage?.inputTokens?.toLocaleString() ?? t("usage.notReported")} + +
+
+ + {t("usage.output")} + + + {usage?.outputTokens?.toLocaleString() ?? t("usage.notReported")} + +
+
+ + {t("usage.cached")} + + + {usage?.cachedInputTokens?.toLocaleString() ?? t("usage.notReported")} + +
+
+ + {t("usage.reasoning")} + + + {usage?.reasoningTokens?.toLocaleString() ?? t("usage.notReported")} + +
+
+ + {t("usage.cacheWrite")} + + + {usage?.cacheWriteTokens?.toLocaleString() ?? t("usage.notReported")} + +
+
+ + {t("usage.tokens")} + + + {usage?.totalTokens?.toLocaleString() ?? t("usage.notReported")} + +
+
+ + + +
+ + {t("usage.firstOutput")} + + + {telemetry.timeToFirstOutputMs !== undefined + ? formatDuration(telemetry.timeToFirstOutputMs) + : t("usage.notReported")} + +
+
+ + {t("usage.cost")} + + + {telemetry.cost !== undefined ? formatCost(telemetry.cost) : t("usage.notReported")} + + {telemetry.costType && ( + + {t(`usage.${telemetry.costType}`)} + + )} +
+ {telemetry.upstreamCost !== undefined && ( +
+ + {t("usage.upstreamCost")} + + + {formatCost(telemetry.upstreamCost)} + +
+ )} + {telemetry.finishReason && ( +
+ + {t("usage.finishReason")} + + + {telemetry.finishReason} + +
+ )} +
+ + {telemetry.steps.length > 0 && ( + + + {t("usage.agentSteps")} + + {telemetry.steps.map((step) => ( + + + + {t("usage.step", { number: step.index })} + {step.routedProvider ? ` · ${step.routedProvider}` : ""} + + + {formatDuration(step.durationMs)} + {step.outputTokensPerSecond !== undefined + ? ` · ${step.outputTokensPerSecond.toFixed(1)} tok/s` + : ""} + {step.cost !== undefined ? ` · ${formatCost(step.cost)}` : ""} + + + {step.generationId && ( + + {t("usage.generation")}: {step.generationId} + + )} + + ))} + + )} + + + + + ); }; @@ -1123,18 +1199,6 @@ const Composer = () => {
- - - - - - - - - - - - { () => 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, }, }); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index e6f887da5..f7a8067c2 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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",