diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index d57b2ca6a..613bc7fc8 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -131,18 +131,24 @@ 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. Selected references appear as compact, typed pills in the -composer and sent message, while preserving assistant-ui's underlying directive syntax for the model. Type `/` +composer and sent message, while preserving assistant-ui's underlying directive syntax for the model. When the +assistant cites one of those references in its own response, Homarr renders the same typed pill and uses the +stored app or integration icon when one is available. Type `/` for common prompt shortcuts. Both menus support keyboard navigation and keep the active result visible while moving through long lists. The selector below the composer combines searchable, provider-grouped models with their description, context size and image-input capability, plus the thinking-level control. 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. +still depend on support from the chosen model and endpoint. Reasoning text is shown live when the provider +streams it. The **Thinking** disclosure can be opened or closed without interrupting the response. 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. +While the next response is still streaming, the meter keeps the last completed reading for the same model and +context capacity instead of briefly showing an incorrect zero. It switches to the new request when token usage +arrives; incompatible model capacities are never mixed. OpenRouter responses request inline usage and routing metadata. When request details are opened, Homarr also loads the server-side generation record for each model step and briefly polls while OpenRouter finishes processing it. The details separate Homarr's end-to-end timing from OpenRouter's provider latency, diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-chat-input.spec.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-chat-input.spec.ts index bd04fa72b..6910b0b6a 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-chat-input.spec.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-chat-input.spec.ts @@ -11,7 +11,7 @@ describe("assistant chat input", () => { parts: [ { type: "text", - text: "Check :integration[integration-1]{name=Media server} on :board[board-1]", + text: "Check :integration[Media server]{name=integration-1} on :board[Home]{name=board-1}", }, ], }, diff --git a/apps/nextjs/src/app/api/assistant/chat/assistant-chat-input.ts b/apps/nextjs/src/app/api/assistant/chat/assistant-chat-input.ts index 88cb707c8..dc6d5ccb8 100644 --- a/apps/nextjs/src/app/api/assistant/chat/assistant-chat-input.ts +++ b/apps/nextjs/src/app/api/assistant/chat/assistant-chat-input.ts @@ -3,7 +3,7 @@ export type AssistantMentionReference = { id: string; }; -const mentionPattern = /:(app|integration|board|widget)\[([^\]\n]{1,1024})\](?:\{name=[^}\n]{1,1024}\})?/gu; +const mentionPattern = /:(app|integration|board|widget)\[([^\]\n]{1,1024})\](?:\{name=([^}\n]{1,1024})\})?/gu; const getMessageText = (message: { parts: unknown[] }) => message.parts @@ -20,7 +20,9 @@ export const getRequestedMentionIds = (messages: { role: "user" | "assistant"; p if (message.role !== "user") continue; for (const match of getMessageText(message).matchAll(mentionPattern)) { const type = match[1] as AssistantMentionReference["type"]; - const id = match[2]; + // assistant-ui stores the display label between brackets and the stable entity ID in `name`. + // Keep the bracket value as a fallback for older messages written before named directives. + const id = match[3] ?? match[2]; if (id) mentions.set(`${type}:${id}`, { type, id }); if (mentions.size >= 30) return [...mentions.values()]; } diff --git a/apps/nextjs/src/app/api/assistant/chat/route.ts b/apps/nextjs/src/app/api/assistant/chat/route.ts index 4bf21b0ae..e3b5ae254 100644 --- a/apps/nextjs/src/app/api/assistant/chat/route.ts +++ b/apps/nextjs/src/app/api/assistant/chat/route.ts @@ -646,6 +646,7 @@ export async function POST(request: Request) { return result.toUIMessageStreamResponse>({ originalMessages: parsed.data.messages as UIMessage[], + sendReasoning: true, messageMetadata: ({ part }) => { const common = { requestId, @@ -682,7 +683,11 @@ export async function POST(request: Request) { latestStep?.inputTokens !== undefined && latestStep.outputTokens !== undefined ? latestStep.inputTokens + latestStep.outputTokens : undefined; - const contextUsed = latestContextUsed ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0); + const contextUsed = + latestContextUsed ?? + (usage.inputTokens !== undefined || usage.outputTokens !== undefined + ? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0) + : undefined); const promptPrice = asFiniteNumber(selectedModel?.promptPrice); const completionPrice = asFiniteNumber(selectedModel?.completionPrice); const estimatedCost = @@ -721,8 +726,8 @@ export async function POST(request: Request) { ...(outputTokensPerSecond !== undefined ? { outputTokensPerSecond } : {}), ...(providerOutputTokensPerSecond !== undefined ? { providerOutputTokensPerSecond } : {}), ...(providerGenerationTimeMs !== undefined ? { generationTimeMs: providerGenerationTimeMs } : {}), - ...(contextUsed > 0 ? { contextUsed } : {}), - ...(selectedModel?.contextLength && contextUsed > 0 + ...(contextUsed !== undefined && contextUsed > 0 ? { contextUsed } : {}), + ...(selectedModel?.contextLength && contextUsed !== undefined && contextUsed > 0 ? { contextUtilization: Math.min(contextUsed / selectedModel.contextLength, 1) } : {}), ...(cost !== undefined ? { cost, costType: hasReportedCost ? "reported" : "estimated" } : {}), diff --git a/apps/nextjs/src/components/assistant/assistant-human-tools.tsx b/apps/nextjs/src/components/assistant/assistant-human-tools.tsx index 1945df5df..4513d1a81 100644 --- a/apps/nextjs/src/components/assistant/assistant-human-tools.tsx +++ b/apps/nextjs/src/components/assistant/assistant-human-tools.tsx @@ -2,7 +2,19 @@ import { useEffect, useRef, useState } from "react"; import type { ToolCallMessagePartProps, ToolCallMessagePartStatus } from "@assistant-ui/react"; -import { Alert, Badge, Box, Button, Group, Skeleton, Stack, Text, TextInput, ThemeIcon } from "@mantine/core"; +import { + Alert, + Badge, + Box, + Button, + Group, + Skeleton, + Stack, + Text, + TextInput, + ThemeIcon, + UnstyledButton, +} from "@mantine/core"; import { IconAlertTriangle, IconArrowRight, @@ -170,36 +182,45 @@ export const AssistantAskUserTool = ({ const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray"; return ( - + + {t(`optionKind.${kind}`)} + + ); }) )} diff --git a/apps/nextjs/src/components/assistant/assistant-markdown-directives.spec.ts b/apps/nextjs/src/components/assistant/assistant-markdown-directives.spec.ts new file mode 100644 index 000000000..377907061 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-markdown-directives.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "vitest"; + +import { resolveAssistantDirectiveEntity, transformAssistantMarkdownDirectives } from "./assistant-markdown-directives"; + +describe("assistant markdown directives", () => { + test("turns assistant item citations into typed inline elements", () => { + const directive: { + type: string; + name: string; + attributes: { name: string }; + children: { type: string; value: string }[]; + data?: Record; + } = { + type: "textDirective", + name: "app", + attributes: { name: "app-1" }, + children: [{ type: "text", value: "Discord" }], + }; + transformAssistantMarkdownDirectives({ type: "root", children: [directive] }); + + expect(directive.data).toEqual({ + hName: "span", + hProperties: { + "data-assistant-directive": "true", + "data-directive-id": "app-1", + "data-directive-label": "Discord", + "data-directive-type": "app", + }, + }); + }); + + test("resolves shorthand citations only when their label is unambiguous", () => { + const entities = [ + { id: "app-1", type: "app" as const, label: "Discord", description: "Chat", iconUrl: "/discord.svg" }, + { id: "board-1", type: "board" as const, label: "Home", description: "Home board" }, + ]; + + expect(resolveAssistantDirectiveEntity(entities, { id: "Discord", label: "Discord", type: "app" })?.id).toBe( + "app-1", + ); + expect(resolveAssistantDirectiveEntity(entities, { id: "Home", label: "Home", type: "board" })?.id).toBe("board-1"); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-markdown-directives.ts b/apps/nextjs/src/components/assistant/assistant-markdown-directives.ts new file mode 100644 index 000000000..da6f5f1ee --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-markdown-directives.ts @@ -0,0 +1,63 @@ +export type AssistantDirectiveType = "app" | "integration" | "board" | "widget"; + +export type AssistantDirectiveEntity = { + id: string; + type: AssistantDirectiveType; + label: string; + description: string; + iconUrl?: string; +}; + +type MarkdownNode = { + type?: string; + name?: string; + value?: string; + attributes?: Record | null; + children?: MarkdownNode[]; + data?: Record; +}; + +const directiveTypes = new Set(["app", "integration", "board", "widget"]); + +const getNodeText = (node: MarkdownNode): string => + typeof node.value === "string" ? node.value : (node.children ?? []).map(getNodeText).join(""); + +export const transformAssistantMarkdownDirectives = (tree: MarkdownNode) => { + const visit = (node: MarkdownNode) => { + if (node.type === "textDirective" && node.name && directiveTypes.has(node.name as AssistantDirectiveType)) { + const label = getNodeText(node).trim(); + const directiveId = node.attributes?.name?.trim() || label; + if (label && directiveId) { + node.data = { + ...node.data, + hName: "span", + hProperties: { + "data-assistant-directive": "true", + "data-directive-id": directiveId, + "data-directive-label": label, + "data-directive-type": node.name, + }, + }; + } + } + for (const child of node.children ?? []) visit(child); + }; + + visit(tree); +}; + +export const remarkAssistantDirectives = () => transformAssistantMarkdownDirectives; + +export const resolveAssistantDirectiveEntity = ( + entities: AssistantDirectiveEntity[], + directive: { id: string; label: string; type: string }, +) => { + if (!directiveTypes.has(directive.type as AssistantDirectiveType)) return undefined; + const matchingType = entities.filter((entity) => entity.type === directive.type); + const exactId = matchingType.find((entity) => entity.id === directive.id); + if (exactId) return exactId; + + const normalizedLabel = directive.label.trim().toLocaleLowerCase(); + const labelMatches = matchingType.filter((entity) => entity.label.trim().toLocaleLowerCase() === normalizedLabel); + return labelMatches.length === 1 ? labelMatches[0] : undefined; +}; diff --git a/apps/nextjs/src/components/assistant/assistant-message-metadata.spec.ts b/apps/nextjs/src/components/assistant/assistant-message-metadata.spec.ts index 38aeb89c5..40ea16dc6 100644 --- a/apps/nextjs/src/components/assistant/assistant-message-metadata.spec.ts +++ b/apps/nextjs/src/components/assistant/assistant-message-metadata.spec.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "vitest"; -import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata"; +import { + getAssistantTelemetry, + getAssistantUsage, + resolveAssistantContextWindowTelemetry, +} from "./assistant-message-metadata"; describe("getAssistantTelemetry", () => { test("extracts persisted request telemetry", () => { @@ -117,3 +121,53 @@ describe("getAssistantTelemetry", () => { ).toEqual({ inputTokens: 120, cachedInputTokens: 24 }); }); }); + +describe("resolveAssistantContextWindowTelemetry", () => { + const current = { + requestId: "current", + provider: "openrouter", + modelId: "example/model", + startedAt: "2026-08-04T10:00:00.000Z", + contextLength: 128_000, + steps: [], + }; + const previousMetadata = { + custom: { + telemetry: { + ...current, + requestId: "previous", + completedAt: "2026-08-04T09:59:00.000Z", + contextUsed: 12_500, + }, + }, + }; + + test("keeps the previous completed reading while a matching request streams", () => { + expect(resolveAssistantContextWindowTelemetry(current, [previousMetadata])).toMatchObject({ + source: "previous", + telemetry: { contextUsed: 12_500 }, + }); + }); + + test("prefers a current completed reading", () => { + expect( + resolveAssistantContextWindowTelemetry({ ...current, contextUsed: 13_000 }, [previousMetadata]), + ).toMatchObject({ source: "current", telemetry: { contextUsed: 13_000 } }); + }); + + test("does not mix readings from a model with another context capacity", () => { + expect( + resolveAssistantContextWindowTelemetry(current, [ + { + custom: { + telemetry: { + ...previousMetadata.custom.telemetry, + modelId: "another/model", + contextLength: 1_000_000, + }, + }, + }, + ]), + ).toBeNull(); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-message-metadata.ts b/apps/nextjs/src/components/assistant/assistant-message-metadata.ts index 7ab3dbb9a..05af4de86 100644 --- a/apps/nextjs/src/components/assistant/assistant-message-metadata.ts +++ b/apps/nextjs/src/components/assistant/assistant-message-metadata.ts @@ -87,6 +87,11 @@ export type AssistantMessageMetadata = { export type AssistantUIMessage = UIMessage; +export type AssistantContextWindowTelemetry = { + telemetry: AssistantRequestTelemetry; + source: "current" | "previous"; +}; + const getFiniteNonNegativeNumber = (value: unknown) => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; @@ -300,3 +305,26 @@ export const getAssistantTelemetry = (metadata: unknown): AssistantRequestTeleme ...(typeof value.finishReason === "string" ? { finishReason: value.finishReason } : {}), }; }; + +export const resolveAssistantContextWindowTelemetry = ( + current: AssistantRequestTelemetry, + previousMetadata: unknown[], +): AssistantContextWindowTelemetry | null => { + if (current.contextLength !== undefined && current.contextUsed !== undefined) { + return { telemetry: current, source: "current" }; + } + + for (const metadata of previousMetadata.toReversed()) { + const previous = getAssistantTelemetry(metadata); + if ( + previous?.completedAt && + previous.requestId !== current.requestId && + previous.modelId === current.modelId && + previous.contextLength === current.contextLength && + previous.contextUsed !== undefined + ) { + return { telemetry: previous, source: "previous" }; + } + } + return null; +}; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index be25be8f7..97801cd85 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -48,6 +48,7 @@ border-radius: var(--mantine-radius-sm); color: var(--mantine-color-yellow-light-color); background: var(--mantine-color-yellow-light); + white-space: nowrap; } .activityDismiss { @@ -213,8 +214,8 @@ } .composerInput { - min-height: 2.625rem; - padding: 0.6rem 0.5rem; + min-height: 2.25rem; + padding: 0.35rem 0.45rem; } .runtimeSelectorTrigger { @@ -390,6 +391,13 @@ text-overflow: ellipsis; } +.directiveChipImage { + width: 0.9rem; + height: 0.9rem; + flex: 0 0 auto; + object-fit: contain; +} + .messageMarkdown { line-height: 1.65; overflow-wrap: anywhere; @@ -670,16 +678,65 @@ } .humanToolOption { + display: grid; + width: 100%; height: auto; min-height: 3.25rem; - padding-block: 0.55rem; - padding-inline: 0.8rem; - white-space: normal; + grid-template-columns: max-content minmax(0, 1fr) max-content; + align-items: center; + gap: 0.65rem; + padding: 0.55rem 0.7rem; + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-md); + background: var(--mantine-color-body); + text-align: start; } -.humanToolOption :global(.mantine-Button-inner), -.humanToolOption :global(.mantine-Button-label) { - width: 100%; +.humanToolOption:hover:not(:disabled) { + border-color: var(--mantine-color-default-border); + background: var(--mantine-color-default-hover); +} + +.humanToolOption:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 2px; +} + +.humanToolOption:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.humanToolOption[data-kind="affirmative"] { + border-color: color-mix(in srgb, var(--mantine-color-green-filled) 35%, var(--mantine-color-default-border)); +} + +.humanToolOption[data-kind="negative"] { + border-color: color-mix(in srgb, var(--mantine-color-red-filled) 30%, var(--mantine-color-default-border)); +} + +.humanToolOptionIcon, +.humanToolOptionBadge { + width: max-content; + flex: 0 0 auto; + overflow: visible; + white-space: nowrap; +} + +.humanToolOptionText { + min-width: 0; +} + +.humanToolOptionBadgeLabel { + width: max-content; + overflow: visible; + text-overflow: clip; +} + +.humanToolOptionLabel, +.humanToolOptionDescription { + overflow-wrap: anywhere; + white-space: normal; } .humanToolOtherInput { @@ -797,20 +854,25 @@ background: var(--mantine-color-default); } -.reasoning summary { +.reasoningToggle { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + border-radius: var(--mantine-radius-sm); cursor: pointer; - list-style: none; } -.reasoning summary::-webkit-details-marker { - display: none; +.reasoningToggle:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 2px; } .reasoningChevron { transition: transform 160ms cubic-bezier(0.22, 1, 0.36, 1); } -.reasoning[open] .reasoningChevron { +.reasoning[data-opened] .reasoningChevron { transform: rotate(180deg); } @@ -1091,27 +1153,35 @@ .composerInput { position: relative; - min-height: 2.5rem; + min-height: 2.25rem; max-height: 9rem; flex: 1; - padding: 0.55rem 0.65rem; + box-sizing: border-box; + padding: 0.35rem 0.45rem; color: var(--mantine-color-text); background: transparent; font: inherit; + line-height: 1.5; cursor: text; } .composerInput :global(.aui-lexical-input) { min-height: 1.5rem; + line-height: 1.5; outline: 0; overflow-wrap: anywhere; white-space: pre-wrap; } +.composerInput :global(.aui-lexical-input > p) { + margin: 0; +} + .composerInput :global(.aui-lexical-placeholder) { position: absolute; - inset: 0.55rem 0.65rem auto; + inset: 0.35rem 0.45rem auto; color: var(--mantine-color-dimmed); + line-height: 1.5; pointer-events: none; } @@ -1128,7 +1198,7 @@ width: fit-content; min-width: 0; max-width: min(24rem, 100%); - padding: 0.3rem 0.45rem; + padding: 0.22rem 0.4rem; border: 1px solid var(--mantine-color-default-border); border-radius: var(--mantine-radius-sm); color: var(--mantine-color-text); @@ -1149,10 +1219,6 @@ cursor: not-allowed; } -.runtimeSelectorIcon { - flex: 0 0 auto; -} - .runtimeSelectorName { min-width: 0; flex: 1; @@ -1165,7 +1231,6 @@ display: inline-flex; flex: 0 0 auto; align-items: center; - gap: 0.2rem; text-transform: none; } @@ -1180,7 +1245,16 @@ } .modelOption { - padding-block: 0.45rem; + margin-inline: 0.25rem; + padding: 0.32rem 0.45rem; + border-radius: var(--mantine-radius-sm); +} + +.modelOption[data-combobox-selected], +.modelOption[data-combobox-active] { + color: var(--mantine-color-text); + background: var(--mantine-color-default-hover); + box-shadow: inset 2px 0 0 var(--mantine-primary-color-filled); } .modelOptionText { @@ -1193,13 +1267,17 @@ color: var(--mantine-color-dimmed); } +.modelOptionDescription { + color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-3)); +} + .runtimeOptionCheck { flex: 0 0 auto; color: var(--mantine-primary-color-filled); } .reasoningSelector { - padding: 0.55rem; + padding: 0.4rem 0.45rem 0.45rem; } .reasoningSegmentedControl :global(.mantine-SegmentedControl-label) { @@ -1342,8 +1420,8 @@ } .composerInput { - min-height: 2.625rem; - padding: 0.6rem 0.55rem; + min-height: 2.25rem; + padding: 0.35rem 0.45rem; } .composerFooter { @@ -1369,10 +1447,6 @@ } @media (max-width: 30em) { - .activityReviewAction > span { - display: none; - } - .historyButton { width: 2.625rem; max-width: 2.625rem; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 79d5cc718..443ceea1e 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -67,7 +67,6 @@ import { IconArrowUp, IconArrowsMaximize, IconAt, - IconBrain, IconCheck, IconChevronDown, IconChevronLeft, @@ -92,7 +91,6 @@ import { IconRobot, IconSearch, IconShieldCheck, - IconSparkles, IconThumbDown, IconThumbUp, IconTool, @@ -100,6 +98,7 @@ import { IconX, } from "@tabler/icons-react"; import remarkBreaks from "remark-breaks"; +import remarkDirective from "remark-directive"; import remarkGfm from "remark-gfm"; import { clientApi } from "@homarr/api/client"; @@ -110,9 +109,15 @@ import { useScopedI18n } from "@homarr/translation/client"; import classes from "./assistant-panel.module.css"; import { useAssistantAutoApproval, useAssistantAutomaticAction } from "./assistant-auto-approval"; import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from "./assistant-directives"; +import { remarkAssistantDirectives, resolveAssistantDirectiveEntity } from "./assistant-markdown-directives"; +import type { AssistantDirectiveEntity } from "./assistant-markdown-directives"; import { normalizeAssistantMarkdown } from "./assistant-markdown"; import { getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image"; -import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata"; +import { + getAssistantTelemetry, + getAssistantUsage, + resolveAssistantContextWindowTelemetry, +} from "./assistant-message-metadata"; import type { AssistantPendingAction } from "./assistant-pending-action"; import { AssistantQuestionPortalProvider } from "./assistant-question-portal"; import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences"; @@ -144,7 +149,7 @@ interface AssistantPanelProps extends AssistantConversationControls { pendingAction: AssistantPendingAction | undefined; } -const markdownRemarkPlugins = [remarkGfm, remarkBreaks]; +const markdownRemarkPlugins = [remarkDirective, remarkAssistantDirectives, remarkGfm, remarkBreaks]; const contextIcons = { app: IconApps, @@ -155,9 +160,15 @@ const contextIcons = { tools: IconTool, }; -const ContextDirectiveChip = ({ directiveId, directiveType, label }: DirectiveChipProps) => { +const ContextDirectiveChip = ({ + directiveId, + directiveType, + label, + iconUrl, +}: DirectiveChipProps & { iconUrl?: string }) => { const t = useScopedI18n("common.assistant"); const Icon = contextIcons[directiveType as keyof typeof contextIcons] ?? IconAt; + const safeIconUrl = getSafeAssistantMarkdownImageSource(iconUrl); const translationKey = getAssistantDirectiveTranslationKey(directiveType); const typeLabel = t(`mentions.${translationKey}`); @@ -168,7 +179,11 @@ const ContextDirectiveChip = ({ directiveId, directiveType, label }: DirectiveCh data-directive-id={directiveId} title={`${typeLabel}: ${label}`} > - + {safeIconUrl ? ( + + ) : ( + + )} {label} ); @@ -273,34 +288,78 @@ const MermaidDiagram = ({ code, components }: SyntaxHighlighterProps) => { ); }; -const AssistantTextPart = () => ( - -); +type MarkdownSpanProps = ComponentPropsWithoutRef<"span"> & { + "data-assistant-directive"?: string; + "data-directive-id"?: string; + "data-directive-label"?: string; + "data-directive-type"?: string; +}; + +const AssistantDirectiveEntitiesContext = createContext([]); + +const AssistantMarkdownSpan = ({ + children, + "data-assistant-directive": assistantDirective, + "data-directive-id": directiveId, + "data-directive-label": directiveLabel, + "data-directive-type": directiveType, + ...props +}: MarkdownSpanProps) => { + const entities = useContext(AssistantDirectiveEntitiesContext); + if (assistantDirective === "true" && directiveId && directiveLabel && directiveType) { + const entity = resolveAssistantDirectiveEntity(entities, { + id: directiveId, + label: directiveLabel, + type: directiveType, + }); + return ( + + ); + } + return {children}; +}; + +const AssistantTextPart = () => { + const { data: entities = [] } = clientApi.assistant.getContextEntities.useQuery(undefined, { staleTime: 60_000 }); + + return ( + + + + ); +}; const UserTextPart = () => { const { text } = useMessagePartText(); + const { data: entities = [] } = clientApi.assistant.getContextEntities.useQuery(undefined, { staleTime: 60_000 }); const segments = parseAssistantDirectives(text); return ( - {segments.map((segment, index) => - segment.kind === "text" ? ( - {segment.text} - ) : ( + {segments.map((segment, index) => { + if (segment.kind === "text") return {segment.text}; + const entity = resolveAssistantDirectiveEntity(entities, segment); + return ( - ), - )} + ); + })} ); }; @@ -333,23 +392,40 @@ const ReasoningPart = ({ text }: ReasoningMessagePartProps) => ( const ReasoningGroup = ({ children, running }: { children: ReactNode; running: boolean }) => { const t = useScopedI18n("common.assistant"); + const contentId = useId(); + const [opened, setOpened] = useState(running); + const [manuallyToggled, setManuallyToggled] = useState(false); + + useEffect(() => { + if (running && !manuallyToggled) setOpened(true); + }, [manuallyToggled, running]); + return ( -
- - - - {running ? : } - - {t("reasoning")} - - - + + { + setManuallyToggled(true); + setOpened((value) => !value); + }} + > + + {running && } + + {t("reasoning")} + - - - {children} - -
+ + + + + {children} + + + ); }; @@ -847,6 +923,7 @@ const RequestTelemetry = () => { const metadata = useAuiState((state) => state.message.metadata); const messageId = useAuiState((state) => state.message.id); const threadId = useAuiState((state) => state.threadListItem.remoteId); + const threadMessageMetadata = useAuiState((state) => state.thread.messages.map((message) => message.metadata)); const persistedTelemetry = getAssistantTelemetry(metadata); const persistedUsage = getAssistantUsage(metadata); const generationQuery = clientApi.assistant.getGenerationTelemetry.useQuery( @@ -941,13 +1018,18 @@ const RequestTelemetry = () => { : {}), }; - const contextLength = telemetry.contextLength ?? 0; - const contextUsed = telemetry.contextUsed ?? 0; - const hasContextWindow = telemetry.contextLength !== undefined && telemetry.contextUsed !== undefined; + const contextWindow = resolveAssistantContextWindowTelemetry(telemetry, threadMessageMetadata); + const contextTelemetry = contextWindow?.telemetry; + const contextLength = contextTelemetry?.contextLength ?? 0; + const contextUsed = contextTelemetry?.contextUsed ?? 0; + const hasContextWindow = contextTelemetry?.contextLength !== undefined && contextTelemetry.contextUsed !== undefined; const contextPercentage = hasContextWindow ? Math.min( 100, - Math.max(0, Math.round((telemetry.contextUtilization ?? contextUsed / Math.max(contextLength, 1)) * 100)), + Math.max( + 0, + Math.round((contextTelemetry.contextUtilization ?? contextUsed / Math.max(contextLength, 1)) * 100), + ), ) : 0; const contextRemaining = hasContextWindow ? Math.max(contextLength - contextUsed, 0) : undefined; @@ -1054,7 +1136,7 @@ const RequestTelemetry = () => { {t("usage.used")} - {telemetry.contextUsed?.toLocaleString() ?? t("usage.notReported")} + {contextTelemetry?.contextUsed?.toLocaleString() ?? t("usage.notReported")}
@@ -1070,7 +1152,7 @@ const RequestTelemetry = () => { {t("usage.capacity")} - {telemetry.contextLength?.toLocaleString() ?? t("usage.notReported")} + {contextTelemetry?.contextLength?.toLocaleString() ?? t("usage.notReported")}
@@ -1929,7 +2011,11 @@ type ComposerProps = AssistantConversationControls & { pendingAction: AssistantP const getModelProviderLabel = (modelId: string, fallback: string) => { const separator = modelId.indexOf("/"); if (separator <= 0) return fallback; - const provider = modelId.slice(0, separator).replaceAll(/[-_]/gu, " "); + const provider = modelId + .slice(0, separator) + .replace(/^[^\p{L}\p{N}]+/u, "") + .replaceAll(/[-_]/gu, " "); + if (!provider) return fallback; return `${provider.charAt(0).toLocaleUpperCase()}${provider.slice(1)}`; }; @@ -1981,7 +2067,7 @@ const RuntimeControls = ({ }; return ( - + - - {modelOptionsLoading ? : } - + {modelOptionsLoading && } {selectedModel?.name ?? t("runtime.model")} - {t(`runtime.reasoning.${reasoning}`)} @@ -2015,6 +2098,7 @@ const RuntimeControls = ({ }} placeholder={t("runtime.searchModels")} aria-label={t("runtime.searchModels")} + size="xs" /> {[...groupedModels.entries()].map(([provider, providerModels]) => ( @@ -2027,14 +2111,11 @@ const RuntimeControls = ({ active={model.id === modelId} > - - - {model.name} - + {model.description?.trim() || model.id} @@ -2060,12 +2141,9 @@ const RuntimeControls = ({ - - - - {t("runtime.thinking")} - - + + {t("runtime.thinking")} + { expect(entities).toEqual( expect.arrayContaining([ - expect.objectContaining({ id: "app-1", type: "app", label: "Plex" }), + expect.objectContaining({ + id: "app-1", + type: "app", + label: "Plex", + iconUrl: "https://example.com/plex.svg", + }), expect.objectContaining({ id: "board-1", type: "board", label: "Home" }), expect.objectContaining({ id: "widget-1", type: "widget", boardId: "board-1" }), - expect.objectContaining({ id: "integration-1", type: "integration", label: "Media server" }), + expect.objectContaining({ + id: "integration-1", + type: "integration", + label: "Media server", + iconUrl: expect.stringContaining("plex"), + }), ]), ); }); diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f9d58f6d4..f7f9d9847 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -224,6 +224,7 @@ catalog: assistant-stream: ^0.3.26 "@ai-sdk/openai-compatible": 3.0.14 remark-breaks: ^4.0.0 + remark-directive: ^3.0.1 remark-gfm: ^4.0.1 catalogMode: strict