From 63280c936a71accd636d467f19df27b65d076407 Mon Sep 17 00:00:00 2001 From: ajnart Date: Sun, 30 Aug 2026 19:06:31 +0200 Subject: [PATCH] feat!: finalize v2 release preparation BREAKING CHANGE: prepare Homarr 2.0.0 release metadata and release safeguards. --- .agents/skills/codebase-context/SKILL.md | 40 + .../codebase-context/agents/openai.yaml | 4 + .../references/architecture.md | 110 + .../codebase-context/references/common.md | 86 + .agents/skills/datatable-migration/SKILL.md | 31 + .../datatable-migration/agents/openai.yaml | 4 + .../references/migration-guide.md | 214 ++ .agents/skills/documentation-sync/SKILL.md | 51 + .../documentation-sync/agents/openai.yaml | 4 + .agents/skills/homarr-custom-widget/SKILL.md | 2 +- .../homarr-custom-widget/agents/openai.yaml | 4 + .agents/skills/mcp-integration/SKILL.md | 98 + .../skills/mcp-integration/agents/openai.yaml | 4 + .agents/skills/release-homarr/SKILL.md | 40 + .../skills/release-homarr/agents/openai.yaml | 4 + .claude/skills | 1 + .cursor/rules/codebase-context.mdc | 208 -- .cursor/rules/documentation-sync.mdc | 50 - .cursor/rules/mantine-datatable-migration.mdc | 612 ---- .cursor/rules/mcp-integration.mdc | 103 - .github/workflows/deployment-docker-image.yml | 24 +- .gitignore | 3 - .mcp.json | 8 + AGENTS.md | 22 +- .../assistant/assistant-composer-triggers.tsx | 192 ++ .../assistant/assistant-composer.tsx | 180 ++ .../assistant-conversation-context.tsx | 610 ++++ .../assistant-conversation-controls.ts | 13 + .../assistant/assistant-history.tsx | 311 ++ .../assistant/assistant-message-content.tsx | 523 ++++ .../assistant/assistant-panel-controls.tsx | 158 + .../components/assistant/assistant-panel.tsx | 2682 +---------------- .../assistant/assistant-runtime-controls.tsx | 350 +++ .../assistant/assistant-tool-rendering.tsx | 438 +++ 34 files changed, 3561 insertions(+), 3623 deletions(-) create mode 100644 .agents/skills/codebase-context/SKILL.md create mode 100644 .agents/skills/codebase-context/agents/openai.yaml create mode 100644 .agents/skills/codebase-context/references/architecture.md create mode 100644 .agents/skills/codebase-context/references/common.md create mode 100644 .agents/skills/datatable-migration/SKILL.md create mode 100644 .agents/skills/datatable-migration/agents/openai.yaml create mode 100644 .agents/skills/datatable-migration/references/migration-guide.md create mode 100644 .agents/skills/documentation-sync/SKILL.md create mode 100644 .agents/skills/documentation-sync/agents/openai.yaml create mode 100644 .agents/skills/homarr-custom-widget/agents/openai.yaml create mode 100644 .agents/skills/mcp-integration/SKILL.md create mode 100644 .agents/skills/mcp-integration/agents/openai.yaml create mode 100644 .agents/skills/release-homarr/SKILL.md create mode 100644 .agents/skills/release-homarr/agents/openai.yaml create mode 120000 .claude/skills delete mode 100644 .cursor/rules/codebase-context.mdc delete mode 100644 .cursor/rules/documentation-sync.mdc delete mode 100644 .cursor/rules/mantine-datatable-migration.mdc delete mode 100644 .cursor/rules/mcp-integration.mdc create mode 100644 .mcp.json create mode 100644 apps/nextjs/src/components/assistant/assistant-composer-triggers.tsx create mode 100644 apps/nextjs/src/components/assistant/assistant-composer.tsx create mode 100644 apps/nextjs/src/components/assistant/assistant-conversation-context.tsx create mode 100644 apps/nextjs/src/components/assistant/assistant-conversation-controls.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-history.tsx create mode 100644 apps/nextjs/src/components/assistant/assistant-message-content.tsx create mode 100644 apps/nextjs/src/components/assistant/assistant-panel-controls.tsx create mode 100644 apps/nextjs/src/components/assistant/assistant-runtime-controls.tsx create mode 100644 apps/nextjs/src/components/assistant/assistant-tool-rendering.tsx diff --git a/.agents/skills/codebase-context/SKILL.md b/.agents/skills/codebase-context/SKILL.md new file mode 100644 index 000000000..679c86db6 --- /dev/null +++ b/.agents/skills/codebase-context/SKILL.md @@ -0,0 +1,40 @@ +--- +name: codebase-context +description: Navigate Homarr's monorepo architecture and reuse shared packages. Use when locating code, choosing a package boundary, understanding runtime services, tRPC, databases, widgets, integrations, cron jobs, Custom Widgets, Workshop, or checking @homarr/common before creating a utility. +--- + +# Homarr Codebase Context + +Orient from the current checkout before editing. Treat package manifests, exports, and source as authoritative when they differ from this snapshot. + +## Workflow + +1. Confirm the branch and inspect the nearest `package.json`, existing sibling modules, and package exports. +2. Search `@homarr/common` before creating a general-purpose helper. Read [references/common.md](references/common.md) for every public entrypoint and export. +3. Read [references/architecture.md](references/architecture.md) when the task crosses packages, runtimes, routing, data, widgets, integrations, cron jobs, Custom Widgets, or Workshop. +4. Keep the change in the deepest package that owns the behavior. Reuse public entrypoints rather than deep-importing implementation files. +5. Re-run targeted searches before documenting counts, consumers, or dependency edges; these change faster than the architectural seams. + +## Current toolchain + +- Use Node `24.18.0` from `mise.toml` and pnpm `11.15.1` from `package.json`. +- Use pnpm workspaces with Turborepo. Workspace packages use the `@homarr/` scope and catalog-managed dependencies. +- Use Next.js App Router, TypeScript, tRPC, Drizzle, Mantine v9, Tabler icons, Jotai, TanStack Query, next-intl, Vitest, and Playwright. +- Use oxlint and oxfmt. Mantine is the application UI system; Tailwind is limited to the docs app. +- `pnpm dev` starts only `@homarr/nextjs`. Start a standalone runtime explicitly when the task needs it. + +## High-value seams + +- Server/RSC tRPC: `@homarr/api/server` +- Client tRPC hooks: `@homarr/api/client` +- Main router: `packages/api/src/root.ts` +- MCP eager router: `packages/api/src/mcp.ts` +- Database schemas: `packages/db/schema/{sqlite,mysql,postgresql}.ts` +- Widget registry: `packages/widgets/src/registry.ts` +- Widget loading manifest: `packages/widgets/src/manifest.ts` +- Integration definitions: `packages/definitions/src/integration.ts` +- Integration factory: `packages/integrations/src/base/creator.ts` +- Cron jobs: `packages/cron-jobs/src/jobs/` +- App routes: `apps/nextjs/src/app/[locale]/` + +When a code change affects users, invoke `documentation-sync`. When exposing tRPC through MCP, invoke `mcp-integration`. diff --git a/.agents/skills/codebase-context/agents/openai.yaml b/.agents/skills/codebase-context/agents/openai.yaml new file mode 100644 index 000000000..86c2c38f0 --- /dev/null +++ b/.agents/skills/codebase-context/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Homarr Codebase Context" + short_description: "Navigate Homarr architecture and shared APIs" + default_prompt: "Use $codebase-context to locate the right Homarr package and reuse existing shared APIs." diff --git a/.agents/skills/codebase-context/references/architecture.md b/.agents/skills/codebase-context/references/architecture.md new file mode 100644 index 000000000..9832b3e01 --- /dev/null +++ b/.agents/skills/codebase-context/references/architecture.md @@ -0,0 +1,110 @@ +# Homarr architecture + +## Contents + +- [Applications and runtimes](#applications-and-runtimes) +- [Package map](#package-map) +- [Dependency direction](#dependency-direction) +- [Core patterns](#core-patterns) +- [Commands and conventions](#commands-and-conventions) + +## Applications and runtimes + +| Path | Role | +| ---------------- | ---------------------------------------------------------------------------------------- | +| `apps/nextjs` | Main Next.js application and HTTP APIs on port 3000 | +| `apps/docs` | Docusaurus documentation site on port 3003 | +| `apps/tasks` | Cron-job initialization and scheduling runtime; it does not expose the old port-3002 API | +| `apps/websocket` | tRPC WebSocket subscriptions on port 3001 | +| `apps/workshop` | Go/PocketBase Workshop service and provider integration | + +`pnpm dev` runs only `@homarr/nextjs`. Use `pnpm --filter @homarr/tasks dev`, `pnpm --filter @homarr/websocket dev`, or `pnpm dev:docs` when a task needs those runtimes. + +In production, `apps/nextjs/src/instrumentation-node.ts` starts the tasks and WebSocket runtimes inside the Node process. Nginx listens on port 7575, proxies `/websockets` to port 3001, and sends other traffic to port 3000. Redis can run internally or externally. + +## Package map + +### Foundation and domain + +- `core`: environment validation, database and Redis factories, logging, infrastructure. +- `definitions`: domain enums, integration definitions, widget kinds, permissions. +- `common`: shared utilities with environment-specific entrypoints. Search it before adding helpers. +- `validation`: shared Zod schemas. +- `server-settings`: typed server-setting keys and access. + +### Data, identity, and transport + +- `db`: Drizzle schemas, queries, migrations, and three database drivers. +- `auth`: Auth.js/NextAuth configuration, sessions, providers, and API keys. +- `api`: tRPC routers, contexts, MCP metadata extraction, OpenAPI, and client/server entrypoints. +- `redis`: Redis clients, pub/sub channels, and caches. +- `cron-jobs-core`, `cron-jobs`, `cron-job-status`: scheduling primitives, job implementations, and status transport. + +### Backends and external systems + +- `integrations`: typed clients for external applications. +- `request-handler`: shared server request flows. +- `docker`, `ping`, `image-proxy`, `icons`, `analytics`: bounded infrastructure capabilities. +- `custom-widgets`: Custom JSX v2 schema, validation, runtime, and security contracts. +- `workshop`: TypeScript Workshop client, backend contracts, and schemas used by Homarr. + +### UI and composed features + +- `ui`: shared Mantine components, theme, and hooks. Search here before creating a primitive. +- `form`, `forms-collection`: Zod-backed form setup and reusable forms. +- `modals`, `modals-collection`: modal primitives and feature modals. +- `boards`: board state, edit mode, and cache coordination. +- `settings`, `spotlight`, `notifications`, `onboarding`: cross-feature UI flows. +- `widgets`: widget definitions, dynamic component loaders, and shared widget UI. +- `translation`: next-intl configuration and locale catalogs. +- `cli`: Node operations CLI. `tools/homarr-dev` is the separate Go developer CLI. + +## Dependency direction + +Prefer these directions; verify the exact dependency in package manifests before changing a boundary. + +1. Put infrastructure in `core` and stable domain names in `definitions`. +2. Put reusable, dependency-light helpers in `common`; use its public environment-specific entrypoints. +3. Build persistence in `db`, identity in `auth`, transport in `redis`, and external clients in `integrations`. +4. Compose server behavior in `api`, `cron-jobs`, and request-handler packages. +5. Build primitives in `ui`, `form`, and `modals`; compose product surfaces in widgets and collection packages. +6. Keep app entrypoints in `apps/*` thin. Avoid moving reusable behavior into `apps/nextjs` when a package already owns it. + +## Core patterns + +### tRPC and MCP + +`packages/api/src/root.ts` lazily composes the application router. Server components use `@homarr/api/server`; client components use `@homarr/api/client`. MCP extraction requires the eager router in `packages/api/src/mcp.ts`, so MCP-enabled subrouters must be imported and registered there directly. Use the `mcp-integration` skill for exposure and security rules. + +### Databases + +Drizzle schemas live in parallel files under `packages/db/schema/`. `DB_DRIVER` selects `better-sqlite3`, `mysql2`, or `node-postgres`; migrations live in matching directories under `packages/db/migrations/`. Apply schema behavior consistently across all supported drivers unless the task is explicitly driver-specific. + +### Widgets + +`packages/widgets/src/registry.ts` owns the explicit widget-module and component-loader registries. `packages/widgets/src/manifest.ts` caches those loaders and exposes definitions, components, and combined resources. Widget modules use `createWidgetDefinition` and typed option builders. Data commonly comes from tRPC queries or Redis-backed cron results. Use existing shared widget components before creating local variants. + +### Integrations + +`packages/definitions/src/integration.ts` owns `integrationDefs` and `IntegrationKind`. `packages/integrations/src/base/creator.ts` owns `createIntegrationAsync`; concrete integrations implement the shared base contract. Keep credentials and permission checks on server-owned paths. + +### Cron jobs + +Jobs live in `packages/cron-jobs/src/jobs/` and use `createCronJob`. `apps/tasks` initializes and starts the job group. Job results and status can flow through Redis and tRPC; the retired port-3002 management-API model is no longer part of the architecture. + +### Routing and localization + +User routes live below `apps/nextjs/src/app/[locale]/`; the locale segment does not add a URL prefix. Route groups organize home and board surfaces. Administrative routes live under `manage`, Custom Widget configuration has a token route, and Workshop has a management surface. + +### Custom Widgets and Workshop + +`packages/custom-widgets` owns the Custom JSX v2 contract. `packages/workshop` is the Homarr-side client/contract package; `apps/workshop` is the separate Go/PocketBase service. Use the `homarr-custom-widget` skill for authoring and security constraints. + +## Commands and conventions + +- Read current scripts from the root or package `package.json`; avoid caching commands that do not exist. +- Use `pnpm dev` for Next.js, `pnpm dev:docs` for docs, and `pnpm dev:cli -- ` for the Go developer CLI. +- Use `pnpm docker:dev:up` for the Redis development dependency. +- Use package-filtered typechecks or focused tests for the touched behavior. +- Use `~/*` only inside the Next.js app. Use public `@homarr/*` entrypoints across package boundaries. +- Keep Mantine application styles separate from the Tailwind-based docs app. diff --git a/.agents/skills/codebase-context/references/common.md b/.agents/skills/codebase-context/references/common.md new file mode 100644 index 000000000..f454752bd --- /dev/null +++ b/.agents/skills/codebase-context/references/common.md @@ -0,0 +1,86 @@ +# `@homarr/common` public API + +## Contents + +- [Reuse workflow](#reuse-workflow) +- [Entrypoints](#entrypoints) +- [Root exports](#root-exports) +- [Types exports](#types-exports) +- [Server exports](#server-exports) +- [Client and environment exports](#client-and-environment-exports) + +## Reuse workflow + +Before writing a utility, search both the public name and the behavior: + +```bash +rg -n "export (const|function|class|type|interface)|export \*" packages/common +rg -n "@homarr/common" apps packages +``` + +Import only from an exported package entrypoint. Do not deep-import `@homarr/common/src/*`. Check `packages/common/package.json` and the source again when adding or renaming an export; this inventory describes the current repository tree. + +## Entrypoints + +| Import | Source | Environment | +| ----------------------- | ---------------------------- | ----------------------------------------------------------------------------------- | +| `@homarr/common` | `index.ts` -> `src/index.ts` | Shared root; includes React hooks and some Next/Mantine-aware helpers | +| `@homarr/common/types` | `src/types.ts` | Type-only utilities | +| `@homarr/common/server` | `src/server.ts` | Node/server-only security, encryption, request, error, and bounded-response helpers | +| `@homarr/common/client` | `src/client.ts` | Client-facing Next server action export | +| `@homarr/common/env` | `env.ts` | Validated package environment | + +## Root exports + +| Area | Public exports | Purpose | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- | +| Objects | `objectKeys`, `objectEntries`, `hashObjectBase64` | Typed key/entry iteration and stable query-key hashing | +| Strings | `capitalize`, `isNullOrWhitespace`, `bestMatch`, `normalizeImageName`, `getImageMatchRank` | Text normalization and matching | +| Cookies | `parseCookies`, `setClientCookie` | Cookie parsing and browser cookie serialization | +| Arrays | `splitToNChunks`, `splitToChunksWithNItems` | Split by number of groups or maximum group size | +| Dates | `formatDuration`, `toValidDate`, `isDateWithin` | Duration formatting, safe date coercion, relative-window checks | +| Timing | `Stopwatch` | Elapsed-time measurement and formatting | +| React hooks | `useTimeAgo`, `useIntegrationConnected` | Relative time and integration freshness state | +| URLs | `removeTrailingSlash`, `extractBaseUrlFromHeaders`, `getPortFromUrl`, `isAbsoluteUrl`, `SAFE_NEW_TAB_REL`, `getSafeApplicationUrl`, `getSafeAppHref` | URL normalization, safe navigation, ports, and new-tab policy | +| Numbers and bytes | `formatNumber`, `randomInt`, `formatBytes`, `formatBytesPair`, `formatByteRate`, `metricToImperial`, `imperialToMetric` | Compact numbers, byte displays, rates, and unit conversion | +| Legacy bytes | `humanFileSize` | Deprecated; use the `formatBytes*` helpers in new code | +| Byte types | `ByteUnitSystem`, `FormatBytesOptions` | Options for byte formatting | +| Errors | `extractErrorMessage`, `FlattenError` | Unknown-error messages and flattenable domain errors | +| Theme | `getMantineColor` | Resolve a Mantine color and shade | +| Functions | `isFunction` | Unknown-value function guard | +| Grid | `GridAlgorithmItem`, `generateResponsiveGridFor` | Responsive nested-section grid placement | +| IDs | `createId` | CUID2 ID creation | + +The root deliberately does not re-export `types`, `server`, `client`, or `env`; use their named entrypoints. + +## Types exports + +`@homarr/common/types` exports: + +- `MaybePromise` +- `AtLeastOneOf` +- `Modify` +- `RemoveReadonly` +- `MaybeArray` +- `Inverse` +- `inferSearchParamsFromSchema` + +## Server exports + +`@homarr/common/server` exports: + +- Security: `generateSecureRandomToken`. +- Encryption: `encryptSecret`, `decryptSecret`, `decryptSecretWithKey`, `createKeyedFingerprint`, `verifyKeyedFingerprint`. +- Requests: `userAgent`, `DeviceType`, `ipAddressFromHeaders`. +- Bounded bodies: `readBoundedJsonResponseAsync`. +- Parse errors: `ParseError`, `ParseErrorHandler`, `ZodParseErrorHandler`, `JsonParseErrorHandler`. +- HTTP handlers: `HttpErrorHandler`, `FetchHttpErrorHandler`, `matchErrorCode`, `OFetchHttpErrorHandler`, `AxiosHttpErrorHandler`, `TsdavHttpErrorHandler`, `OctokitHttpErrorHandler`. +- HTTP errors: `RequestError`, `ResponseError`, `requestErrorMap`. +- HTTP error types: `AnyRequestError`, `AnyRequestErrorInput`, `RequestErrorInput`, `RequestErrorType`, `RequestErrorReason`, `AnyRequestErrorReason`, `RequestErrorCode`. + +`NodeFetchHttpErrorHandler` exists as an implementation file but is not exported by the public server entrypoint. Add an intentional public export before using it across packages. + +## Client and environment exports + +- `@homarr/common/client` exports `revalidatePathActionAsync`, a Next server action for page revalidation. +- `@homarr/common/env` exports `env` with validated `NODE_ENV`, `SECRET_ENCRYPTION_KEY`, and `NO_EXTERNAL_CONNECTION` values. diff --git a/.agents/skills/datatable-migration/SKILL.md b/.agents/skills/datatable-migration/SKILL.md new file mode 100644 index 000000000..ed5a2d1e0 --- /dev/null +++ b/.agents/skills/datatable-migration/SKILL.md @@ -0,0 +1,31 @@ +--- +name: datatable-migration +description: Migrate Homarr tables from mantine-react-table to mantine-datatable. Use when converting an MRT table, changing DataTable columns, sorting, expansion, context menus, responsive visibility, or widget table layout persistence. +--- + +# DataTable Migration + +Migrate one table at a time and preserve its behavior, permissions, translations, responsive layout, and error states. + +## Workflow + +1. Read the source table, its callers, translations, tests, and nearby migrated tables. +2. Search current consumers and shared patterns instead of relying on a cached candidate list: + + ```bash + rg -l 'mantine-react-table' apps packages --glob '*.{ts,tsx}' + rg -l 'from "mantine-datatable"' apps packages --glob '*.{ts,tsx}' + ``` + +3. For a widget table, reuse `packages/widgets/src/common/homarr-data-table.tsx` and `use-persisted-table-layout.ts`. Preserve their shared sizing, edit-mode, styling, hydration, and debounced persistence behavior. +4. Read [references/migration-guide.md](references/migration-guide.md) for the conversion map, sorting, responsive columns, persistence, row interactions, and hardening rules. +5. Keep the existing data and mutation contracts. Convert the view layer without broad unrelated cleanup. +6. Validate the migrated table at its narrow and wide sizes and run only the focused formatter, typecheck, and existing tests that cover the changed table. + +## Completion criteria + +- No MRT imports, types, hooks, or translated-table helpers remain in the migrated component. +- Sorting, row identity, loading, empty, error, selection, expansion, and actions match the previous behavior. +- Widget tables use the shared Homarr wrapper and persistence hooks instead of local copies. +- User-visible strings remain translated and interactive controls remain accessible. +- Documentation is updated through `documentation-sync` when user-facing behavior changes. diff --git a/.agents/skills/datatable-migration/agents/openai.yaml b/.agents/skills/datatable-migration/agents/openai.yaml new file mode 100644 index 000000000..eff2023bc --- /dev/null +++ b/.agents/skills/datatable-migration/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Homarr DataTable Migration" + short_description: "Migrate Homarr tables to Mantine DataTable" + default_prompt: "Use $datatable-migration to migrate this Homarr table while preserving behavior and shared widget patterns." diff --git a/.agents/skills/datatable-migration/references/migration-guide.md b/.agents/skills/datatable-migration/references/migration-guide.md new file mode 100644 index 000000000..b01421e7b --- /dev/null +++ b/.agents/skills/datatable-migration/references/migration-guide.md @@ -0,0 +1,214 @@ +# Mantine DataTable migration guide + +## Contents + +- [Choose the table seam](#choose-the-table-seam) +- [Convert imports and columns](#convert-imports-and-columns) +- [Implement sorting](#implement-sorting) +- [Use the shared widget table](#use-the-shared-widget-table) +- [Persist widget column layout](#persist-widget-column-layout) +- [Adapt columns to widget width](#adapt-columns-to-widget-width) +- [Preserve row interactions](#preserve-row-interactions) +- [Harden the result](#harden-the-result) +- [Migration checklist](#migration-checklist) + +## Choose the table seam + +Inspect current source before choosing an abstraction: + +```bash +rg -l 'mantine-react-table' apps packages --glob '*.{ts,tsx}' +rg -n 'HomarrDataTable|usePersistedTableLayout|useTableLayoutPersistence' packages/widgets/src +``` + +Use `DataTable` directly for management pages and other non-widget surfaces unless that area has a narrower wrapper. For widget tables, use these shared modules: + +- `packages/widgets/src/common/homarr-data-table.tsx` +- `packages/widgets/src/common/homarr-data-table.css` +- `packages/widgets/src/common/use-persisted-table-layout.ts` + +Use downloads, Docker, and Beszel System Table as current examples. The wrapper owns transparent board-card styling, proportional/fixed table layout, paired-column resize previews, standard scroll-area props, cell padding, and edit-mode pointer behavior. Keep those decisions centralized. + +## Convert imports and columns + +Replace MRT primitives and types: + +```typescript +// Before +import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; +import type { MRT_ColumnDef } from "mantine-react-table"; + +// After +import { DataTable } from "mantine-datatable"; +import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; +``` + +Convert each column deliberately: + +| MRT | Mantine DataTable | +| ------------------------ | ------------------------------------ | +| `accessorKey` | `accessor` | +| `header` | `title` | +| `size` | `width` | +| `enableSorting` | `sortable` | +| `Cell: ({ row }) => ...` | `render: (record) => ...` | +| table-body-cell props | `cellsStyle` or shared wrapper props | + +```typescript +const columns: DataTableColumn[] = [ + { + accessor: "name", + title: t("items.name.columnTitle"), + width: 200, + sortable: true, + ellipsis: true, + render: (record) => {record.name}, + }, +]; +``` + +Pass records and columns directly: + +```tsx + +``` + +Memoize columns when they depend on translations, options, permissions, or responsive configuration. Keep every dependency explicit. + +## Implement sorting + +Mantine DataTable reports sort state; the component still owns the sorted records. + +```typescript +const [sortStatus, setSortStatus] = useState>({ + columnAccessor: "name", + direction: "asc", +}); + +const sortedRecords = useMemo(() => { + const multiplier = sortStatus.direction === "desc" ? -1 : 1; + return records.toSorted((left, right) => left.name.localeCompare(right.name) * multiplier); +}, [records, sortStatus.direction]); +``` + +Use an accessor-specific comparator map when columns contain different types. Preserve the original table's null ordering and stable tie-breaker. If options configure the default sort, synchronize `sortStatus` when those options change. + +## Use the shared widget table + +Render widget tables through `HomarrDataTable`: + +```tsx + record.uniqueKey} +/> +``` + +Let the wrapper own its default border, hover, resize, scroll-area, height, transparency, and pointer-event behavior. Extend its props only when multiple widget tables need the same behavior. Keep widget-specific colors and row content in the widget. + +## Persist widget column layout + +Add hidden `columnOrder` and `columnWidths` string options to the widget definition when users can drag or resize columns. Hidden options still require translation labels. + +Build the save callback with `useTableLayoutPersistence`; it updates local options and writes to the board item only when the user has change access and the board/item IDs exist: + +```typescript +const persistLayout = useTableLayoutPersistence({ + boardId, + hasChangeAccess, + itemId, + saveItemOptions, + setOptions, +}); +``` + +Hydrate, validate, merge, and debounce layout changes with `usePersistedTableLayout`: + +```typescript +const { effectiveColumns, storeKey } = usePersistedTableLayout({ + columns, + columnAccessors, + columnOrder: options.columnOrder, + columnWidths: options.columnWidths, + itemId, + storeKeyPrefix: "downloads-table", + onLayoutChange: persistLayout, +}); +``` + +Pass `effectiveColumns` to the table and `storeKey` through the DataTable storage prop used by the local example. Do not recreate JSON parsing, allowed-accessor filtering, hydration guards, store-key resets, hidden-column merging, width normalization, or delayed saves in a widget component. + +## Adapt columns to widget width + +Derive a small size configuration from the widget's measured width. Keep breakpoints ordered and return one complete configuration: + +```typescript +interface SizeConfig { + cellPadding: number; + showSpeed: boolean; + showState: boolean; +} + +const getSizeConfig = (width: number): SizeConfig => { + if (width < 300) return { cellPadding: 2, showSpeed: false, showState: false }; + if (width < 500) return { cellPadding: 4, showSpeed: false, showState: true }; + return { cellPadding: 4, showSpeed: true, showState: true }; +}; + +const size = getSizeConfig(width); +``` + +Prefer clear `if` branches or a breakpoint lookup in production code when more than two tiers exist. Filter or mark columns hidden from one typed visibility map so option selection and width rules are both applied. Preserve the original accessor list for persistence even when a column is temporarily hidden. + +## Preserve row interactions + +### Context menus + +Use Mantine `Menu` and `Portal` with `onRowContextMenu`. Store the pointer coordinates and selected record, prevent the browser menu, and close on scroll, Escape, action completion, or outside interaction. Use `closeOnItemClick={false}` only for a menu item with an inline confirmation step. + +Disable the row context-menu handler in edit mode. Keep action permission checks from the original table and report mutation failures with the shared notification helper. + +### Expansion + +Use DataTable `rowExpansion` and a stable `idAccessor`. Avoid a competing `onRowClick` when `rowExpansion.trigger` is `"click"`; the handlers conflict. Keep expansion content resilient to missing optional fields. + +### Row identity + +Use a composite accessor when IDs can collide across integrations: + +```tsx +idAccessor={(record) => `${record.integration.id}:${record.id}`} +``` + +## Harden the result + +- Guard ratios and percentages against zero denominators. +- Catch `navigator.clipboard.writeText()` failures in non-secure contexts. +- Parse persisted or server-provided JSON through the shared persistence helper, not an unchecked cast. +- Use a small `Box` for a status dot instead of an empty dotted `Badge`. +- Keep tooltip targets clipped inside ellipsis cells and choose a position that remains inside narrow widgets. +- Give every `ActionIcon` an `aria-label` and toggle controls an `aria-pressed` state. +- Translate column titles, detail labels, states, actions, errors, empty states, and hidden option labels. +- Preserve `isError`, empty, initial-loading, background-refresh, and mutation-error behavior separately. +- Keep mutation invalidation and error handling shared when multiple row actions use the same flow. +- Validate action permissions at the procedure boundary; hiding a row action is not authorization. + +## Migration checklist + +1. Inventory the table's columns, sorting, filters, selection, actions, expansion, pagination, responsive behavior, translations, and permissions. +2. Replace MRT imports and convert column definitions. +3. Implement explicit sorting and stable row identity. +4. Use `HomarrDataTable` for widgets; use the nearest existing direct-DataTable pattern elsewhere. +5. Use shared widget persistence hooks when columns are draggable or resizable. +6. Preserve narrow-width visibility without losing saved layout for hidden columns. +7. Restore context menus, expansion, loading, empty, error, and mutation feedback. +8. Restore translations and accessibility attributes. +9. Remove obsolete MRT-only hooks or dependencies only when no consumers remain. +10. Validate the affected table at narrow and wide widths, then run only its focused checks. diff --git a/.agents/skills/documentation-sync/SKILL.md b/.agents/skills/documentation-sync/SKILL.md new file mode 100644 index 000000000..31d4b4852 --- /dev/null +++ b/.agents/skills/documentation-sync/SKILL.md @@ -0,0 +1,51 @@ +--- +name: documentation-sync +description: Keep Homarr's Docusaurus documentation aligned with user-facing code. Use when adding or changing integrations, widgets, APIs, environment variables, CLI commands, authentication, permissions, cron jobs, settings, or UI behavior that users must understand. +--- + +# Documentation Sync + +Update `apps/docs/` in the same change as user-facing behavior. Inspect adjacent pages and the docs types before copying a pattern; the source and docs evolve together. + +## Map the change + +| Code change | Documentation target | +| ----------------------------------- | -------------------------------------------------------------------- | +| New integration | `apps/docs/docs/integrations//index.mdx` and `index.ts` | +| New widget | `apps/docs/docs/widgets//index.mdx` and `index.ts` | +| Changed public or management API | `apps/docs/docs/management/api/index.mdx` | +| New or changed environment variable | Relevant page under `apps/docs/docs/advanced/` or installation docs | +| New or changed CLI command | `apps/docs/docs/advanced/command-line/` | +| Authentication/provider change | Relevant SSO or authentication page under `apps/docs/docs/advanced/` | +| UI or workflow change | Relevant getting-started, management, widget, or integration page | +| Cron-job behavior | `apps/docs/docs/management/tasks.mdx` | +| Permission behavior | `apps/docs/docs/management/users.mdx` and the affected feature page | +| Custom Widget or Workshop behavior | Relevant Custom Widget, Assistant, or Workshop documentation | + +If no existing row fits, search by the user-visible term and update the page where a user would look for the behavior: + +```bash +rg -n "" apps/docs/docs apps/docs/src +``` + +## Follow existing typed patterns + +For an integration: + +- Add or update `apps/docs/docs/integrations//index.ts` with the local `IntegrationDefinition` pattern. +- Add or update `index.mdx` with the established `IntegrationHeader`, `IntegrationCapabilites`, and `IntegrationSecrets` components as applicable. +- Reuse metadata from `@homarr/definitions` when the docs app already exposes it. + +For a widget: + +- Add or update `apps/docs/docs/widgets//index.ts` with the local `WidgetDefinition` pattern. +- Add or update `index.mdx` with the established `WidgetHeader`, `WidgetConfig`, and `AddingWidget` components as applicable. +- Document options, required integrations, permissions, empty states, and changed behavior that affect setup or use. + +## Completion criteria + +1. Describe the shipped behavior, not an implementation plan. +2. Keep names, defaults, paths, screenshots, links, and prerequisites consistent with code. +3. Update every affected page and remove superseded guidance. +4. Run the narrowest useful docs validation. Use `pnpm turbo build --filter=@homarr/docs` when links, MDX, generated definitions, or navigation can fail; otherwise run the docs package formatter on touched files. +5. Treat broken links and anchors as failures; Docusaurus checks them strictly. diff --git a/.agents/skills/documentation-sync/agents/openai.yaml b/.agents/skills/documentation-sync/agents/openai.yaml new file mode 100644 index 000000000..9c9b77cc5 --- /dev/null +++ b/.agents/skills/documentation-sync/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Homarr Documentation Sync" + short_description: "Keep user-facing docs aligned with code" + default_prompt: "Use $documentation-sync to map this Homarr code change to the required documentation updates." diff --git a/.agents/skills/homarr-custom-widget/SKILL.md b/.agents/skills/homarr-custom-widget/SKILL.md index af88d88f5..2ba62e8c2 100644 --- a/.agents/skills/homarr-custom-widget/SKILL.md +++ b/.agents/skills/homarr-custom-widget/SKILL.md @@ -1,6 +1,6 @@ --- name: homarr-custom-widget -description: Author safe Homarr Custom JSX v2 widgets from API documentation. +description: Author, validate, preview, install, and configure safe Homarr Custom JSX v2 widgets. Use when creating a widget from API documentation, testing its states and actions, installing one from Workshop, or configuring sources and credentials through Homarr. --- # Homarr Custom Widget diff --git a/.agents/skills/homarr-custom-widget/agents/openai.yaml b/.agents/skills/homarr-custom-widget/agents/openai.yaml new file mode 100644 index 000000000..666ca1b5a --- /dev/null +++ b/.agents/skills/homarr-custom-widget/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Homarr Custom Widget" + short_description: "Build and deploy safe Custom JSX widgets" + default_prompt: "Use $homarr-custom-widget to author, validate, preview, and configure a safe Custom JSX v2 widget." diff --git a/.agents/skills/mcp-integration/SKILL.md b/.agents/skills/mcp-integration/SKILL.md new file mode 100644 index 000000000..90c2474e1 --- /dev/null +++ b/.agents/skills/mcp-integration/SKILL.md @@ -0,0 +1,98 @@ +--- +name: mcp-integration +description: Expose Homarr tRPC procedures safely through MCP. Use when adding or changing tRPC procedures under packages/api, adding MCP metadata, registering an eager MCP router, shaping tool inputs and descriptions, or reviewing MCP authorization, sensitive data, and destructive actions. +--- + +# MCP Integration + +Expose a procedure only when an AI client should call it. Preserve the procedure's existing authorization and add MCP-specific safeguards for sensitive or destructive behavior. + +## Assess exposure + +Prefer: + +- Data queries: list, search, get, health, stats, and summaries. +- User-requested actions with explicit permission checks and bounded inputs. +- Serializable results that do not reveal credentials or internal-only state. + +Skip subscriptions, session/onboarding internals, file streams, blobs, credential values, and procedures whose authorization or audit behavior is unclear. Ask for a security review when a mutation is destructive, tenant boundaries are ambiguous, or returned data is sensitive. + +## Add metadata + +Place `.meta()` before `.input()` and `.query()` or `.mutation()`: + +```typescript +getAll: protectedProcedure + .meta({ + mcp: { + enabled: true, + description: "List resources the current user can access, including stable IDs used by resource_get", + }, + }) + .input(z.object({ limit: z.number().int().min(1).max(100).default(50) })) + .query(async ({ ctx, input }) => { + // Preserve normal procedure authorization. + }); +``` + +No-input procedures can omit `.input()`; the MCP extractor accepts an empty object for them. + +For mutations, state the effect and required permission in the description: + +```typescript +remove: permissionRequiredProcedure + .meta({ + mcp: { + enabled: true, + description: "Delete one resource by ID. Requires full access; get the ID from resource_all", + }, + }) + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + // Perform the permission-scoped action. + }); +``` + +## Write discoverable descriptions + +Include every item that applies: + +- What the tool returns or changes. +- Supported integrations or services. +- Required IDs and the tool that returns them. +- Permission requirements and the meaning of permission fields. +- Important bounds, confirmation semantics, or irreversible effects. + +Use precise domain language. A description such as `Get calendar events` is insufficient because it omits supported services, required IDs, and result scope. + +## Shape inputs for clients + +- Use a top-level `z.object({...})` for parameterized tools. +- Bound strings, arrays, numeric ranges, pagination, and payload sizes. +- Use defaults where a safe, unsurprising value exists. +- Prefer explicit fields over a top-level union or discriminated union. +- Keep identifiers stable and describe how to obtain them. +- Keep secrets out of inputs unless the procedure is explicitly a credential-configuration flow with server-owned encryption and non-return guarantees. + +## Register the eager router + +The application router in `packages/api/src/root.ts` is lazy. MCP tool extraction is synchronous, so add an eager import and registration in `packages/api/src/mcp.ts`: + +```typescript +import { resourceRouter } from "./router/resource"; + +export const mcpRouter = createTRPCRouter({ + resource: resourceRouter, +}); +``` + +Register the smallest router that owns the enabled procedures. If a parent router uses `lazy()`, import the needed subrouter directly. Metadata on a procedure is not enough; a procedure absent from `mcpRouter` is not exposed. + +## Validate the tool surface + +1. Confirm the procedure still enforces its ordinary auth and permission boundary. +2. Confirm `packages/api/src/mcp.ts` eagerly includes the procedure. +3. Confirm the generated tool name, description, and JSON schema are unambiguous. +4. Run the focused API MCP spec when the tool list or extraction changes: `pnpm test packages/api/src/test/mcp.spec.ts`. +5. Run the focused route spec when protocol or transport behavior changes: `pnpm test apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts`. +6. If manually probing `/api/mcp/`, use a scoped API key and avoid printing credentials or secret-bearing results. diff --git a/.agents/skills/mcp-integration/agents/openai.yaml b/.agents/skills/mcp-integration/agents/openai.yaml new file mode 100644 index 000000000..c72dd10db --- /dev/null +++ b/.agents/skills/mcp-integration/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Homarr MCP Integration" + short_description: "Expose tRPC procedures safely through MCP" + default_prompt: "Use $mcp-integration to expose this Homarr tRPC procedure as a safe, discoverable MCP tool." diff --git a/.agents/skills/release-homarr/SKILL.md b/.agents/skills/release-homarr/SKILL.md new file mode 100644 index 000000000..58b3731b7 --- /dev/null +++ b/.agents/skills/release-homarr/SKILL.md @@ -0,0 +1,40 @@ +--- +name: release-homarr +description: Prepare, verify, publish, or recover a Homarr release. Use when changing the root version, merging dev into main, running Semantic Release, publishing GitHub or GHCR artifacts, manually redeploying an existing version, checking release CI, or coordinating a major release such as v2. +--- + +# Release Homarr + +Treat the Git tag, GitHub release, multi-platform GHCR image, and announcement as separate gates. Verify each one against the same commit and version. + +## Release paths + +- A push to `dev` builds the mutable `dev` image. It does not create a Git tag or GitHub release. +- A push to `main` runs Semantic Release. Merge `dev` into `main` only when the release should start. +- A manual dispatch redeploys the latest existing tag reachable from the selected `main` or `beta` ref. It does not calculate or create a new release. +- A `beta` push creates a prerelease through the configured Semantic Release channel. + +## Prepare a major release + +1. Set the root `package.json` version to the intended major, such as `2.0.0`. Do not change private workspace package versions unless they are published independently. +2. Ensure commits since the latest production tag contain a conventional breaking signal: `type!:` or a `BREAKING CHANGE:` footer. Package metadata alone does not make Semantic Release choose a major. +3. Merge the release work into `dev` and let its CI and preview image complete. This is a release candidate, not a production release. +4. Verify the final `dev` head and open the deliberate `dev` to `main` release PR. Preserve a breaking signal when squashing or rebasing stacked branches. +5. Merge that PR only at the planned announcement time. The `main` push starts the production release workflow. + +## Verify publication + +For the exact `main` release commit, verify: + +1. Semantic Release created the expected `vX.Y.Z` tag and draft GitHub release. +2. AMD64 and ARM64 builds succeeded. +3. The versioned and channel GHCR tags resolve to one multi-platform manifest containing both architectures. +4. Release assets were extracted from the same release ref. +5. The GitHub release became public only after publication completed. +6. The configured announcement ran, or publish the planned human announcement after every prior gate passes. + +Do not rerun an older workflow without comparing its commit and computed version to the intended release. A stale rerun can republish an older reachable tag. + +## Recovery + +Rerun only the failed jobs from the exact release workflow when the release commit and version are correct. Use manual dispatch only to rebuild an existing release intentionally; it is not a substitute for a missing Semantic Release run. diff --git a/.agents/skills/release-homarr/agents/openai.yaml b/.agents/skills/release-homarr/agents/openai.yaml new file mode 100644 index 000000000..fc2dde4dd --- /dev/null +++ b/.agents/skills/release-homarr/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Release Homarr" + short_description: "Safely prepare and publish Homarr releases" + default_prompt: "Use $release-homarr to verify and publish a Homarr release." diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 000000000..2b7a412b8 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.cursor/rules/codebase-context.mdc b/.cursor/rules/codebase-context.mdc deleted file mode 100644 index 418e281c6..000000000 --- a/.cursor/rules/codebase-context.mdc +++ /dev/null @@ -1,208 +0,0 @@ ---- -description: Comprehensive Homarr codebase architecture and conventions reference -alwaysApply: true ---- - -# Homarr Codebase Context - -Homarr is an open-source, self-hosted dashboard for managing homelab services. It integrates with 50+ self-hosted apps (media servers, download clients, DNS, NAS, etc.) and provides a drag-and-drop widget-based UI. - -## Tech Stack - -- **Monorepo**: pnpm workspaces + Turborepo -- **Framework**: Next.js (App Router, `output: "standalone"`) — T3 Stack -- **Language**: TypeScript throughout -- **ORM**: Drizzle (supports SQLite via better-sqlite3, MySQL, PostgreSQL) -- **API**: tRPC (HTTP + WebSocket subscriptions) with superjson + OpenAPI bridge via `trpc-to-openapi` -- **Auth**: NextAuth v5 (database sessions, Credentials/LDAP/OIDC providers) -- **UI**: Mantine v9 (not Tailwind) + Tabler icons -- **State**: Jotai (atoms), TanStack Query (server state via tRPC) -- **Realtime**: WebSocket server (`ws`) for tRPC subscriptions, backed by Redis pub/sub -- **Cron**: `node-cron` in a standalone Fastify service (`apps/tasks`) -- **i18n**: next-intl with `[locale]` dynamic segment (prefix mode: `"never"`, locale from cookie) -- **Testing**: Vitest + jsdom, Playwright for E2E -- **Lint/Format**: oxlint + oxfmt (not ESLint/Prettier) -- **Docs**: Docusaurus 3 in `apps/docs/` (`@homarr/docs`) -- **Package manager**: pnpm 11.15.1, Node >= 24.18.0 - -## Repository Structure - -``` -homarr/ -├── apps/ -│ ├── nextjs/ # Main Next.js application (port 3000) -│ ├── docs/ # Docusaurus 3 documentation site (@homarr/docs) -│ ├── tasks/ # Cron job runner + Fastify tRPC API (port 3002) -│ └── websocket/ # Standalone tRPC WebSocket server (port 3001) -├── packages/ -│ ├── api/ # tRPC appRouter, procedures, OpenAPI -│ ├── auth/ # NextAuth config, providers, session, API keys -│ ├── db/ # Drizzle schema (3 DB drivers), migrations, queries -│ ├── core/ # Env validation, DB/Redis driver factories, logging -│ ├── definitions/ # Domain enums: WidgetKind, IntegrationKind, permissions -│ ├── widgets/ # Dashboard widget definitions, components, and lazy registry -│ ├── integrations/ # Integration classes (HTTP clients to external apps) -│ ├── redis/ # Redis pub/sub channels, caching abstractions -│ ├── translation/ # next-intl setup, locale configs, lang JSON files -│ ├── ui/ # Shared Mantine components, theme, hooks -│ ├── validation/ # Shared zod schemas for API/forms -│ ├── common/ # Shared utilities, IDs, errors -│ ├── cron-jobs/ # Cron job implementations (25+ jobs) -│ ├── cron-jobs-core/ # Cron scheduling primitives -│ ├── cron-job-api/ # tRPC router for job management (start/stop/trigger) -│ ├── cron-job-status/ # Cron status via Redis -│ ├── boards/ # Board context, edit mode, cache updater -│ ├── modals/ # Modal primitives on Mantine -│ ├── modals-collection/ # Feature modals (apps, boards, docker, etc.) -│ ├── form/ # useZodForm (Mantine + zod resolver) -│ ├── forms-collection/# Reusable form UIs (new app, icon picker, upload) -│ ├── spotlight/ # Command palette / search with multiple modes -│ ├── request-handler/ # Server request handlers (feeds, integrations) -│ ├── notifications/ # Mantine notifications wrapper -│ ├── docker/ # Dockerode-based Docker access -│ ├── icons/ # Icon DB/repo integration -│ ├── image-proxy/ # Image proxy + caching -│ ├── ping/ # Reachability / ping utilities -│ ├── analytics/ # Server-side analytics (Umami) -│ ├── server-settings/ # Server setting keys/types -│ ├── settings/ # User-facing settings UI context -│ └── cli/ # Node CLI for ops (brocli) -├── tooling/ -│ ├── typescript/ # Base tsconfig -│ └── github/ # CI setup action -├── development/ # Dev docker-compose (Redis, MySQL, PostgreSQL) -├── e2e/ # E2E test specs -└── Dockerfile # Multi-stage production build -``` - -## Three Runtime Services - -| Service | Port | Role | -|---------|------|------| -| Next.js | 3000 | Main web app (SSR + API routes) | -| WebSocket | 3001 | tRPC subscriptions via `ws` | -| Tasks | 3002 | Cron job runner + management API via Fastify | - -In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header. - -## Package Dependency Layers - -1. **Foundation**: `core` (env, DB drivers, Redis client, logging) → no `@homarr` deps -2. **Domain**: `definitions` → `common` → `core` -3. **Data**: `db` → `core`, `definitions`, `common` -4. **Auth**: `auth` → `db`, `definitions`, `core`, `validation` -5. **Cross-cutting**: `translation`, `validation`, `redis`, `server-settings` -6. **Feature backends**: `integrations`, `request-handler`, `cron-jobs`, `docker`, `ping` -7. **API surface**: `api` → pulls in auth, db, redis, integrations, etc. -8. **UI stack**: `ui`, `modals`, `form`, `notifications`, `boards`, `spotlight` -9. **Composed features**: `widgets`, `modals-collection`, `forms-collection` - -## Key Patterns - -### tRPC - -- **Server caller** (RSC): `import { api } from "@homarr/api/server"` → cached caller with auth context -- **Client hooks**: `import { clientApi } from "@homarr/api/client"` → React Query hooks -- **Router structure**: `packages/api/src/root.ts` defines top-level namespaces (`user`, `board`, `widget`, `integration`, `docker`, `kubernetes`, etc.) -- **Procedures**: `publicProcedure`, `protectedProcedure`, `permissionRequiredProcedure`, `onboardingProcedure` -- **WebSocket subscriptions**: Client uses `wsLink` when `type === "subscription"`, connects to port 3001 - -### Multi-Database Support - -Drizzle schemas exist in three parallel implementations: -- `packages/db/schema/sqlite.ts` -- `packages/db/schema/mysql.ts` -- `packages/db/schema/postgresql.ts` - -Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-postgres`). Migrations are per-engine in `packages/db/migrations/{sqlite,mysql,postgresql}/`. - -### Widget System - -Widget kinds live in `packages/definitions/src/widget.ts`. Integration selection and authorization metadata lives in -`packages/definitions/src/widget-integration-map.ts`. Each widget module exports a typed `definition` and -`componentLoader` created with `createWidgetDefinition(...).withDynamicImport(...)`. - -Register widgets in all three explicit maps in `packages/widgets/src/registry.ts`: the type-only module map, literal -module loaders, and literal component loaders. The two runtime loader maps are deliberately separate so -`packages/widgets/src/manifest.ts` can request a widget's definition chunk and component chunk in parallel while -keeping every lazy import statically discoverable by Next.js. The manifest owns promise caching and clears rejected -entries so later loads can retry. Do not restore an eager root `widgetImports` map. - -### Integration System - -Integrations are defined in `packages/definitions/src/integration.ts` (`integrationDefs`) and instantiated via `createIntegrationAsync` in `packages/integrations/src/base/creator.ts`. Each extends abstract `Integration` class with `testConnectionAsync` and typed methods. - -### Cron Job System - -Jobs defined in `packages/cron-jobs/src/jobs/`. Each job: -- Uses `createCronJob` with a cron expression -- Publishes results to Redis channels -- Widget subscriptions consume those channels via tRPC subscriptions -- Job management (start/stop/trigger) via `cronJobApi` HTTP client to port 3002 - -### Modals - -Created with `createModal(component).withOptions({ defaultTitle })` from `@homarr/modals`. Opened via `useModalAction(SomeModal)`. Feature modals live in `packages/modals-collection/`. - -### Forms - -`useZodForm` from `@homarr/form` wraps Mantine `useForm` with zod validation. Shared form UIs in `packages/forms-collection/`. - -### Routing (App Router) - -- `apps/nextjs/src/app/[locale]/` — all UI routes under locale segment -- Route groups: `(home)`, `(content)`, `(board)` — don't affect URLs -- Key routes: `boards/[name]`, `manage/...` (admin), `auth/login`, `init` (onboarding), `widgets/[kind]` -- Locale driven by cookie (no URL prefix) - -### Auth Providers - -Configured via `AUTH_PROVIDERS` env. Supports: `credentials` (local), `ldap`, `oidc`. Database sessions with httpOnly cookies. API key auth for programmatic access. - -## Environment Variables - -Key env vars (from `.env.example`): -- `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres` -- `DB_URL`: Database connection string -- `AUTH_SECRET`: NextAuth secret -- `SECRET_ENCRYPTION_KEY`: AES-256-CBC for integration secrets -- `AUTH_PROVIDERS`: Comma-separated (`credentials`, `ldap`, `oidc`) -- `CRON_JOB_API_KEY`: Shared key between Next.js and tasks service -- `REDIS_*`: Redis connection (host, port, password) -- `ENABLE_KUBERNETES`: Optional Kubernetes integration - -## Development Commands - -- `pnpm dev` — runs all three services in parallel via Turborepo -- `pnpm docker:dev` — starts Redis + MySQL + PostgreSQL containers -- `pnpm db:push` — push schema to SQLite (dev) -- `pnpm db:studio` — Drizzle Studio -- `pnpm test` — Vitest unit tests -- `pnpm test:e2e` — Playwright E2E -- `pnpm lint` / `pnpm format` — oxlint + oxfmt -- `pnpm dev:docs` — Docusaurus docs site (port 3003) -- `pnpm typecheck` — tsc --noEmit across all packages - -## Conventions - -- Workspace packages use `@homarr/` scope -- All versions managed via pnpm `catalog:` in workspace -- Path alias `~/*` → `./src/*` in Next.js app -- Mantine for all UI (no Tailwind) — primary color is red, `autoContrast: true` -- Icons from `@tabler/icons-react` -- Drag-and-drop via `@dnd-kit/*` -- `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`) -- `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom` - -## Documentation (`apps/docs`) - -Docusaurus 3 site at `apps/docs/`, package name `@homarr/docs`. - -- Run with `pnpm dev:docs` from root -- Build with `pnpm turbo build --filter=@homarr/docs` -- Uses Tailwind CSS 3 for custom pages (not Mantine) -- Imports `@homarr/definitions` for type-safe integration/widget metadata -- When adding or modifying widgets, integrations, cron jobs, API routes, env vars, or user-facing features, you MUST also update corresponding docs in `apps/docs/docs/` -- Integration docs: `apps/docs/docs/integrations//index.mdx` + `index.ts` -- Widget docs: `apps/docs/docs/widgets//index.mdx` + `index.ts` -- Strict link checking: broken links/anchors cause build failure diff --git a/.cursor/rules/documentation-sync.mdc b/.cursor/rules/documentation-sync.mdc deleted file mode 100644 index a05ca5d84..000000000 --- a/.cursor/rules/documentation-sync.mdc +++ /dev/null @@ -1,50 +0,0 @@ ---- -description: Keep documentation in sync with code changes -globs: - - packages/definitions/src/** - - packages/widgets/src/** - - packages/integrations/src/** - - packages/api/src/** - - apps/nextjs/src/** - - apps/docs/** ---- - -# Documentation Sync Rule - -When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`. - -## What triggers a doc update - -| Code change | Doc location | -|---|---| -| New integration | `apps/docs/docs/integrations//index.mdx` + `index.ts` | -| New widget | `apps/docs/docs/widgets//index.mdx` + `index.ts` | -| Changed API surface | `apps/docs/docs/management/api.mdx` | -| New/changed env vars | `apps/docs/docs/advanced/` | -| New CLI commands | `apps/docs/docs/advanced/command-line/` | -| Changed auth providers | `apps/docs/docs/advanced/` SSO pages | -| UI/UX changes | Relevant getting-started or management pages | -| New cron job | `apps/docs/docs/management/tasks.mdx` | -| Changed permissions | `apps/docs/docs/management/users.mdx` | - -## Integration doc pattern - -Each integration has a folder under `apps/docs/docs/integrations//`: - -- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path -- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets` -- Import metadata from `@homarr/definitions` where possible - -## Widget doc pattern - -Each widget has a folder under `apps/docs/docs/widgets//`: - -- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration -- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding` - -## Development - -- Run docs locally: `pnpm dev:docs` from root -- Build docs: `pnpm turbo build --filter=@homarr/docs` -- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata -- Docusaurus strict mode throws on broken links — fix any broken references before committing diff --git a/.cursor/rules/mantine-datatable-migration.mdc b/.cursor/rules/mantine-datatable-migration.mdc deleted file mode 100644 index 55b48fb41..000000000 --- a/.cursor/rules/mantine-datatable-migration.mdc +++ /dev/null @@ -1,612 +0,0 @@ -# Migrating Tables from mantine-react-table to mantine-datatable - -This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables. - -## Migration Candidates - -Current `mantine-react-table` consumers that could be migrated: - -**Widgets (in `packages/widgets/`):** -- `docker/component.tsx` — Docker container table -- `media-server/component.tsx` — Media server sessions - -**Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):** -- `tools/docker/docker-table.tsx` -- `tools/tasks/_components/tasks-table.tsx` -- `tools/api/components/api-keys.tsx` -- `users/_components/user-list.tsx` -- `users/invites/_components/invite-list.tsx` -- `tools/kubernetes/` — 8 Kubernetes resource tables - -**Modals:** -- `packages/modals-collection/src/search-engines/request-media-modal.tsx` - -**Shared hook to remove once all migrated:** -- `packages/ui/src/hooks/use-translated-mantine-react-table.ts` - ---- - -## 1. Core Migration: mantine-react-table → mantine-datatable - -### Import changes - -```typescript -// BEFORE -import { MantineReactTable, useMantineReactTable } from "mantine-react-table"; -import type { MRT_ColumnDef } from "mantine-react-table"; - -// AFTER -import { DataTable, useDataTableColumns } from "mantine-datatable"; -import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable"; -``` - -### Column definition shape - -```typescript -// BEFORE (MRT) -const columns: MRT_ColumnDef[] = [ - { - accessorKey: "name", - header: "Name", - size: 200, - enableSorting: true, - Cell: ({ row }) => {row.original.name}, - }, -]; - -// AFTER (mantine-datatable) -const columns: DataTableColumn[] = [ - { - accessor: "name", // not accessorKey - title: "Name", // not header - width: 200, // not size - sortable: true, // not enableSorting - render: (record) => {record.name}, // not Cell - }, -]; -``` - -Key differences: -- `accessorKey` → `accessor` -- `header` → `title` -- `size` → `width` -- `Cell: ({ row }) =>` → `render: (record) =>` -- `enableSorting` → `sortable` -- `enableColumnActions` → does not exist (no column action menus) -- `mantineTableBodyCellProps` → `cellsStyle` in `defaultColumnProps` - -### Table component - -```typescript -// BEFORE -const table = useTranslatedMantineReactTable({ columns, data, ... }); -return ; - -// AFTER -return ( - -); -``` - -### Sorting is manual - -mantine-datatable does NOT sort for you. You must sort the data yourself: - -```typescript -const [sortStatus, setSortStatus] = useState>({ - columnAccessor: "name", - direction: "asc", -}); - -const sortedData = useMemo(() => { - const mult = sortStatus.direction === "desc" ? -1 : 1; - return [...data].toSorted((a, b) => { - const aVal = a[sortStatus.columnAccessor]; - const bVal = b[sortStatus.columnAccessor]; - if (typeof aVal === "string") return aVal.localeCompare(bVal as string) * mult; - return ((aVal as number) - (bVal as number)) * mult; - }); -}, [data, sortStatus]); - -// Pass sortedData to records, not data - -``` - ---- - -## 2. Making Tables Transparent (Widget Context) - -Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to `var(--mantine-color-body)` which is opaque. - -Create a CSS file alongside the component: - -```css -/* styles.css */ -.my-table, -.my-table .mantine-datatable-table, -.my-table .mantine-datatable-table thead, -.my-table .mantine-datatable-table tbody, -.my-table .mantine-datatable-table tfoot, -.my-table th, -.my-table td { - background-color: transparent !important; -} - -.my-table .mantine-datatable-table tr { - background-color: transparent; -} - -/* Hover: translucent instead of opaque */ -.my-table .mantine-datatable-table tbody tr:hover { - background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important; -} - -/* Headers: frosted glass effect */ -.my-table th { - white-space: nowrap; - background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important; - backdrop-filter: blur(8px); -} - -/* Cell overflow */ -.my-table td { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Hide sort icon until hover */ -.my-table .mantine-datatable-header-cell-sortable-icon { - opacity: 0; - transition: opacity 150ms; -} -.my-table th:hover .mantine-datatable-header-cell-sortable-icon { - opacity: 1; -} - -/* Row expansion area */ -.my-table .mantine-datatable-row-expansion-cell { - background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important; -} -``` - -Import it: `import "./styles.css";` and apply via `className="my-table"`. - ---- - -## 3. Responsive Column Visibility - -Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop. - -```typescript -interface SizeConfig { - fontSize: "xs" | "sm"; - iconSize: number; - cellPadding: number; - showSpeedColumns: boolean; - showTimeColumn: boolean; - showStateColumn: boolean; -} - -const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [ - { maxWidth: 300, config: { fontSize: "xs", iconSize: 12, cellPadding: 2, showSpeedColumns: false, showTimeColumn: false, showStateColumn: false } }, - { maxWidth: 500, config: { fontSize: "xs", iconSize: 14, cellPadding: 4, showSpeedColumns: false, showTimeColumn: true, showStateColumn: false } }, -]; - -const DEFAULT_SIZE_CONFIG: SizeConfig = { fontSize: "sm", iconSize: 14, cellPadding: 4, showSpeedColumns: true, showTimeColumn: true, showStateColumn: true }; - -function getSizeConfig(width: number): SizeConfig { - for (const { maxWidth, config } of SIZE_BREAKPOINTS) { - if (width < maxWidth) return config; - } - return DEFAULT_SIZE_CONFIG; -} - -// Usage: memoize it -const size = useMemo(() => getSizeConfig(width), [width]); -``` - -Then use a visibility check lookup table for columns: - -```typescript -const columnVisibilityChecks: Partial boolean>> = { - upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns, - downSpeed: (ctx) => ctx.size.showSpeedColumns, - time: (ctx) => ctx.size.showTimeColumn, - state: (ctx) => ctx.size.showStateColumn, -}; - -function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean { - if (!ctx.optionsColumnSet.has(accessor)) return false; - const check = columnVisibilityChecks[accessor]; - if (check && !check(ctx)) return false; - return true; -} -``` - ---- - -## 4. Column Persistence (Drag/Resize → Server) - -`useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices. - -### Hidden widget options - -In `index.ts`, add hidden text options: - -```typescript -columnOrder: factory.text({ defaultValue: "" }), -columnWidths: factory.text({ defaultValue: "" }), -``` - -With visibility override: - -```typescript -{ - columnOrder: { shouldHide: () => true }, - columnWidths: { shouldHide: () => true }, -} -``` - -### Translation keys required - -Even hidden options need translation keys or the translation test fails: - -```json -"widget.mywidget.option.columnOrder.label": "Column order", -"widget.mywidget.option.columnWidths.label": "Column widths" -``` - -### Dynamic storeKey - -Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding: - -```typescript -const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`; -``` - -### Hydration from server → localStorage - -On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back: - -```typescript -const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } = - useDataTableColumns({ key: storeKey, columns }); - -const lastStoreKey = useRef(storeKey); -const hydrated = useRef(false); - -useEffect(() => { - if (lastStoreKey.current !== storeKey) { - lastStoreKey.current = storeKey; - hydrated.current = false; - } - if (hydrated.current) return; - if (savedOrder.length > 0) setColumnsOrder(savedOrder); - if (Object.keys(savedWidths).length > 0) { - setMultipleColumnWidths( - Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })), - ); - } - requestAnimationFrame(() => { hydrated.current = true; }); -}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]); -``` - -### Persist localStorage → server - -Watch for changes and write back: - -```typescript -const prevOrder = useRef(columnsOrder); -const prevWidths = useRef(columnsWidth); - -useEffect(() => { - if (!hydrated.current) return; - const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current); - const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current); - prevOrder.current = columnsOrder; - prevWidths.current = columnsWidth; - - if (!orderChanged && !widthsChanged) return; - - if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) }); - if (widthsChanged) { - const widthMap: Record = {}; - for (const entry of columnsWidth) { - const key = Object.keys(entry)[0]; - if (!key) continue; - const w = entry[key as keyof typeof entry]; - if (typeof w === "number") widthMap[key] = w; - else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10); - } - persistOption({ columnWidths: JSON.stringify(widthMap) }); - } -}, [columnsOrder, columnsWidth, persistOption]); -``` - -The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation). - ---- - -## 5. Context Menu (Right-Click) - -Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly: - -```typescript -interface ContextMenuState { - x: number; - y: number; - item: MyRow; -} - -const [contextMenu, setContextMenu] = useState(null); - -const handleContextMenu = useCallback( - ({ record, event }: { record: MyRow; event: React.MouseEvent }) => { - event.preventDefault(); - setContextMenu({ x: event.clientX, y: event.clientY, item: record }); - }, - [], -); - -// On DataTable: - - -// Render: -{contextMenu && } -``` - -The menu component uses `Portal` for correct z-index: - -```tsx -function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: () => void }) { - return ( - - - - - - - { /* action */ onClose(); }}>Action - - - - ); -} -``` - -**Key fix:** Use `closeOnItemClick={false}` if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: `onScroll={() => { if (contextMenu) closeContextMenu(); }}`. - ---- - -## 6. Row Expansion - -```typescript - , - }} -/> -``` - -**Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work. - ---- - -## 7. Common Pitfalls & Fixes - -### Status indicator dots - -Don't use `` without children — it renders a pill with empty space. Use a plain `Box`: - -```tsx - -``` - -### Tooltip overflow on long text columns - -When a column has `ellipsis: true`, the tooltip target element can still extend beyond the cell bounds if inner elements don't clip. Fix: - -```tsx - - - ... - - -``` - -Use `position="bottom-start"` instead of `position="right"` so the tooltip never escapes the widget horizontally. - -### Dark mode tooltips - -Mantine tooltips default to the theme's tooltip color which may be white in light mode and stay white in dark mode. Always set `color="dark"` on informational tooltips inside widgets. - -### Division by zero - -Guard all ratio calculations: - -```typescript -if (item.size > 0) ratio = item.sent / item.size; -if (totalDown > 0) ratio = totalUp / totalDown; -``` - -### Composite idAccessor - -When rows can come from multiple integrations, IDs may collide. Use a composite key: - -```typescript -idAccessor={(record) => `${record.integration.id}:${record.id}`} -``` - -### JSON option parsing - -Always wrap in try/catch: - -```typescript -function parseJsonOption(value: string | undefined, fallback: T): T { - if (!value) return fallback; - try { return JSON.parse(value) as T; } - catch { return fallback; } -} -``` - -### Clipboard API - -`navigator.clipboard.writeText()` can throw in non-HTTPS contexts. Always catch: - -```typescript -void navigator.clipboard.writeText(text).catch(() => {}); -``` - -### Sync sortStatus with widget options - -If the widget has configurable default sort, sync it: - -```typescript -useEffect(() => { - setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection }); -}, [options.defaultSort, defaultSortDirection]); -``` - ---- - -## 8. Accessibility - -- All `ActionIcon` components need `aria-label` -- Filter toggles (client badges, status badges) need `aria-pressed={isActive}` -- `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize - ---- - -## 9. Error Handling - -- Every mutation needs an `onError` callback with `showErrorNotification` -- The `saveItemOptions` mutation (column persistence) also needs `onError` -- Check `isError` on the main query and show a fallback UI -- Use a shared `mutationOptions` object to avoid repeating `onSettled`/`onError`: - -```typescript -const mutationOptions = { - onSettled: () => void utils.myQuery.invalidate(), - onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }), -}; -``` - ---- - -## 10. i18n - -- No hardcoded strings. Every user-visible string goes through `useScopedI18n` -- Column titles: `t("items.{accessor}.columnTitle")` -- Detail labels: `t("items.{accessor}.detailsTitle")` -- States: `t("states.{state}")` -- Actions: `t("actions.{action}")` -- Errors: `t("errors.{errorKey}")` -- Hidden options still need `.label` translation keys - ---- - -## 11. Edit Mode - -Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop: - -```typescript -let tablePointerEvents: React.CSSProperties["pointerEvents"]; -if (isEditMode) tablePointerEvents = "none"; - -let rowContextMenuHandler = handleContextMenu; -if (isEditMode) rowContextMenuHandler = undefined; - - -``` - ---- - -## 12. DataTable Props Reference (Commonly Used) - -```typescript - record.uniqueKey} - height="100%" - className="my-table" - defaultColumnProps={{ - noWrap: true, - draggable: true, - resizable: true, - cellsStyle: () => ({ padding: `${size.cellPadding}px 8px` }), - }} - scrollAreaProps={{ type: "auto", scrollbarSize: 6 }} - rowBackgroundColor={(record) => conditionalBgMap[record.state]} - onRowContextMenu={handleContextMenu} - rowExpansion={{ ... }} - onScroll={() => { if (contextMenu) closeContextMenu(); }} -/> -``` - ---- - -## 13. Layout Stability - -- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic -- Give non-name columns explicit `width` values so only the name column flexes -- Use `ellipsis: true` on the name column — it handles `text-overflow: ellipsis` internally -- Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length -- `flexShrink: 0` on badges/icons prevents them from being squeezed by long names - ---- - -## 14. Checklist for Each Migration - -1. Replace imports (`mantine-react-table` → `mantine-datatable`) -2. Convert column definitions (`accessorKey` → `accessor`, `header` → `title`, `Cell` → `render`, etc.) -3. Implement manual sorting with `useState` + `useMemo` -4. Add CSS file for transparent backgrounds (if widget context) -5. Add responsive column visibility (if widget context) -6. Add hidden options + hydration logic (if column persistence needed) -7. Add translation keys (including hidden option labels) -8. Add context menu if row actions exist -9. Add row expansion if detail view exists -10. Add error handling on all mutations -11. Add accessibility attributes -12. Test at multiple widget widths -13. Update docs in `apps/docs/docs/widgets/` if applicable -14. Run `pnpm turbo typecheck` -15. Run translation spec — catches missing keys diff --git a/.cursor/rules/mcp-integration.mdc b/.cursor/rules/mcp-integration.mdc deleted file mode 100644 index 989b0c4f6..000000000 --- a/.cursor/rules/mcp-integration.mdc +++ /dev/null @@ -1,103 +0,0 @@ ---- -description: How to expose tRPC procedures as MCP tools for AI clients -globs: packages/api/src/router/**/*.ts -alwaysApply: false ---- - -# MCP Integration Guide - -Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr. - -## Adding MCP to a procedure - -Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain: - -```typescript -// Read-only tool (query) -myProcedure: protectedProcedure - .meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } }) - .input(z.object({ limit: z.number().default(50) })) - .query(async ({ ctx, input }) => { ... }) - -// Write tool (mutation) -myAction: protectedProcedure - .meta({ mcp: { enabled: true, description: "Delete an item by ID. Requires interact permission on the integration" } }) - .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { ... }) - -// No-input procedure (z.void) — works fine, no .input() needed -getAll: protectedProcedure - .meta({ mcp: { enabled: true, description: "List all resources" } }) - .query(async ({ ctx }) => { ... }) -``` - -The `.meta()` call must come **before** `.input()` and `.query()`/`.mutation()` in the chain. - -## Writing good descriptions - -The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr. - -**Must include:** -- What the tool returns or does -- Which integrations/services it works with (if applicable) -- How to get required IDs (e.g., "Use integration_all to get the integrationId") -- Permission requirements (if non-obvious) - -**Good:** -``` -"Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all" -``` - -**Bad:** -``` -"Get calendar events" -``` - -**For tools that return permission fields**, explain what they mean: -``` -"List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error" -``` - -## Registering in the MCP router - -After adding `.meta()` to procedures, you must register the router in `packages/api/src/mcp.ts`. This file uses **eager imports** (no `lazy()`) because MCP tool extraction requires synchronous access to all procedure definitions. - -```typescript -// packages/api/src/mcp.ts -import { myNewRouter } from "./router/my-new-feature"; - -export const mcpRouter = createTRPCRouter({ - // ... existing routers - myFeature: myNewRouter, -}); -``` - -**If your router is nested** (e.g., widget sub-routers), import and register the sub-router directly — do not import a parent that uses `lazy()`. - -## What to expose vs. what to skip - -**Expose:** -- Data queries (list, search, get by ID) -- Actions users would ask an AI to do (toggle, create, delete, approve) -- Status checks (health, stats, summaries) - -**Skip:** -- Subscriptions (WebSocket-only, not supported by MCP) -- Internal procedures (session management, onboarding steps) -- File upload/download procedures -- Procedures that return non-serializable data (streams, blobs) - -## Input schema rules - -- Use `z.object({...})` for inputs — this maps cleanly to MCP tool parameters -- `z.void()` (no `.input()`) works — the MCP handler patches these to accept empty objects -- Avoid `z.union()` or `z.discriminatedUnion()` at the top level — AI clients struggle with these -- Use `.default()` on optional fields so the AI doesn't need to guess values - -## Checklist for new features - -1. Add `.meta({ mcp: { enabled: true, description: "..." } })` to relevant procedures -2. Import and register the router in `packages/api/src/mcp.ts` (eager, no lazy) -3. Write descriptions that explain what the tool does, what it needs, and what it returns -4. If the tool requires an ID from another tool, say which one in the description -5. Test with `curl` against `/api/mcp/mcp` to verify the tool appears in `tools/list` diff --git a/.github/workflows/deployment-docker-image.yml b/.github/workflows/deployment-docker-image.yml index 486dcb617..b7cd921c3 100644 --- a/.github/workflows/deployment-docker-image.yml +++ b/.github/workflows/deployment-docker-image.yml @@ -13,7 +13,7 @@ on: type: boolean required: false default: true - description: Send notifications + description: Send notifications when redeploying an existing release permissions: contents: write # Required to update package.json version @@ -41,7 +41,7 @@ jobs: git_ref: ${{ steps.read-git-ref.outputs.ref || github.ref }} skipped: ${{ env.SKIP_RELEASE }} steps: - - run: echo "Skipping release for workflow_dispatch event" + - run: echo "Manual dispatch redeploys the latest existing release without creating a new one" if: env.SKIP_RELEASE == 'true' # The below generated version fallback represents a normalized branch name, for example "feature/branch-name" -> "feature-branch-name" - run: echo "version="$(echo ${{github.ref_name}} | sed 's/[^a-zA-Z0-9\-]/-/g') >> "$GITHUB_OUTPUT" @@ -69,6 +69,26 @@ jobs: with: node-version: 24.18.0 package-manager-cache: false + - name: Verify major release intent + if: env.SKIP_RELEASE == 'false' + run: | + latest_tag="$(git describe --tags --abbrev=0)" + latest_major="${latest_tag#v}" + latest_major="${latest_major%%.*}" + package_version="$(jq --raw-output '.version' package.json)" + package_major="${package_version%%.*}" + + if (( package_major <= latest_major )); then + exit 0 + fi + + if git log --format='%s%n%b' "${latest_tag}..HEAD" \ + | grep --extended-regexp --quiet '(^[[:alnum:]-]+(\([^)]*\))?!:)|(^BREAKING[ -]CHANGE:)'; then + exit 0 + fi + + echo "::error::package.json declares ${package_version}, but commits since ${latest_tag} contain no breaking-change signal. Use a conventional ! commit or BREAKING CHANGE footer so Semantic Release creates the intended major version." + exit 1 - name: Install dependencies if: env.SKIP_RELEASE == 'false' run: pnpm install --frozen-lockfile diff --git a/.gitignore b/.gitignore index be3e30235..8c4d8f8df 100644 --- a/.gitignore +++ b/.gitignore @@ -76,9 +76,6 @@ apps/nextjs/public/images/background.png .opencode .agents/* !.agents/skills/ -.agents/skills/* -!.agents/skills/homarr-custom-widget/ -!.agents/skills/homarr-custom-widget/** skills-lock.json # docusaurus diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..6636fc5c1 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "mantine": { + "command": "npx", + "args": ["-y", "@mantine/mcp-server"] + } + } +} diff --git a/AGENTS.md b/AGENTS.md index e9a25c2ec..bed9e8ecc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,8 +7,9 @@ homarr/ ├── apps/ │ ├── nextjs/ # Main Next.js application (port 3000) │ ├── docs/ # Docusaurus 3 documentation site (@homarr/docs) -│ ├── tasks/ # Cron job runner + Fastify tRPC API (port 3002) -│ └── websocket/ # Standalone tRPC WebSocket server (port 3001) +│ ├── tasks/ # Cron-job initialization and scheduling runtime +│ ├── websocket/ # Standalone tRPC WebSocket server (port 3001) +│ └── workshop/ # Go/PocketBase Workshop service ├── packages/ │ ├── api/ # tRPC appRouter, procedures, OpenAPI │ ├── auth/ # NextAuth config, providers, session, API keys @@ -40,6 +41,9 @@ homarr/ │ ├── analytics/ # Server-side analytics (Umami) │ ├── server-settings/ # Server setting keys/types │ ├── settings/ # User-facing settings UI context +│ ├── custom-widgets/ # Custom JSX v2 schema, validation, and runtime +│ ├── onboarding/ # Onboarding studio and setup flow +│ ├── workshop/ # Homarr-side Workshop client and contracts │ └── cli/ # Node CLI for ops (brocli) ├── tooling/ │ ├── typescript/ # Base tsconfig @@ -59,7 +63,7 @@ When modifying user-facing code, you MUST also update the corresponding document - New integration → `apps/docs/docs/integrations//index.mdx` + `index.ts` - New widget → `apps/docs/docs/widgets//index.mdx` + `index.ts` -- Changed API → `apps/docs/docs/management/api.mdx` +- Changed API → `apps/docs/docs/management/api/index.mdx` - New/changed env vars → `apps/docs/docs/advanced/` - New CLI commands → `apps/docs/docs/advanced/command-line/` - Auth changes → `apps/docs/docs/advanced/` SSO pages @@ -82,9 +86,21 @@ When modifying user-facing code, you MUST also update the corresponding document - Lint: oxlint (not ESLint) - Format: oxfmt (not Prettier) - UI: Mantine (not Tailwind) — Tailwind is only used in docs app +- Mantine: use the project-scoped MCP server in `.mcp.json` for current v9 APIs, and check `packages/ui/` before creating a new primitive. - Icons: @tabler/icons-react - Docs app can import from `@homarr/definitions` for shared types - Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images. - Run `pnpm dev:cli -- build ` from a Homarr checkout to build `homarr:` with rebuild provenance. - Run `pnpm dev:cli -- build --pr ` to build a PR locally from a temporary checkout. - Run Go checks from `tools/homarr-dev` with `go test ./...` and `go vet ./...`. + +## Agent Skills + +Portable skills live in `.agents/skills/`. Read the relevant `SKILL.md` before working in that domain; detailed references are loaded only when needed. Claude-compatible discovery is provided through `.claude/skills`. + +- `codebase-context` — architecture, package boundaries, and shared utilities +- `documentation-sync` — required user-facing documentation updates +- `mcp-integration` — safe tRPC-to-MCP exposure +- `datatable-migration` — Mantine DataTable migration patterns +- `homarr-custom-widget` — safe Custom JSX v2 authoring +- `release-homarr` — release preparation, publication, and recovery diff --git a/apps/nextjs/src/components/assistant/assistant-composer-triggers.tsx b/apps/nextjs/src/components/assistant/assistant-composer-triggers.tsx new file mode 100644 index 000000000..26a66de8f --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-composer-triggers.tsx @@ -0,0 +1,192 @@ +"use client"; + +import type { RefObject } from "react"; +import { useContext, useLayoutEffect, useMemo, useRef } from "react"; +import type { Unstable_TriggerItem } from "@assistant-ui/react"; +import { + ComposerPrimitive, + useAui, + unstable_useMentionAdapter, + unstable_useSlashCommandAdapter, + unstable_useTriggerPopoverScopeContext, +} from "@assistant-ui/react"; +import { Text, ThemeIcon } from "@mantine/core"; +import { IconAt, IconChevronRight } from "@tabler/icons-react"; + +import { useI18n } from "@homarr/translation/client"; + +import classes from "./assistant-panel.module.css"; +import { AssistantDirectiveEntitiesContext, contextIcons } from "./assistant-message-content"; +import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll"; + +const TriggerPopoverAutoScroll = ({ viewportRef }: { viewportRef: RefObject }) => { + const { activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query } = + unstable_useTriggerPopoverScopeContext(); + + useLayoutEffect(() => { + if (!open) return; + + const viewport = viewportRef.current; + const highlightedItem = viewport?.querySelector("[data-highlighted]"); + if (!viewport || !highlightedItem) return; + + const viewportRect = viewport.getBoundingClientRect(); + const itemRect = highlightedItem.getBoundingClientRect(); + const viewportTop = viewportRect.top + viewport.clientTop; + const viewportBottom = viewportTop + viewport.clientHeight; + + viewport.scrollTop = getNearestTriggerScrollTop({ + scrollTop: viewport.scrollTop, + viewportTop, + viewportBottom, + itemTop: itemRect.top, + itemBottom: itemRect.bottom, + }); + }, [activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query, viewportRef]); + + return null; +}; + +const TriggerItem = ({ item, index }: { item: Unstable_TriggerItem; index: number }) => { + const Icon = + typeof item.metadata?.icon === "string" && item.metadata.icon in contextIcons + ? contextIcons[item.metadata.icon as keyof typeof contextIcons] + : IconAt; + return ( + + + + +
+ + {item.label} + + {item.description && ( + + {item.description} + + )} +
+
+ ); +}; + +export const ComposerTriggers = () => { + const t = useI18n("assistant"); + const aui = useAui(); + const mentionViewportRef = useRef(null); + const slashViewportRef = useRef(null); + const { entities, isLoading } = useContext(AssistantDirectiveEntitiesContext); + const categories = useMemo( + () => + (["app", "integration", "board", "widget"] as const).map((type) => ({ + id: type, + label: t(`mentions.${type}`), + items: entities + .filter((entity) => entity.type === type) + .map((entity) => ({ + id: entity.id, + type: entity.type, + label: entity.label, + description: entity.description, + icon: entity.type, + })), + })), + [entities, t], + ); + const mention = unstable_useMentionAdapter({ + categories, + includeModelContextTools: { + category: { id: "tools", label: t("mentions.tools") }, + formatLabel: (name) => name.replaceAll("_", " "), + icon: "tools", + }, + iconMap: contextIcons, + fallbackIcon: IconAt, + }); + const slash = unstable_useSlashCommandAdapter({ + removeOnExecute: true, + commands: [ + { + id: "health", + label: "/health", + description: t("commands.health"), + execute: () => aui.composer().setText(t("suggestions.health.prompt")), + }, + { + id: "explore", + label: "/explore", + description: t("commands.explore"), + execute: () => aui.composer().setText(t("suggestions.explore.prompt")), + }, + { + id: "media", + label: "/media", + description: t("commands.media"), + execute: () => aui.composer().setText(t("suggestions.media.prompt")), + }, + { + id: "style", + label: "/style", + description: t("commands.style"), + execute: () => aui.composer().setText(t("suggestions.style.prompt")), + }, + ], + }); + + return ( + <> + + + + + {(items) => + items.map((category) => { + const Icon = contextIcons[category.id as keyof typeof contextIcons] ?? IconAt; + return ( + + + + + + {category.label} + + + + ); + }) + } + + + {(items) => + items.map((item, index) => ) + } + + + + + + + {(items) => items.map((item, index) => )} + + + + ); +}; diff --git a/apps/nextjs/src/components/assistant/assistant-composer.tsx b/apps/nextjs/src/components/assistant/assistant-composer.tsx new file mode 100644 index 000000000..275272ea7 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-composer.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useLayoutEffect, useRef } from "react"; +import { ComposerPrimitive, ThreadPrimitive, useAuiState } from "@assistant-ui/react"; +import { LexicalComposerInput } from "@assistant-ui/react-lexical"; +import { ActionIcon, Box, Button, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core"; +import { IconArrowUp, IconMessage, IconPaperclip, IconPlayerStop, IconQuote, IconX } from "@tabler/icons-react"; + +import { useI18n } from "@homarr/translation/client"; + +import classes from "./assistant-panel.module.css"; +import { ComposerTriggers } from "./assistant-composer-triggers"; +import type { AssistantConversationControls } from "./assistant-conversation-controls"; +import { ConversationContext } from "./assistant-conversation-context"; +import { useAssistantPreferences } from "./assistant-context"; +import { Attachment, ContextDirectiveChip } from "./assistant-message-content"; +import type { AssistantPendingAction } from "./assistant-pending-action"; +import { isAssistantProviderUnavailable } from "./assistant-provider-quota"; +import { HomarrProviderQuota, RuntimeControls } from "./assistant-runtime-controls"; + +type ComposerProps = AssistantConversationControls & { pendingAction: AssistantPendingAction | undefined }; + +export const Composer = (props: ComposerProps) => { + const t = useI18n("assistant"); + const preferences = useAssistantPreferences(); + const running = useAuiState((state) => state.thread.isRunning); + const hasPendingAction = props.pendingAction !== undefined; + const providerUnavailable = isAssistantProviderUnavailable({ + provider: preferences.provider, + signedIn: preferences.providerUser !== null, + remaining: preferences.quota?.remaining, + }); + const sendBlocked = hasPendingAction || providerUnavailable; + let sendLabel = t("send"); + if (providerUnavailable) sendLabel = t("providerQuota.unavailableDescription"); + if (hasPendingAction) sendLabel = t("pendingAction.sendBlocked"); + const composerInputRef = useRef(null); + const composerLabel = t("composerPlaceholder"); + + useLayoutEffect(() => { + const editor = composerInputRef.current?.querySelector(".aui-lexical-input"); + if (!editor) return; + editor.setAttribute("aria-label", composerLabel); + return () => { + if (editor.getAttribute("aria-label") === composerLabel) editor.removeAttribute("aria-label"); + }; + }, [composerLabel]); + + return ( + + + + { + if (sendBlocked) event.preventDefault(); + }} + > + + + + + + + + + + + {() => } + + + + + + + + + + + + {running ? ( + + + + + + ) : ( + + + + + + )} + + + + + + + + + + + + + ); +}; + +export const usePendingActionCopy = (action: AssistantPendingAction | undefined) => { + const t = useI18n("assistant"); + if (!action) return undefined; + if (action.kind === "question") { + return { title: t("pendingAction.answerTitle"), detail: action.detail ?? t("pendingAction.answerFallback") }; + } + if (action.kind === "form") { + const isBoardSettings = action.toolName === "configure_board_settings"; + return { + title: isBoardSettings ? t("pendingAction.boardFormTitle") : t("pendingAction.formTitle"), + detail: action.detail ?? (isBoardSettings ? t("configureBoardSettings.title") : t("configureApp.title")), + }; + } + const tool = action.toolName.replaceAll("_", " "); + return { title: t("activity.approval"), detail: action.detail ? `${tool} · ${action.detail}` : tool }; +}; + +export const PendingActionBanner = ({ pendingAction }: { pendingAction: AssistantPendingAction | undefined }) => { + const t = useI18n("assistant"); + const copy = usePendingActionCopy(pendingAction); + if (!copy || pendingAction?.kind === "question") return null; + + return ( + + + + + + + + {copy.title} + + + {copy.detail} + + + + + + + + ); +}; + +export const PendingQuestionDock = ({ + pendingAction, + setTarget, +}: { + pendingAction: AssistantPendingAction | undefined; + setTarget: (target: HTMLDivElement | null) => void; +}) => { + const t = useI18n("assistant"); + if (pendingAction?.kind !== "question") return null; + + return ( + +
+ + ); +}; diff --git a/apps/nextjs/src/components/assistant/assistant-conversation-context.tsx b/apps/nextjs/src/components/assistant/assistant-conversation-context.tsx new file mode 100644 index 000000000..feef3cd7b --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-conversation-context.tsx @@ -0,0 +1,610 @@ +"use client"; + +import { useId, useMemo, useState } from "react"; +import { useAuiState } from "@assistant-ui/react"; +import { + Badge, + Box, + Collapse, + Divider, + Group, + Loader, + Popover, + RingProgress, + ScrollArea, + Stack, + Text, + Tooltip, + UnstyledButton, + useComputedColorScheme, +} from "@mantine/core"; +import { IconChevronDown, IconRobot } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { assistantProviderIds, assistantProviderPresets } from "@homarr/definitions"; +import type { AssistantProvider } from "@homarr/definitions"; +import { useI18n } from "@homarr/translation/client"; + +import classes from "./assistant-panel.module.css"; +import { + getAssistantContextBreakdown, + getAssistantConversationUsage, + getAssistantTelemetry, +} from "./assistant-message-metadata"; +import type { AssistantContextBreakdown, AssistantConversationTurnUsage } from "./assistant-message-metadata"; + +const formatDuration = (milliseconds: number) => + milliseconds < 1000 ? `${Math.round(milliseconds)} ms` : `${(milliseconds / 1000).toFixed(1)} s`; + +const getCostPrecision = (cost: number) => { + if (cost < 0.001) return 5; + if (cost < 0.01) return 4; + return 3; +}; + +const formatCost = (cost: number) => { + if (cost === 0) return "$0"; + if (cost < 0.00001) return `<$0.00001`; + return `$${cost.toFixed(getCostPrecision(cost))}`; +}; + +const formatStepThroughput = (providerThroughput: number | undefined, fallbackThroughput: number | undefined) => { + const throughput = providerThroughput ?? fallbackThroughput; + if (throughput === undefined) return ""; + return ` · ${throughput.toFixed(1)} tok/s`; +}; + +const getContextColor = (percentage: number) => { + if (percentage >= 90) return "red"; + if (percentage >= 75) return "orange"; + return "blue"; +}; + +const getAssistantProviderPreset = (provider: string) => { + if (!assistantProviderIds.includes(provider as AssistantProvider)) return null; + return assistantProviderPresets[provider as AssistantProvider]; +}; + +const ProviderIcon = ({ provider, size = 16 }: { provider: string; size?: number }) => { + const colorScheme = useComputedColorScheme("light"); + const preset = getAssistantProviderPreset(provider); + const source = colorScheme === "dark" && preset && "darkIconUrl" in preset ? preset.darkIconUrl : preset?.iconUrl; + + return source ? ( + + ) : ( + + ); +}; + +export const ProviderMessageInfo = () => { + const metadata = useAuiState((state) => state.message.metadata); + const telemetry = getAssistantTelemetry(metadata); + if (!telemetry) return null; + + const details = [ + telemetry.modelId, + telemetry.durationMs !== undefined ? formatDuration(telemetry.durationMs) : undefined, + telemetry.outputTokensPerSecond !== undefined ? `${telemetry.outputTokensPerSecond.toFixed(1)} tok/s` : undefined, + telemetry.cost !== undefined ? formatCost(telemetry.cost) : undefined, + ].filter(Boolean); + + return ( + + + {telemetry.provider} + + {details.join(" · ")} + + } + multiline + maw="min(24rem, calc(100vw - 2rem))" + > + + + + + ); +}; + +const ConversationTurn = ({ + index, + messageId, + threadId, + turn, +}: { + index: number; + messageId?: string; + threadId?: string; + turn: AssistantConversationTurnUsage; +}) => { + const t = useI18n("assistant"); + const detailsId = useId(); + const [opened, setOpened] = useState(false); + const generationQuery = clientApi.assistant.getGenerationTelemetry.useQuery( + { threadId: threadId ?? "", messageId: messageId ?? "" }, + { + enabled: opened && turn.provider === "openrouter" && Boolean(threadId) && Boolean(messageId), + refetchInterval: (query) => + query.state.data?.complete === false && query.state.dataUpdateCount < 6 ? 1500 : false, + retry: 2, + staleTime: (query) => (query.state.data?.complete ? 5 * 60_000 : 0), + }, + ); + const generationById = new Map( + generationQuery.data?.generations.flatMap((generation) => + generation.generationId ? [[generation.generationId, generation] as const] : [], + ) ?? [], + ); + const steps = turn.telemetry.steps.map((step) => ({ + ...step, + ...(step.generationId ? generationById.get(step.generationId) : undefined), + })); + const sumCompleteMetric = (getValue: (step: (typeof steps)[number]) => number | undefined) => { + if (steps.length === 0) return undefined; + const values = steps.map(getValue); + return values.some((value) => value === undefined) + ? undefined + : (values as number[]).reduce((sum, value) => sum + value, 0); + }; + const providerInputTokens = sumCompleteMetric((step) => step.inputTokens); + const providerOutputTokens = sumCompleteMetric((step) => step.outputTokens); + const providerGenerationTimeMs = sumCompleteMetric((step) => step.generationTimeMs); + const providerOutputTokensPerSecond = + providerOutputTokens !== undefined && providerGenerationTimeMs !== undefined && providerGenerationTimeMs > 0 + ? providerOutputTokens / (providerGenerationTimeMs / 1000) + : undefined; + const latestStep = steps.at(-1); + const providerContextUsed = + latestStep?.inputTokens !== undefined && latestStep.outputTokens !== undefined + ? latestStep.inputTokens + latestStep.outputTokens + : undefined; + const usage = { + ...turn.usage, + ...(generationQuery.data?.complete && providerInputTokens !== undefined + ? { inputTokens: providerInputTokens } + : {}), + ...(generationQuery.data?.complete && providerOutputTokens !== undefined + ? { outputTokens: providerOutputTokens } + : {}), + ...(generationQuery.data?.complete && providerInputTokens !== undefined && providerOutputTokens !== undefined + ? { totalTokens: providerInputTokens + providerOutputTokens } + : {}), + ...(generationQuery.data?.complete && sumCompleteMetric((step) => step.cachedInputTokens) !== undefined + ? { cachedInputTokens: sumCompleteMetric((step) => step.cachedInputTokens) } + : {}), + ...(generationQuery.data?.complete && sumCompleteMetric((step) => step.reasoningTokens) !== undefined + ? { reasoningTokens: sumCompleteMetric((step) => step.reasoningTokens) } + : {}), + }; + const telemetry = { + ...turn.telemetry, + steps, + ...(generationQuery.data?.complete && providerGenerationTimeMs !== undefined + ? { generationTimeMs: providerGenerationTimeMs } + : {}), + ...(generationQuery.data?.complete && providerOutputTokensPerSecond !== undefined + ? { providerOutputTokensPerSecond } + : {}), + ...(generationQuery.data?.complete && providerContextUsed !== undefined + ? { + contextUsed: providerContextUsed, + ...(turn.telemetry.contextLength + ? { contextUtilization: Math.min(providerContextUsed / turn.telemetry.contextLength, 1) } + : {}), + } + : {}), + ...(generationQuery.data?.complete && sumCompleteMetric((step) => step.cost) !== undefined + ? { cost: sumCompleteMetric((step) => step.cost), costType: "reported" as const } + : {}), + ...(generationQuery.data?.complete && sumCompleteMetric((step) => step.upstreamCost) !== undefined + ? { upstreamCost: sumCompleteMetric((step) => step.upstreamCost) } + : {}), + ...(generationQuery.data?.complete && sumCompleteMetric((step) => step.cacheDiscount) !== undefined + ? { cacheDiscount: sumCompleteMetric((step) => step.cacheDiscount) } + : {}), + ...(generationQuery.data?.complete && sumCompleteMetric((step) => step.fallbackCount) !== undefined + ? { fallbackCount: sumCompleteMetric((step) => step.fallbackCount) } + : {}), + ...(generationQuery.data?.complete && sumCompleteMetric((step) => step.fallbackLatencyMs) !== undefined + ? { fallbackLatencyMs: sumCompleteMetric((step) => step.fallbackLatencyMs) } + : {}), + }; + const displayedThroughput = telemetry.providerOutputTokensPerSecond ?? telemetry.outputTokensPerSecond; + let reportedCost: string | undefined; + if (telemetry.cost !== undefined) { + reportedCost = formatCost(telemetry.cost); + if (telemetry.costType) reportedCost += ` · ${t(`usage.${telemetry.costType}`)}`; + } + + return ( + + setOpened((value) => !value)} + > + + + + + + + + {t("usage.step", { number: index + 1 })} · {turn.modelId} + + + {[ + telemetry.durationMs !== undefined ? formatDuration(telemetry.durationMs) : undefined, + displayedThroughput !== undefined ? `${displayedThroughput.toFixed(1)} tok/s` : undefined, + usage.totalTokens !== undefined + ? `${usage.totalTokens.toLocaleString()} ${t("usage.tokens")}` + : undefined, + telemetry.cost !== undefined ? formatCost(telemetry.cost) : undefined, + ] + .filter(Boolean) + .join(" · ") || t("usage.notReported")} + + + + + + + + + {generationQuery.isFetching && generationQuery.data?.complete !== true && ( + + + + {t("usage.loadingProviderDetails")} + + + )} + {!generationQuery.isFetching && generationQuery.data?.complete === false && ( + + {t("usage.providerDetailsPending")} + + )} + + {[ + [t("usage.input"), usage.inputTokens?.toLocaleString()], + [t("usage.output"), usage.outputTokens?.toLocaleString()], + [t("usage.cached"), usage.cachedInputTokens?.toLocaleString()], + [t("usage.reasoning"), usage.reasoningTokens?.toLocaleString()], + [t("usage.cacheWrite"), usage.cacheWriteTokens?.toLocaleString()], + [t("usage.tokens"), usage.totalTokens?.toLocaleString()], + [ + t("usage.firstOutput"), + telemetry.timeToFirstOutputMs !== undefined ? formatDuration(telemetry.timeToFirstOutputMs) : undefined, + ], + [ + t("usage.endToEnd"), + telemetry.durationMs !== undefined ? formatDuration(telemetry.durationMs) : undefined, + ], + [ + t("usage.generationTime"), + telemetry.generationTimeMs !== undefined ? formatDuration(telemetry.generationTimeMs) : undefined, + ], + [ + t("usage.providerThroughput"), + displayedThroughput !== undefined ? `${displayedThroughput.toFixed(1)} tok/s` : undefined, + ], + [t("usage.cost"), reportedCost], + [ + t("usage.upstreamCost"), + telemetry.upstreamCost !== undefined ? formatCost(telemetry.upstreamCost) : undefined, + ], + [t("usage.fallbacks"), telemetry.fallbackCount?.toLocaleString()], + [ + t("usage.fallbackLatency"), + telemetry.fallbackLatencyMs !== undefined ? formatDuration(telemetry.fallbackLatencyMs) : undefined, + ], + [ + t("usage.cacheDiscount"), + telemetry.cacheDiscount !== undefined ? formatCost(telemetry.cacheDiscount) : undefined, + ], + [t("usage.webSearches"), telemetry.webSearchRequests?.toLocaleString()], + [t("usage.finishReason"), telemetry.finishReason], + ].map(([label, value]) => ( +
+ + {label} + + + {value ?? t("usage.notReported")} + +
+ ))} +
+ {(telemetry.contextUsed !== undefined || telemetry.contextLength !== undefined) && ( + + {t("usage.contextWindow")}: {telemetry.contextUsed?.toLocaleString() ?? t("usage.notReported")} /{" "} + {telemetry.contextLength?.toLocaleString() ?? t("usage.notReported")} + + )} + {steps.length > 0 && ( + + + {t("usage.agentSteps")} + + {steps.map((step) => ( + + + + {t("usage.step", { number: step.index })} + {step.routedProvider ? ` · ${step.routedProvider}` : ""} + + + {formatDuration(step.durationMs)} + {formatStepThroughput(step.providerOutputTokensPerSecond, step.outputTokensPerSecond)} + {step.cost !== undefined ? ` · ${formatCost(step.cost)}` : ""} + + + + {[ + step.providerLatencyMs !== undefined + ? `${t("usage.providerLatency")} ${formatDuration(step.providerLatencyMs)}` + : undefined, + step.generationTimeMs !== undefined + ? `${t("usage.generationTime")} ${formatDuration(step.generationTimeMs)}` + : undefined, + step.moderationLatencyMs !== undefined + ? `${t("usage.moderationLatency")} ${formatDuration(step.moderationLatencyMs)}` + : undefined, + step.inputTokens !== undefined + ? `${step.inputTokens.toLocaleString()} ${t("usage.inputShort")}` + : undefined, + step.outputTokens !== undefined + ? `${step.outputTokens.toLocaleString()} ${t("usage.outputShort")}` + : undefined, + step.cachedInputTokens !== undefined + ? `${step.cachedInputTokens.toLocaleString()} ${t("usage.cachedShort")}` + : undefined, + step.reasoningTokens !== undefined + ? `${step.reasoningTokens.toLocaleString()} ${t("usage.reasoningShort")}` + : undefined, + step.fallbackCount !== undefined + ? `${t("usage.fallbacks")} ${step.fallbackCount.toLocaleString()}` + : undefined, + step.fallbackLatencyMs !== undefined + ? `${t("usage.fallbackLatency")} ${formatDuration(step.fallbackLatencyMs)}` + : undefined, + step.nativeFinishReason, + ] + .filter(Boolean) + .join(" · ") || t("usage.notReported")} + + {(step.normalizedInputTokens !== undefined || step.normalizedOutputTokens !== undefined) && ( + + {t("usage.normalizedTokens")}: {step.normalizedInputTokens?.toLocaleString() ?? "–"} /{" "} + {step.normalizedOutputTokens?.toLocaleString() ?? "–"} + + )} + {(step.routerStrategy || step.routerRegion || step.serviceTier || step.dataRegion || step.isByok) && ( + + {[ + step.routerStrategy, + step.routerRegion, + step.serviceTier, + step.dataRegion, + step.isByok ? t("usage.byok") : undefined, + ] + .filter(Boolean) + .join(" · ")} + + )} + {step.generationId && ( + + {t("usage.generation")}: {step.generationId} + + )} + + ))} + + )} +
+
+
+ ); +}; + +const ConversationContextBreakdown = ({ breakdown }: { breakdown: AssistantContextBreakdown }) => { + const t = useI18n("assistant"); + const hasContext = breakdown.percentage !== undefined; + const categories = [ + { label: t("usage.input"), value: breakdown.inputTokens }, + { label: t("usage.cached"), value: breakdown.cachedInputTokens }, + { label: t("usage.output"), value: breakdown.outputTokens }, + { label: t("usage.reasoning"), value: breakdown.reasoningTokens }, + ]; + + return ( + + + {hasContext ? `${breakdown.percentage}%` : "–"} + + } + /> + + {[ + { label: t("usage.used"), value: breakdown.contextUsed }, + { label: t("usage.remaining"), value: breakdown.remaining }, + { label: t("usage.capacity"), value: breakdown.contextLength }, + ].map((item) => ( + + + {item.label} + + + {item.value?.toLocaleString() ?? t("usage.notReported")} + + + ))} + + + {categories.map((category) => ( + + + {category.label} + + + {category.value?.toLocaleString() ?? t("usage.notReported")} + + + ))} + + + ); +}; + +export const ConversationContext = () => { + const t = useI18n("assistant"); + const [opened, setOpened] = useState(false); + const messages = useAuiState((state) => state.thread.messages); + const threadId = useAuiState((state) => state.threadListItem.remoteId); + const metadata = useMemo(() => messages.map((message) => message.metadata), [messages]); + const messageIdByRequestId = useMemo( + () => + new Map( + messages.flatMap((message) => { + const telemetry = getAssistantTelemetry(message.metadata); + return telemetry ? [[telemetry.requestId, message.id] as const] : []; + }), + ), + [messages], + ); + const usage = useMemo(() => getAssistantConversationUsage(metadata), [metadata]); + const breakdown = useMemo(() => getAssistantContextBreakdown(usage), [usage]); + const hasTokenUsage = usage.turns.some((turn) => turn.totalTokens !== undefined); + const hasCost = usage.turns.some((turn) => turn.cost !== undefined); + const hasContext = breakdown.percentage !== undefined; + const contextPercentage = breakdown.percentage ?? 0; + const quickLabel = [ + hasTokenUsage ? `${usage.totalTokens.toLocaleString()} ${t("usage.tokens")}` : undefined, + hasCost ? formatCost(usage.cost) : undefined, + ] + .filter(Boolean) + .join(" · "); + + return ( + + + setOpened((value) => !value)} + > + + + + {hasContext ? `${contextPercentage}%` : t("mentions.context")} + + + + + + + + + + {t("usage.contextWindow")} + + + {t("usage.requestDetails")} + + + {hasContext && ( + + {contextPercentage}% + + )} + + +
+ + {t("usage.tokens")} + + + {hasTokenUsage ? usage.totalTokens.toLocaleString() : t("usage.notReported")} + +
+
+ + {t("usage.cost")} + + + {hasCost ? formatCost(usage.cost) : t("usage.notReported")} + +
+
+ + {t("usage.agentSteps")} + + + {usage.turns.reduce((total, turn) => total + turn.telemetry.steps.length, 0).toLocaleString()} + +
+
+ + + + + {usage.turns.length === 0 ? ( + + {t("usage.notReported")} + + ) : ( + usage.turns.map((turn, index) => ( + + )) + )} + + +
+
+
+ ); +}; diff --git a/apps/nextjs/src/components/assistant/assistant-conversation-controls.ts b/apps/nextjs/src/components/assistant/assistant-conversation-controls.ts new file mode 100644 index 000000000..5cb86725a --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-conversation-controls.ts @@ -0,0 +1,13 @@ +import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences"; + +export interface AssistantConversationControls { + modelId: string | null; + models: AssistantRuntimeModelOption[]; + modelOptionsLoading: boolean; + reasoning: AssistantReasoningMode; + isRefreshing: boolean; + autoFocusComposer?: boolean; + onRefresh: () => Promise; + onModelChange: (modelId: string) => void; + onReasoningChange: (reasoning: AssistantReasoningMode) => void; +} diff --git a/apps/nextjs/src/components/assistant/assistant-history.tsx b/apps/nextjs/src/components/assistant/assistant-history.tsx new file mode 100644 index 000000000..e86e637ba --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-history.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { createContext, useContext, useEffect, useRef, useState } from "react"; +import { ThreadListItemPrimitive, ThreadListPrimitive, useAui, useAuiState } from "@assistant-ui/react"; +import { ActionIcon, Button, Group, Popover, ScrollArea, Stack, Text, TextInput, UnstyledButton } from "@mantine/core"; +import { + IconCheck, + IconDotsVertical, + IconFileExport, + IconHistory, + IconPencil, + IconPlus, + IconSearch, + IconTrash, + IconX, +} from "@tabler/icons-react"; + +import { fetchApi } from "@homarr/api/client"; +import { showErrorNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; +import { InlineConfirmActionIcon } from "@homarr/ui"; + +import classes from "./assistant-panel.module.css"; +import { + buildAssistantConversationMarkdown, + getAssistantConversationExportFilename, +} from "./assistant-conversation-export"; + +export const downloadAssistantMarkdown = (markdown: string, filename: string) => { + const blobUrl = URL.createObjectURL(new Blob([markdown], { type: "text/markdown;charset=utf-8" })); + const link = document.createElement("a"); + link.href = blobUrl; + link.download = filename; + link.hidden = true; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(blobUrl), 0); +}; + +const HistorySelectContext = createContext<() => void>(() => undefined); + +const ThreadListItem = () => { + const t = useI18n("assistant"); + const actionT = useI18n("common.action"); + const aui = useAui(); + const onSelect = useContext(HistorySelectContext); + const remoteId = useAuiState((state) => state.threadListItem.remoteId); + const title = useAuiState((state) => state.threadListItem.title); + const [exporting, setExporting] = useState(false); + const [actionsOpened, setActionsOpened] = useState(false); + const [renameOpened, setRenameOpened] = useState(false); + const [renaming, setRenaming] = useState(false); + const [nextTitle, setNextTitle] = useState(title ?? ""); + const renameInputRef = useRef(null); + + useEffect(() => { + if (renameOpened) renameInputRef.current?.focus(); + }, [renameOpened]); + + const exportConversation = async () => { + if (!remoteId || exporting) return; + setExporting(true); + try { + const conversation = await fetchApi.assistant.getThread.query({ threadId: remoteId }); + const markdown = buildAssistantConversationMarkdown(conversation); + downloadAssistantMarkdown( + markdown, + getAssistantConversationExportFilename(conversation.thread.title ?? title, remoteId), + ); + } catch { + showErrorNotification({ + title: t("exportConversation.failedTitle"), + message: t("exportConversation.failedDescription"), + }); + } finally { + setExporting(false); + } + }; + + const renameConversation = async () => { + const trimmedTitle = nextTitle.trim(); + if (!trimmedTitle || renaming) return; + setRenaming(true); + try { + await aui.threadListItem().rename(trimmedTitle); + setRenameOpened(false); + setActionsOpened(false); + } catch { + showErrorNotification({ + title: t("renameConversation.failedTitle"), + message: t("renameConversation.failedDescription"), + }); + } finally { + setRenaming(false); + } + }; + + const deleteConversation = async () => { + try { + await aui.threadListItem().delete(); + } catch { + showErrorNotification({ + title: t("deleteConversation.failedTitle"), + message: t("deleteConversation.failedDescription"), + }); + } + }; + + if (renameOpened) { + return ( + + setNextTitle(event.currentTarget.value)} + aria-label={t("renameConversation.label")} + maxLength={72} + size="xs" + onKeyDown={(event) => { + if (event.key === "Enter") void renameConversation(); + if (event.key === "Escape" && !renaming) setRenameOpened(false); + }} + /> + void renameConversation()} + > + + + setRenameOpened(false)} + > + + + + ); + } + + return ( + + + + + + + + + {actionsOpened && ( + + { + setNextTitle(title ?? ""); + setRenameOpened(true); + }} + > + + + void exportConversation()} + > + + + } + onConfirm={deleteConversation} + > + + + + )} + setActionsOpened((current) => !current)} + > + {actionsOpened ? : } + + + ); +}; + +const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => { + const t = useI18n("assistant"); + const [query, setQuery] = useState(""); + const normalizedQuery = query.trim().toLocaleLowerCase(); + const threadItems = useAuiState((state) => state.threads.threadItems); + const hasMatchingConversation = threadItems.some((item) => { + const itemTitle = item.title ?? t("newConversation"); + return itemTitle.toLocaleLowerCase().includes(normalizedQuery); + }); + return ( + + + + + + + + + + + {t("conversations")} + + + setQuery(event.currentTarget.value)} + leftSection={} + placeholder={t("searchConversations")} + aria-label={t("searchConversations")} + size="xs" + /> + + + + {({ threadListItem }) => { + const itemTitle = threadListItem.title ?? t("newConversation"); + if (normalizedQuery && !itemTitle.toLocaleLowerCase().includes(normalizedQuery)) return null; + return ; + }} + + {normalizedQuery && !hasMatchingConversation && ( + + {t("noMatchingConversations")} + + )} + + + + + ); +}; + +export const ConversationHistory = () => { + const t = useI18n("assistant"); + const [opened, setOpened] = useState(false); + return ( + + + + + + setOpened(false)} /> + + + ); +}; diff --git a/apps/nextjs/src/components/assistant/assistant-message-content.tsx b/apps/nextjs/src/components/assistant/assistant-message-content.tsx new file mode 100644 index 000000000..1841d2931 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-message-content.tsx @@ -0,0 +1,523 @@ +"use client"; + +import type { ComponentPropsWithoutRef, MouseEvent as ReactMouseEvent, ReactNode } from "react"; +import { createContext, lazy, Suspense, useContext, useEffect, useId, useState } from "react"; +import type { + EmptyMessagePartProps, + FileMessagePartProps, + ImageMessagePartProps, + ReasoningMessagePartProps, + SourceMessagePartProps, +} from "@assistant-ui/react"; +import { AttachmentPrimitive, useAuiState, useMessagePartText } from "@assistant-ui/react"; +import type { DirectiveChipProps } from "@assistant-ui/react-lexical"; +import { MarkdownTextPrimitive, unstable_memoizeMarkdownComponents } from "@assistant-ui/react-markdown"; +import type { SyntaxHighlighterProps } from "@assistant-ui/react-markdown"; +import { ActionIcon, Anchor, Box, Group, Loader, Text, useComputedColorScheme } from "@mantine/core"; +import { + IconAlertTriangle, + IconApps, + IconAt, + IconDownload, + IconFile, + IconLink, + IconMessage, + IconPhoto, + IconTool, + IconX, +} from "@tabler/icons-react"; +import remarkBreaks from "remark-breaks"; +import remarkDirective from "remark-directive"; +import remarkGfm from "remark-gfm"; +import { useRouter } from "next/navigation"; + +import { clientApi } from "@homarr/api/client"; +import { useI18n } from "@homarr/translation/client"; + +import classes from "./assistant-panel.module.css"; +import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from "./assistant-directives"; +import { AssistantDotMatrix } from "./assistant-dot-matrix"; +import { AssistantImage } from "./assistant-image"; +import { remarkAssistantDirectives, resolveAssistantDirectiveEntity } from "./assistant-markdown-directives"; +import type { AssistantDirectiveEntity } from "./assistant-markdown-directives"; +import { normalizeAssistantMarkdown } from "./assistant-markdown"; +import { getSafeAssistantAttachmentImageSource, getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image"; +import { getSafeAssistantHttpUrl } from "./assistant-url"; + +// Load Shiki only when a settled response contains a code block. +const ShikiHighlighter = lazy(() => import("react-shiki/web")); + +const markdownRemarkPlugins = [remarkDirective, remarkAssistantDirectives, remarkGfm, remarkBreaks]; + +export const contextIcons = { + app: IconApps, + integration: IconLink, + board: IconMessage, + widget: IconTool, + tool: IconTool, + tools: IconTool, +}; + +export const ContextDirectiveChip = ({ + directiveId, + directiveType, + label, + iconUrl, +}: DirectiveChipProps & { iconUrl?: string }) => { + const t = useI18n("assistant"); + const Icon = contextIcons[directiveType as keyof typeof contextIcons] ?? IconAt; + const safeIconUrl = getSafeAssistantMarkdownImageSource(iconUrl); + const translationKey = getAssistantDirectiveTranslationKey(directiveType); + const typeLabel = t(`mentions.${translationKey}`); + + return ( + + {safeIconUrl ? ( + + ) : ( + + )} + {label} + + ); +}; + +const getInternalAssistantHref = (href: string) => { + if (href.startsWith("/") && !href.startsWith("//")) return href; + if (typeof window === "undefined" || !URL.canParse(href)) return null; + const url = new URL(href); + return url.origin === window.location.origin ? `${url.pathname}${url.search}${url.hash}` : null; +}; + +export const hasModifiedLinkClick = (event: ReactMouseEvent) => + event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey; + +const MarkdownLink = ({ href, children, onClick, onMouseEnter, ...props }: ComponentPropsWithoutRef<"a">) => { + const router = useRouter(); + const httpLink = href !== undefined && /^https?:\/\//iu.test(href); + const relativeLink = href?.startsWith("/") && !href.startsWith("//"); + const safeLink = href?.startsWith("#") || href?.startsWith("mailto:") || httpLink || relativeLink; + + if (!href || !safeLink) return {children}; + + return ( + { + onMouseEnter?.(event); + const internalHref = getInternalAssistantHref(href); + if (internalHref) router.prefetch(internalHref); + }} + onClick={(event) => { + onClick?.(event); + if (event.defaultPrevented || hasModifiedLinkClick(event)) return; + const internalHref = getInternalAssistantHref(href); + if (!internalHref) return; + event.preventDefault(); + router.push(internalHref); + }} + > + {children} + + ); +}; + +const MarkdownTable = ({ children, ...props }: ComponentPropsWithoutRef<"table">) => ( +
+ {children}
+
+); + +const MarkdownImage = ({ src, alt = "", ...props }: ComponentPropsWithoutRef<"img">) => { + const t = useI18n("assistant.image"); + const safeSource = getSafeAssistantMarkdownImageSource(src); + if (!safeSource) return null; + + return ( + + ); +}; + +const AssistantSyntaxHighlighter = ({ code, components, language }: SyntaxHighlighterProps) => { + const colorScheme = useComputedColorScheme("light"); + const streaming = useAuiState((state) => state.part.status?.type === "running"); + const Pre = components.Pre; + const Code = components.Code; + if (streaming) { + return ( +
+        {code}
+      
+ ); + } + + return ( + + {code} + + } + > + + {code} + + + ); +}; + +const MermaidDiagram = ({ code, components }: SyntaxHighlighterProps) => { + const t = useI18n("assistant"); + const colorScheme = useComputedColorScheme("light"); + const diagramId = `assistant-mermaid-${useId().replaceAll(/[^A-Za-z0-9_-]/gu, "")}`; + const [rendered, setRendered] = useState<{ code: string; svg?: string; failed?: boolean }>({ code }); + + useEffect(() => { + let active = true; + setRendered({ code }); + void import("mermaid") + .then(async ({ default: mermaid }) => { + mermaid.initialize({ + startOnLoad: false, + securityLevel: "strict", + suppressErrorRendering: true, + theme: colorScheme === "dark" ? "dark" : "neutral", + fontFamily: "Inter, system-ui, sans-serif", + }); + return await mermaid.render(diagramId, code); + }) + .then(({ svg }) => { + if (active) setRendered({ code, svg }); + }) + .catch(() => { + document.getElementById(`d${diagramId}`)?.remove(); + if (active) setRendered({ code, failed: true }); + }); + + return () => { + active = false; + }; + }, [code, colorScheme, diagramId]); + + if (rendered.code !== code || (!rendered.svg && !rendered.failed)) { + return ( + + + + {t("markdown.renderingDiagram")} + + + ); + } + if (rendered.failed || !rendered.svg) { + const Pre = components.Pre; + const Code = components.Code; + return ( + + + {t("markdown.diagramUnavailable")} + +
+          {code}
+        
+
+ ); + } + return ( + + ); +}; + +type MarkdownSpanProps = ComponentPropsWithoutRef<"span"> & { + "data-assistant-directive"?: string; + "data-directive-id"?: string; + "data-directive-label"?: string; + "data-directive-type"?: string; +}; + +interface AssistantDirectiveEntitiesState { + entities: AssistantDirectiveEntity[]; + isLoading: boolean; +} + +export const AssistantDirectiveEntitiesContext = createContext({ + entities: [], + isLoading: false, +}); + +export const AssistantDirectiveEntitiesProvider = ({ children }: { children: ReactNode }) => { + const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, { + staleTime: 60_000, + }); + + return ( + + {children} + + ); +}; + +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 assistantMarkdownComponents = unstable_memoizeMarkdownComponents({ + a: MarkdownLink, + img: MarkdownImage, + span: AssistantMarkdownSpan, + table: MarkdownTable, + SyntaxHighlighter: AssistantSyntaxHighlighter, +}); + +export const AssistantTextPart = () => ( + +); + +export const UserTextPart = () => { + const { text } = useMessagePartText(); + const { entities } = useContext(AssistantDirectiveEntitiesContext); + const segments = parseAssistantDirectives(text); + return ( + + {segments.map((segment, index) => { + if (segment.kind === "text") return {segment.text}; + const entity = resolveAssistantDirectiveEntity(entities, segment); + return ( + + ); + })} + + ); +}; + +const AttachmentPreview = () => { + const attachment = useAuiState((state) => state.attachment); + const [filePreview, setFilePreview] = useState(null); + const contentImage = attachment.content?.find((part) => part.type === "image"); + const persistedPreview = + contentImage?.type === "image" + ? getSafeAssistantAttachmentImageSource( + contentImage.image, + typeof window === "undefined" ? undefined : window.location.origin, + ) + : null; + + useEffect(() => { + if (persistedPreview || attachment.type !== "image" || !attachment.file) { + setFilePreview(null); + return; + } + const objectUrl = URL.createObjectURL(attachment.file); + setFilePreview(objectUrl); + return () => URL.revokeObjectURL(objectUrl); + }, [attachment.file, attachment.type, persistedPreview]); + + const source = persistedPreview ?? filePreview; + if (source) { + return ; + } + return ( + + {attachment.type === "image" ? : } + + ); +}; + +export const Attachment = ({ removable = false }: { removable?: boolean }) => { + const t = useI18n("assistant"); + const attachment = useAuiState((state) => state.attachment); + return ( + + + + + + + {attachment.contentType && ( + + {attachment.contentType} + + )} + + {attachment.status.type === "running" && } + {removable && ( + + + + + + )} + + ); +}; +export const SentAttachment = () => ; +export const HiddenMessagePart = () => null; + +export const ReasoningPart = (_props: ReasoningMessagePartProps) => ( + + + +); + +export const AssistantMessagePending = ({ status }: EmptyMessagePartProps) => { + const t = useI18n("assistant"); + if (status.type !== "running") return null; + + return ( + + + + {t("activity.thinking")} + + + ); +}; + +export const ReasoningVisibilityContext = createContext<{ + collapsed: boolean; + setCollapsed: (collapsed: boolean) => void; +}>({ collapsed: false, setCollapsed: () => undefined }); + +export const SourcePart = (source: SourceMessagePartProps) => { + const safeUrl = getSafeAssistantHttpUrl(source.url); + const content = ( + <> + + {source.title} + + ); + + return safeUrl ? ( + + {content} + + ) : ( + + {content} + + ); +}; + +export const FilePart = ({ data, filename, mimeType }: FileMessagePartProps) => { + const t = useI18n("assistant"); + const downloadable = data.startsWith("data:"); + const displayName = filename ?? mimeType; + return ( + + + + {displayName} + + {downloadable && ( + + + + )} + + ); +}; + +export const ImagePart = ({ image, filename }: ImageMessagePartProps) => { + const t = useI18n("assistant"); + const source = getSafeAssistantAttachmentImageSource( + image, + typeof window === "undefined" ? undefined : window.location.origin, + ); + if (!source) { + return ( + + + {t("externalImageBlocked")} + + ); + } + return ( + + ); +}; diff --git a/apps/nextjs/src/components/assistant/assistant-panel-controls.tsx b/apps/nextjs/src/components/assistant/assistant-panel-controls.tsx new file mode 100644 index 000000000..d31c21015 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-panel-controls.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState } from "react"; +import { ThreadPrimitive } from "@assistant-ui/react"; +import { ActionIcon, Box, Button, Popover, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core"; +import { + IconActivityHeartbeat, + IconApps, + IconPalette, + IconRefresh, + IconRobot, + IconSearch, + IconShieldCheck, +} from "@tabler/icons-react"; + +import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useI18n } from "@homarr/translation/client"; + +import classes from "./assistant-panel.module.css"; +import { useAssistantAutoApproval } from "./assistant-auto-approval"; +import type { AssistantConversationControls } from "./assistant-conversation-controls"; + +export const AutoApprovalControl = () => { + const t = useI18n("assistant.autoApproval"); + const [opened, setOpened] = useState(false); + const { enabled, setEnabled } = useAssistantAutoApproval(); + + return ( + setOpened(false)} + position="bottom-end" + width="min(19rem, calc(100vw - 1rem))" + shadow="md" + withinPortal + > + + setOpened((current) => !current)} + aria-label={t("label")} + aria-pressed={enabled} + title={enabled ? t("enabled") : t("label")} + > + + + + + + + + {t("title")} + + + {t("description")} + + + + + + + ); +}; + +export const ViewRefreshAction = ({ + isRefreshing, + onRefresh, +}: Pick) => { + const t = useI18n("assistant.refresh"); + + const refresh = async () => { + try { + await onRefresh(); + showSuccessNotification({ title: t("completeTitle"), message: t("completeDescription") }); + } catch { + showErrorNotification({ title: t("failedTitle"), message: t("failedDescription") }); + } + }; + + return ( + + void refresh()} + aria-label={isRefreshing ? t("working") : t("action")} + > + + + + ); +}; + +export const EmptyThread = () => { + const t = useI18n("assistant"); + return ( + + + + + + + + + {t("emptyTitle")} + + + {t("emptyDescription")} + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index df27b7856..21ce48ea0 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -1,189 +1,93 @@ "use client"; -import type { ComponentPropsWithoutRef, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react"; -import { - createContext, - lazy, - Suspense, - useContext, - useEffect, - useId, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; -import type { - EmptyMessagePartProps, - FileMessagePartProps, - ImageMessagePartProps, - ReasoningMessagePartProps, - SourceMessagePartProps, - ToolCallMessagePartProps, - Unstable_TriggerItem, -} from "@assistant-ui/react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { MessageStatus } from "@assistant-ui/react"; import { - ActionBarPrimitive, ActionBarMorePrimitive, + ActionBarPrimitive, AuiIf, - AttachmentPrimitive, BranchPickerPrimitive, ChainOfThoughtByIndicesProvider, - ChainOfThoughtPrimitive, ComposerPrimitive, ErrorPrimitive, MessagePrimitive, SelectionToolbarPrimitive, - ThreadListItemPrimitive, ThreadListPrimitive, ThreadPrimitive, useAui, useAuiState, - useMessagePartText, - unstable_useMentionAdapter, - unstable_useSlashCommandAdapter, - unstable_useTriggerPopoverScopeContext, } from "@assistant-ui/react"; -import { LexicalComposerInput } from "@assistant-ui/react-lexical"; -import type { DirectiveChipProps } from "@assistant-ui/react-lexical"; -import { MarkdownTextPrimitive, unstable_memoizeMarkdownComponents } from "@assistant-ui/react-markdown"; -import type { SyntaxHighlighterProps } from "@assistant-ui/react-markdown"; import { ActionIcon, Anchor, Badge, Box, Button, - Collapse, - Combobox, - Divider, Group, - Image, - Loader, - Popover, - Progress, - RingProgress, - ScrollArea, - SegmentedControl, Stack, Text, - TextInput, ThemeIcon, Tooltip, UnstyledButton, - useCombobox, - useComputedColorScheme, } from "@mantine/core"; import { useReducedMotion, useWindowEvent } from "@mantine/hooks"; import { - IconActivityHeartbeat, IconAlertTriangle, - IconApps, IconArrowUp, IconArrowsMaximize, - IconAt, - IconBattery1, - IconBattery3, - IconBatteryFilled, - IconBatteryOff, IconCheck, - IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp, IconCopy, - IconDownload, IconDotsVertical, - IconFile, IconFileExport, IconLink, - IconMessage, IconMinus, - IconPaperclip, - IconPalette, IconPencil, - IconPhoto, - IconHistory, - IconPlayerStop, IconPlus, IconQuote, IconRefresh, - IconRobot, IconSearch, - IconShieldCheck, IconThumbDown, IconThumbUp, - IconTool, - IconTrash, IconVolume, IconVolumeOff, IconX, } from "@tabler/icons-react"; -import remarkBreaks from "remark-breaks"; -import remarkDirective from "remark-directive"; -import remarkGfm from "remark-gfm"; -import { useRouter } from "next/navigation"; -import { clientApi, fetchApi } from "@homarr/api/client"; -import { useTimeAgo } from "@homarr/common"; -import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions"; -import type { AssistantProvider } from "@homarr/definitions"; -import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; -import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; -import { InlineConfirmActionIcon } from "@homarr/ui"; +import { useI18n } from "@homarr/translation/client"; -import classes from "./assistant-panel.module.css"; import { getAssistantActivityState } from "./assistant-activity-state"; -import { useAssistantAutoApproval, useAssistantAutomaticAction } from "./assistant-auto-approval"; -import { - buildAssistantConversationMarkdown, - buildAssistantMessageMarkdown, - getAssistantConversationExportFilename, -} from "./assistant-conversation-export"; +import { Composer, PendingActionBanner, PendingQuestionDock, usePendingActionCopy } from "./assistant-composer"; +import type { AssistantConversationControls } from "./assistant-conversation-controls"; +import { ProviderMessageInfo } from "./assistant-conversation-context"; +import { buildAssistantMessageMarkdown } from "./assistant-conversation-export"; import { AssistantDotMatrix } from "./assistant-dot-matrix"; -import { useAssistantPreferences } from "./assistant-context"; -import { AssistantImage } from "./assistant-image"; -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 { getSafeAssistantAttachmentImageSource, getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image"; +import { ConversationHistory, downloadAssistantMarkdown } from "./assistant-history"; import { - getAssistantContextBreakdown, - getAssistantConversationUsage, - getAssistantTelemetry, -} from "./assistant-message-metadata"; -import type { AssistantContextBreakdown, AssistantConversationTurnUsage } from "./assistant-message-metadata"; + AssistantDirectiveEntitiesProvider, + AssistantMessagePending, + AssistantTextPart, + FilePart, + HiddenMessagePart, + ImagePart, + ReasoningPart, + ReasoningVisibilityContext, + SentAttachment, + SourcePart, + UserTextPart, +} from "./assistant-message-content"; import { assistantMessageGroupBy } from "./assistant-message-grouping"; +import { getAssistantTelemetry } from "./assistant-message-metadata"; +import { AutoApprovalControl, EmptyThread, ViewRefreshAction } from "./assistant-panel-controls"; +import classes from "./assistant-panel.module.css"; import type { AssistantPendingAction } from "./assistant-pending-action"; import { AssistantQuestionPortalProvider } from "./assistant-question-portal"; -import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences"; -import { useAssistantReasoningState } from "./assistant-reasoning-state"; -import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll"; -import { getToolResultPresentation } from "./assistant-tool-result"; -import { getAssistantIconSearchQuery } from "./assistant-tool-label"; -import { getAssistantProviderQuotaLevel, isAssistantProviderUnavailable } from "./assistant-provider-quota"; -import { getAssistantToolTraceTarget } from "./assistant-tool-trace"; -import { getSafeAssistantHttpUrl } from "./assistant-url"; +import { AgentTraceToolGroup, AssistantChainOfThought, ToolPart } from "./assistant-tool-rendering"; import { isEscapeOwnedByNestedOverlay } from "../board/advanced-focus/escape"; -// Shiki's web grammar bundle is intentionally loaded only when a settled response contains a code -// block. Most assistant conversations never need it, so it should not inflate the global dashboard -// shell merely because the assistant provider is mounted. -const ShikiHighlighter = lazy(() => import("react-shiki/web")); - -export interface AssistantConversationControls { - modelId: string | null; - models: AssistantRuntimeModelOption[]; - modelOptionsLoading: boolean; - reasoning: AssistantReasoningMode; - isRefreshing: boolean; - autoFocusComposer?: boolean; - onRefresh: () => Promise; - onModelChange: (modelId: string) => void; - onReasoningChange: (reasoning: AssistantReasoningMode) => void; -} +export type { AssistantConversationControls } from "./assistant-conversation-controls"; interface AssistantPanelProps extends AssistantConversationControls { opened: boolean; @@ -201,887 +105,6 @@ interface AssistantPanelProps extends AssistantConversationControls { pendingAction: AssistantPendingAction | undefined; } -const markdownRemarkPlugins = [remarkDirective, remarkAssistantDirectives, remarkGfm, remarkBreaks]; - -const contextIcons = { - app: IconApps, - integration: IconLink, - board: IconMessage, - widget: IconTool, - tool: IconTool, - tools: IconTool, -}; - -const ContextDirectiveChip = ({ - directiveId, - directiveType, - label, - iconUrl, -}: DirectiveChipProps & { iconUrl?: string }) => { - const t = useI18n("assistant"); - const Icon = contextIcons[directiveType as keyof typeof contextIcons] ?? IconAt; - const safeIconUrl = getSafeAssistantMarkdownImageSource(iconUrl); - const translationKey = getAssistantDirectiveTranslationKey(directiveType); - const typeLabel = t(`mentions.${translationKey}`); - - return ( - - {safeIconUrl ? ( - - ) : ( - - )} - {label} - - ); -}; - -const getInternalAssistantHref = (href: string) => { - if (href.startsWith("/") && !href.startsWith("//")) return href; - if (typeof window === "undefined" || !URL.canParse(href)) return null; - const url = new URL(href); - return url.origin === window.location.origin ? `${url.pathname}${url.search}${url.hash}` : null; -}; - -const hasModifiedLinkClick = (event: ReactMouseEvent) => - event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey; - -const MarkdownLink = ({ href, children, onClick, onMouseEnter, ...props }: ComponentPropsWithoutRef<"a">) => { - const router = useRouter(); - const httpLink = href !== undefined && /^https?:\/\//iu.test(href); - const relativeLink = href?.startsWith("/") && !href.startsWith("//"); - const safeLink = href?.startsWith("#") || href?.startsWith("mailto:") || httpLink || relativeLink; - - if (!href || !safeLink) return {children}; - - return ( - { - onMouseEnter?.(event); - const internalHref = getInternalAssistantHref(href); - if (internalHref) router.prefetch(internalHref); - }} - onClick={(event) => { - onClick?.(event); - if (event.defaultPrevented || hasModifiedLinkClick(event)) return; - const internalHref = getInternalAssistantHref(href); - if (!internalHref) return; - event.preventDefault(); - router.push(internalHref); - }} - > - {children} - - ); -}; - -const MarkdownTable = ({ children, ...props }: ComponentPropsWithoutRef<"table">) => ( -
- {children}
-
-); - -const MarkdownImage = ({ src, alt = "", ...props }: ComponentPropsWithoutRef<"img">) => { - const t = useI18n("assistant.image"); - const safeSource = getSafeAssistantMarkdownImageSource(src); - if (!safeSource) return null; - - return ( - - ); -}; - -const AssistantSyntaxHighlighter = ({ code, components, language }: SyntaxHighlighterProps) => { - const colorScheme = useComputedColorScheme("light"); - const streaming = useAuiState((state) => state.part.status?.type === "running"); - const Pre = components.Pre; - const Code = components.Code; - if (streaming) { - return ( -
-        {code}
-      
- ); - } - - return ( - - {code} - - } - > - - {code} - - - ); -}; - -const MermaidDiagram = ({ code, components }: SyntaxHighlighterProps) => { - const t = useI18n("assistant"); - const colorScheme = useComputedColorScheme("light"); - const diagramId = `assistant-mermaid-${useId().replaceAll(/[^A-Za-z0-9_-]/gu, "")}`; - const [rendered, setRendered] = useState<{ code: string; svg?: string; failed?: boolean }>({ code }); - - useEffect(() => { - let active = true; - setRendered({ code }); - void import("mermaid") - .then(async ({ default: mermaid }) => { - mermaid.initialize({ - startOnLoad: false, - securityLevel: "strict", - suppressErrorRendering: true, - theme: colorScheme === "dark" ? "dark" : "neutral", - fontFamily: "Inter, system-ui, sans-serif", - }); - return await mermaid.render(diagramId, code); - }) - .then(({ svg }) => { - if (active) setRendered({ code, svg }); - }) - .catch(() => { - document.getElementById(`d${diagramId}`)?.remove(); - if (active) setRendered({ code, failed: true }); - }); - - return () => { - active = false; - }; - }, [code, colorScheme, diagramId]); - - if (rendered.code !== code || (!rendered.svg && !rendered.failed)) { - return ( - - - - {t("markdown.renderingDiagram")} - - - ); - } - if (rendered.failed || !rendered.svg) { - const Pre = components.Pre; - const Code = components.Code; - return ( - - - {t("markdown.diagramUnavailable")} - -
-          {code}
-        
-
- ); - } - return ( - - ); -}; - -type MarkdownSpanProps = ComponentPropsWithoutRef<"span"> & { - "data-assistant-directive"?: string; - "data-directive-id"?: string; - "data-directive-label"?: string; - "data-directive-type"?: string; -}; - -interface AssistantDirectiveEntitiesState { - entities: AssistantDirectiveEntity[]; - isLoading: boolean; -} - -const AssistantDirectiveEntitiesContext = createContext({ - entities: [], - isLoading: false, -}); - -const AssistantDirectiveEntitiesProvider = ({ children }: { children: ReactNode }) => { - const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, { - staleTime: 60_000, - }); - - return ( - - {children} - - ); -}; - -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 assistantMarkdownComponents = unstable_memoizeMarkdownComponents({ - a: MarkdownLink, - img: MarkdownImage, - span: AssistantMarkdownSpan, - table: MarkdownTable, - SyntaxHighlighter: AssistantSyntaxHighlighter, -}); - -const AssistantTextPart = () => ( - -); - -const UserTextPart = () => { - const { text } = useMessagePartText(); - const { entities } = useContext(AssistantDirectiveEntitiesContext); - const segments = parseAssistantDirectives(text); - return ( - - {segments.map((segment, index) => { - if (segment.kind === "text") return {segment.text}; - const entity = resolveAssistantDirectiveEntity(entities, segment); - return ( - - ); - })} - - ); -}; - -const AttachmentPreview = () => { - const attachment = useAuiState((state) => state.attachment); - const [filePreview, setFilePreview] = useState(null); - const contentImage = attachment.content?.find((part) => part.type === "image"); - const persistedPreview = - contentImage?.type === "image" - ? getSafeAssistantAttachmentImageSource( - contentImage.image, - typeof window === "undefined" ? undefined : window.location.origin, - ) - : null; - - useEffect(() => { - if (persistedPreview || attachment.type !== "image" || !attachment.file) { - setFilePreview(null); - return; - } - const objectUrl = URL.createObjectURL(attachment.file); - setFilePreview(objectUrl); - return () => URL.revokeObjectURL(objectUrl); - }, [attachment.file, attachment.type, persistedPreview]); - - const source = persistedPreview ?? filePreview; - if (source) { - return ; - } - return ( - - {attachment.type === "image" ? : } - - ); -}; - -const Attachment = ({ removable = false }: { removable?: boolean }) => { - const t = useI18n("assistant"); - const attachment = useAuiState((state) => state.attachment); - return ( - - - - - - - {attachment.contentType && ( - - {attachment.contentType} - - )} - - {attachment.status.type === "running" && } - {removable && ( - - - - - - )} - - ); -}; -const SentAttachment = () => ; -const HiddenMessagePart = () => null; - -const ReasoningPart = (_props: ReasoningMessagePartProps) => ( - - - -); - -const AssistantMessagePending = ({ status }: EmptyMessagePartProps) => { - const t = useI18n("assistant"); - if (status.type !== "running") return null; - - return ( - - - - {t("activity.thinking")} - - - ); -}; - -const ReasoningVisibilityContext = createContext<{ - collapsed: boolean; - setCollapsed: (collapsed: boolean) => void; -}>({ collapsed: false, setCollapsed: () => undefined }); - -const SourcePart = (source: SourceMessagePartProps) => { - const safeUrl = getSafeAssistantHttpUrl(source.url); - const content = ( - <> - - {source.title} - - ); - - return safeUrl ? ( - - {content} - - ) : ( - - {content} - - ); -}; - -const FilePart = ({ data, filename, mimeType }: FileMessagePartProps) => { - const t = useI18n("assistant"); - const downloadable = data.startsWith("data:"); - const displayName = filename ?? mimeType; - return ( - - - - {displayName} - - {downloadable && ( - - - - )} - - ); -}; - -const ImagePart = ({ image, filename }: ImageMessagePartProps) => { - const t = useI18n("assistant"); - const source = getSafeAssistantAttachmentImageSource( - image, - typeof window === "undefined" ? undefined : window.location.origin, - ); - if (!source) { - return ( - - - {t("externalImageBlocked")} - - ); - } - return ( - - ); -}; - -const formatToolResultValue = (value: string | number | boolean) => - typeof value === "number" ? value.toLocaleString() : String(value); - -const ToolResultPresentation = ({ result, toolName }: { result: unknown; toolName: string }) => { - const presentation = getToolResultPresentation(result, { toolName }); - if (!presentation) return null; - - if (presentation.type === "text") { - return ( - - {presentation.text} - - ); - } - - if (presentation.type === "properties") { - return ( - - {presentation.fields.map((field) => ( - - - {field.label} - - - {formatToolResultValue(field.value)} - - - ))} - - ); - } - - if (presentation.type === "icons") { - if (presentation.items.length === 0) return null; - const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length); - - return ( - - {presentation.items.map((item) => ( - - - {item.name} - - - - {item.name} - - - {item.variant} - - - {item.repository && ( - - {item.repository} - - )} - - ))} - {hiddenCount > 0 && ( - - +{hiddenCount} - - )} - - ); - } - - const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length); - return ( - - {presentation.items.map((item, index) => ( - - - - - {item.title} - - {item.description && ( - - {item.description} - - )} - - {item.badges.length > 0 && ( - - {item.badges.map((badge) => ( - - {badge} - - ))} - - )} - - {item.fields.length > 0 && ( - - {item.fields.map((field) => ( - - {field.label}:{" "} - - {formatToolResultValue(field.value)} - - - ))} - - )} - - ))} - {hiddenCount > 0 && ( - - +{hiddenCount} - - )} - - ); -}; - -const getToolResultNavigation = (result: unknown) => { - if (typeof result !== "object" || result === null || Array.isArray(result)) return []; - - const record = result as Record; - const candidates = [ - { href: record.previewPath, translationKey: "toolNavigation.openPreview" as const }, - { href: record.managementPath, translationKey: "toolNavigation.openWidget" as const }, - ]; - - return candidates.filter( - (candidate): candidate is { href: string; translationKey: (typeof candidate)["translationKey"] } => - typeof candidate.href === "string" && candidate.href.startsWith("/") && !candidate.href.startsWith("//"), - ); -}; - -const ToolResultNavigation = ({ result }: { result: unknown }) => { - const t = useI18n("assistant"); - const router = useRouter(); - const links = getToolResultNavigation(result); - - if (links.length === 0) return null; - - return ( - - {links.map((link) => ( - - ))} - - ); -}; - -const ToolResultPreview = ({ result, toolName }: { result: unknown; toolName: string }) => { - const navigation = getToolResultNavigation(result); - if (navigation.length === 0) return ; - - return ( - - - - - ); -}; - -const AgentTraceToolContext = createContext(false); - -const ToolPart = ({ - toolCallId, - toolName, - args, - result, - isError, - status, - approval, - respondToApproval, - timing, -}: ToolCallMessagePartProps) => { - const t = useI18n("assistant"); - const compact = useContext(AgentTraceToolContext); - const [opened, setOpened] = useState(false); - const [approvalResponse, setApprovalResponse] = useState<"approve" | "deny" | null>(null); - const completed = status?.type === "complete"; - const awaitingApproval = approval !== undefined && approval.approved === undefined && !approval.resolution; - const denied = approval?.approved === false; - const failed = - !denied && - (isError === true || - status?.type === "incomplete" || - (typeof result === "object" && result !== null && "error" in result)); - const successful = completed && !denied && !failed; - const compactPresentation = compact && !awaitingApproval; - const traceTarget = getAssistantToolTraceTarget(args); - const iconSearchQuery = getAssistantIconSearchQuery(toolName, args); - const displayName = - iconSearchQuery === null - ? toolName.replaceAll("_", " ") - : iconSearchQuery.length > 0 - ? t("toolActivity.iconSearch", { query: iconSearchQuery }) - : t("toolActivity.iconBrowse"); - const duration = timing?.completedAt !== undefined ? Math.max(0, timing.completedAt - timing.startedAt) : undefined; - const autoApprovalInProgress = useAssistantAutomaticAction({ - toolCallId, - ready: awaitingApproval, - completed: !awaitingApproval, - confirm: () => { - respondToApproval({ approved: true, reason: "Approved automatically by the user." }); - }, - }); - - return ( - - setOpened((current) => !current)} - aria-expanded={opened} - > - - - - {denied || failed ? : successful ? : } - - - - {displayName} - - {compactPresentation && traceTarget && ( - - {traceTarget} - - )} - - - - {duration !== undefined && ( - - {duration < 1000 ? `${duration} ms` : `${(duration / 1000).toFixed(1)} s`} - - )} - - {awaitingApproval - ? t("approvalRequired") - : denied - ? t("denied") - : failed - ? t("failed") - : successful - ? t("complete") - : t("working")} - - - - - - {awaitingApproval && ( - - - {t("approvalDescription")} - - - {autoApprovalInProgress ? ( - - - - {t("autoApproval.approving")} - - - ) : ( - - - - - )} - - )} - {!compactPresentation && successful && result !== undefined && ( - - )} - - - {compactPresentation && successful && result !== undefined && ( - - )} - - - {t("toolInput")} - - - {JSON.stringify(args, null, 2)} - - - {!denied && result !== undefined && ( - - - {t("toolOutput")} - - - {typeof result === "string" ? result : JSON.stringify(result, null, 2)} - - - )} - - - - ); -}; - -const AgentTraceToolGroup = ({ children }: { children?: ReactNode }) => ( - - - {children} - - -); - -const ChainOfThoughtLayout = ({ children }: { children?: ReactNode }) => ( - - {children} - -); - -const AssistantChainOfThought = ({ children }: { children?: ReactNode }) => { - const t = useI18n("assistant"); - const contentId = useId(); - const { collapsed: preferredCollapsed, setCollapsed: setPreferredCollapsed } = useContext(ReasoningVisibilityContext); - const { chainStatus, collapsed } = useAssistantReasoningState(preferredCollapsed); - const running = chainStatus.type === "running"; - - return ( - - - setPreferredCollapsed(!collapsed)} - > - - {(running || chainStatus.type === "requires-action") && ( - - )} - - {t("reasoning")} - - - - - - - - ); -}; - const BranchPicker = () => { const t = useI18n("assistant"); return ( @@ -1103,18 +126,6 @@ const BranchPicker = () => { ); }; -const downloadAssistantMarkdown = (markdown: string, filename: string) => { - const blobUrl = URL.createObjectURL(new Blob([markdown], { type: "text/markdown;charset=utf-8" })); - const link = document.createElement("a"); - link.href = blobUrl; - link.download = filename; - link.hidden = true; - document.body.append(link); - link.click(); - link.remove(); - setTimeout(() => URL.revokeObjectURL(blobUrl), 0); -}; - const AssistantMessageActions = () => { const t = useI18n("assistant"); const aui = useAui(); @@ -1279,573 +290,6 @@ const UserMessage = () => { ); }; -const formatDuration = (milliseconds: number) => - milliseconds < 1000 ? `${Math.round(milliseconds)} ms` : `${(milliseconds / 1000).toFixed(1)} s`; - -const formatCost = (cost: number) => { - if (cost === 0) return "$0"; - if (cost < 0.00001) return `<$0.00001`; - return `$${cost.toFixed(cost < 0.001 ? 5 : cost < 0.01 ? 4 : 3)}`; -}; - -const getContextColor = (percentage: number) => { - if (percentage >= 90) return "red"; - if (percentage >= 75) return "orange"; - return "blue"; -}; - -const getAssistantProviderPreset = (provider: string) => { - if (!assistantProviderIds.includes(provider as AssistantProvider)) return null; - return assistantProviderPresets[provider as AssistantProvider]; -}; - -const ProviderIcon = ({ provider, size = 16 }: { provider: string; size?: number }) => { - const colorScheme = useComputedColorScheme("light"); - const preset = getAssistantProviderPreset(provider); - const source = colorScheme === "dark" && preset && "darkIconUrl" in preset ? preset.darkIconUrl : preset?.iconUrl; - - return source ? ( - - ) : ( - - ); -}; - -const ProviderMessageInfo = () => { - const metadata = useAuiState((state) => state.message.metadata); - const telemetry = getAssistantTelemetry(metadata); - if (!telemetry) return null; - - const details = [ - telemetry.modelId, - telemetry.durationMs !== undefined ? formatDuration(telemetry.durationMs) : undefined, - telemetry.outputTokensPerSecond !== undefined ? `${telemetry.outputTokensPerSecond.toFixed(1)} tok/s` : undefined, - telemetry.cost !== undefined ? formatCost(telemetry.cost) : undefined, - ].filter(Boolean); - - return ( - - - {telemetry.provider} - - {details.join(" · ")} - - } - multiline - maw="min(24rem, calc(100vw - 2rem))" - > - - - - - ); -}; - -const ConversationTurn = ({ - index, - messageId, - threadId, - turn, -}: { - index: number; - messageId?: string; - threadId?: string; - turn: AssistantConversationTurnUsage; -}) => { - const t = useI18n("assistant"); - const detailsId = useId(); - const [opened, setOpened] = useState(false); - const generationQuery = clientApi.assistant.getGenerationTelemetry.useQuery( - { threadId: threadId ?? "", messageId: messageId ?? "" }, - { - enabled: opened && turn.provider === "openrouter" && Boolean(threadId) && Boolean(messageId), - refetchInterval: (query) => - query.state.data?.complete === false && query.state.dataUpdateCount < 6 ? 1500 : false, - retry: 2, - staleTime: (query) => (query.state.data?.complete ? 5 * 60_000 : 0), - }, - ); - const generationById = new Map( - generationQuery.data?.generations.flatMap((generation) => - generation.generationId ? [[generation.generationId, generation] as const] : [], - ) ?? [], - ); - const steps = turn.telemetry.steps.map((step) => ({ - ...step, - ...(step.generationId ? generationById.get(step.generationId) : undefined), - })); - const sumCompleteMetric = (getValue: (step: (typeof steps)[number]) => number | undefined) => { - if (steps.length === 0) return undefined; - const values = steps.map(getValue); - return values.some((value) => value === undefined) - ? undefined - : (values as number[]).reduce((sum, value) => sum + value, 0); - }; - const providerInputTokens = sumCompleteMetric((step) => step.inputTokens); - const providerOutputTokens = sumCompleteMetric((step) => step.outputTokens); - const providerGenerationTimeMs = sumCompleteMetric((step) => step.generationTimeMs); - const providerOutputTokensPerSecond = - providerOutputTokens !== undefined && providerGenerationTimeMs !== undefined && providerGenerationTimeMs > 0 - ? providerOutputTokens / (providerGenerationTimeMs / 1000) - : undefined; - const latestStep = steps.at(-1); - const providerContextUsed = - latestStep?.inputTokens !== undefined && latestStep.outputTokens !== undefined - ? latestStep.inputTokens + latestStep.outputTokens - : undefined; - const usage = { - ...turn.usage, - ...(generationQuery.data?.complete && providerInputTokens !== undefined - ? { inputTokens: providerInputTokens } - : {}), - ...(generationQuery.data?.complete && providerOutputTokens !== undefined - ? { outputTokens: providerOutputTokens } - : {}), - ...(generationQuery.data?.complete && providerInputTokens !== undefined && providerOutputTokens !== undefined - ? { totalTokens: providerInputTokens + providerOutputTokens } - : {}), - ...(generationQuery.data?.complete && sumCompleteMetric((step) => step.cachedInputTokens) !== undefined - ? { cachedInputTokens: sumCompleteMetric((step) => step.cachedInputTokens) } - : {}), - ...(generationQuery.data?.complete && sumCompleteMetric((step) => step.reasoningTokens) !== undefined - ? { reasoningTokens: sumCompleteMetric((step) => step.reasoningTokens) } - : {}), - }; - const telemetry = { - ...turn.telemetry, - steps, - ...(generationQuery.data?.complete && providerGenerationTimeMs !== undefined - ? { generationTimeMs: providerGenerationTimeMs } - : {}), - ...(generationQuery.data?.complete && providerOutputTokensPerSecond !== undefined - ? { providerOutputTokensPerSecond } - : {}), - ...(generationQuery.data?.complete && providerContextUsed !== undefined - ? { - contextUsed: providerContextUsed, - ...(turn.telemetry.contextLength - ? { contextUtilization: Math.min(providerContextUsed / turn.telemetry.contextLength, 1) } - : {}), - } - : {}), - ...(generationQuery.data?.complete && sumCompleteMetric((step) => step.cost) !== undefined - ? { cost: sumCompleteMetric((step) => step.cost), costType: "reported" as const } - : {}), - ...(generationQuery.data?.complete && sumCompleteMetric((step) => step.upstreamCost) !== undefined - ? { upstreamCost: sumCompleteMetric((step) => step.upstreamCost) } - : {}), - ...(generationQuery.data?.complete && sumCompleteMetric((step) => step.cacheDiscount) !== undefined - ? { cacheDiscount: sumCompleteMetric((step) => step.cacheDiscount) } - : {}), - ...(generationQuery.data?.complete && sumCompleteMetric((step) => step.fallbackCount) !== undefined - ? { fallbackCount: sumCompleteMetric((step) => step.fallbackCount) } - : {}), - ...(generationQuery.data?.complete && sumCompleteMetric((step) => step.fallbackLatencyMs) !== undefined - ? { fallbackLatencyMs: sumCompleteMetric((step) => step.fallbackLatencyMs) } - : {}), - }; - const displayedThroughput = telemetry.providerOutputTokensPerSecond ?? telemetry.outputTokensPerSecond; - - return ( - - setOpened((value) => !value)} - > - - - - - - - - {t("usage.step", { number: index + 1 })} · {turn.modelId} - - - {[ - telemetry.durationMs !== undefined ? formatDuration(telemetry.durationMs) : undefined, - displayedThroughput !== undefined ? `${displayedThroughput.toFixed(1)} tok/s` : undefined, - usage.totalTokens !== undefined - ? `${usage.totalTokens.toLocaleString()} ${t("usage.tokens")}` - : undefined, - telemetry.cost !== undefined ? formatCost(telemetry.cost) : undefined, - ] - .filter(Boolean) - .join(" · ") || t("usage.notReported")} - - - - - - - - - {generationQuery.isFetching && generationQuery.data?.complete !== true && ( - - - - {t("usage.loadingProviderDetails")} - - - )} - {!generationQuery.isFetching && generationQuery.data?.complete === false && ( - - {t("usage.providerDetailsPending")} - - )} - - {[ - [t("usage.input"), usage.inputTokens?.toLocaleString()], - [t("usage.output"), usage.outputTokens?.toLocaleString()], - [t("usage.cached"), usage.cachedInputTokens?.toLocaleString()], - [t("usage.reasoning"), usage.reasoningTokens?.toLocaleString()], - [t("usage.cacheWrite"), usage.cacheWriteTokens?.toLocaleString()], - [t("usage.tokens"), usage.totalTokens?.toLocaleString()], - [ - t("usage.firstOutput"), - telemetry.timeToFirstOutputMs !== undefined ? formatDuration(telemetry.timeToFirstOutputMs) : undefined, - ], - [ - t("usage.endToEnd"), - telemetry.durationMs !== undefined ? formatDuration(telemetry.durationMs) : undefined, - ], - [ - t("usage.generationTime"), - telemetry.generationTimeMs !== undefined ? formatDuration(telemetry.generationTimeMs) : undefined, - ], - [ - t("usage.providerThroughput"), - displayedThroughput !== undefined ? `${displayedThroughput.toFixed(1)} tok/s` : undefined, - ], - [ - t("usage.cost"), - telemetry.cost !== undefined - ? `${formatCost(telemetry.cost)}${telemetry.costType ? ` · ${t(`usage.${telemetry.costType}`)}` : ""}` - : undefined, - ], - [ - t("usage.upstreamCost"), - telemetry.upstreamCost !== undefined ? formatCost(telemetry.upstreamCost) : undefined, - ], - [t("usage.fallbacks"), telemetry.fallbackCount?.toLocaleString()], - [ - t("usage.fallbackLatency"), - telemetry.fallbackLatencyMs !== undefined ? formatDuration(telemetry.fallbackLatencyMs) : undefined, - ], - [ - t("usage.cacheDiscount"), - telemetry.cacheDiscount !== undefined ? formatCost(telemetry.cacheDiscount) : undefined, - ], - [t("usage.webSearches"), telemetry.webSearchRequests?.toLocaleString()], - [t("usage.finishReason"), telemetry.finishReason], - ].map(([label, value]) => ( -
- - {label} - - - {value ?? t("usage.notReported")} - -
- ))} -
- {(telemetry.contextUsed !== undefined || telemetry.contextLength !== undefined) && ( - - {t("usage.contextWindow")}: {telemetry.contextUsed?.toLocaleString() ?? t("usage.notReported")} /{" "} - {telemetry.contextLength?.toLocaleString() ?? t("usage.notReported")} - - )} - {steps.length > 0 && ( - - - {t("usage.agentSteps")} - - {steps.map((step) => ( - - - - {t("usage.step", { number: step.index })} - {step.routedProvider ? ` · ${step.routedProvider}` : ""} - - - {formatDuration(step.durationMs)} - {step.providerOutputTokensPerSecond !== undefined - ? ` · ${step.providerOutputTokensPerSecond.toFixed(1)} tok/s` - : step.outputTokensPerSecond !== undefined - ? ` · ${step.outputTokensPerSecond.toFixed(1)} tok/s` - : ""} - {step.cost !== undefined ? ` · ${formatCost(step.cost)}` : ""} - - - - {[ - step.providerLatencyMs !== undefined - ? `${t("usage.providerLatency")} ${formatDuration(step.providerLatencyMs)}` - : undefined, - step.generationTimeMs !== undefined - ? `${t("usage.generationTime")} ${formatDuration(step.generationTimeMs)}` - : undefined, - step.moderationLatencyMs !== undefined - ? `${t("usage.moderationLatency")} ${formatDuration(step.moderationLatencyMs)}` - : undefined, - step.inputTokens !== undefined - ? `${step.inputTokens.toLocaleString()} ${t("usage.inputShort")}` - : undefined, - step.outputTokens !== undefined - ? `${step.outputTokens.toLocaleString()} ${t("usage.outputShort")}` - : undefined, - step.cachedInputTokens !== undefined - ? `${step.cachedInputTokens.toLocaleString()} ${t("usage.cachedShort")}` - : undefined, - step.reasoningTokens !== undefined - ? `${step.reasoningTokens.toLocaleString()} ${t("usage.reasoningShort")}` - : undefined, - step.fallbackCount !== undefined - ? `${t("usage.fallbacks")} ${step.fallbackCount.toLocaleString()}` - : undefined, - step.fallbackLatencyMs !== undefined - ? `${t("usage.fallbackLatency")} ${formatDuration(step.fallbackLatencyMs)}` - : undefined, - step.nativeFinishReason, - ] - .filter(Boolean) - .join(" · ") || t("usage.notReported")} - - {(step.normalizedInputTokens !== undefined || step.normalizedOutputTokens !== undefined) && ( - - {t("usage.normalizedTokens")}: {step.normalizedInputTokens?.toLocaleString() ?? "–"} /{" "} - {step.normalizedOutputTokens?.toLocaleString() ?? "–"} - - )} - {(step.routerStrategy || step.routerRegion || step.serviceTier || step.dataRegion || step.isByok) && ( - - {[ - step.routerStrategy, - step.routerRegion, - step.serviceTier, - step.dataRegion, - step.isByok ? t("usage.byok") : undefined, - ] - .filter(Boolean) - .join(" · ")} - - )} - {step.generationId && ( - - {t("usage.generation")}: {step.generationId} - - )} - - ))} - - )} -
-
-
- ); -}; - -const ConversationContextBreakdown = ({ breakdown }: { breakdown: AssistantContextBreakdown }) => { - const t = useI18n("assistant"); - const hasContext = breakdown.percentage !== undefined; - const categories = [ - { label: t("usage.input"), value: breakdown.inputTokens }, - { label: t("usage.cached"), value: breakdown.cachedInputTokens }, - { label: t("usage.output"), value: breakdown.outputTokens }, - { label: t("usage.reasoning"), value: breakdown.reasoningTokens }, - ]; - - return ( - - - {hasContext ? `${breakdown.percentage}%` : "–"} - - } - /> - - {[ - { label: t("usage.used"), value: breakdown.contextUsed }, - { label: t("usage.remaining"), value: breakdown.remaining }, - { label: t("usage.capacity"), value: breakdown.contextLength }, - ].map((item) => ( - - - {item.label} - - - {item.value?.toLocaleString() ?? t("usage.notReported")} - - - ))} - - - {categories.map((category) => ( - - - {category.label} - - - {category.value?.toLocaleString() ?? t("usage.notReported")} - - - ))} - - - ); -}; - -const ConversationContext = () => { - const t = useI18n("assistant"); - const [opened, setOpened] = useState(false); - const messages = useAuiState((state) => state.thread.messages); - const threadId = useAuiState((state) => state.threadListItem.remoteId); - const metadata = useMemo(() => messages.map((message) => message.metadata), [messages]); - const messageIdByRequestId = useMemo( - () => - new Map( - messages.flatMap((message) => { - const telemetry = getAssistantTelemetry(message.metadata); - return telemetry ? [[telemetry.requestId, message.id] as const] : []; - }), - ), - [messages], - ); - const usage = useMemo(() => getAssistantConversationUsage(metadata), [metadata]); - const breakdown = useMemo(() => getAssistantContextBreakdown(usage), [usage]); - const hasTokenUsage = usage.turns.some((turn) => turn.totalTokens !== undefined); - const hasCost = usage.turns.some((turn) => turn.cost !== undefined); - const hasContext = breakdown.percentage !== undefined; - const contextPercentage = breakdown.percentage ?? 0; - const quickLabel = [ - hasTokenUsage ? `${usage.totalTokens.toLocaleString()} ${t("usage.tokens")}` : undefined, - hasCost ? formatCost(usage.cost) : undefined, - ] - .filter(Boolean) - .join(" · "); - - return ( - - - setOpened((value) => !value)} - > - - - - {hasContext ? `${contextPercentage}%` : t("mentions.context")} - - - - - - - - - - {t("usage.contextWindow")} - - - {t("usage.requestDetails")} - - - {hasContext && ( - - {contextPercentage}% - - )} - - -
- - {t("usage.tokens")} - - - {hasTokenUsage ? usage.totalTokens.toLocaleString() : t("usage.notReported")} - -
-
- - {t("usage.cost")} - - - {hasCost ? formatCost(usage.cost) : t("usage.notReported")} - -
-
- - {t("usage.agentSteps")} - - - {usage.turns.reduce((total, turn) => total + turn.telemetry.steps.length, 0).toLocaleString()} - -
-
- - - - - {usage.turns.length === 0 ? ( - - {t("usage.notReported")} - - ) : ( - usage.turns.map((turn, index) => ( - - )) - )} - - -
-
-
- ); -}; const RuntimeError = () => { const t = useI18n("assistant"); const actionT = useI18n("common.action"); @@ -1995,1053 +439,10 @@ const AssistantMessage = () => { const assistantThreadMessageComponents = { UserMessage, AssistantMessage }; -const HistorySelectContext = createContext<() => void>(() => undefined); - -const ThreadListItem = () => { - const t = useI18n("assistant"); - const actionT = useI18n("common.action"); - const aui = useAui(); - const onSelect = useContext(HistorySelectContext); - const remoteId = useAuiState((state) => state.threadListItem.remoteId); - const title = useAuiState((state) => state.threadListItem.title); - const [exporting, setExporting] = useState(false); - const [actionsOpened, setActionsOpened] = useState(false); - const [renameOpened, setRenameOpened] = useState(false); - const [renaming, setRenaming] = useState(false); - const [nextTitle, setNextTitle] = useState(title ?? ""); - const renameInputRef = useRef(null); - - useEffect(() => { - if (renameOpened) renameInputRef.current?.focus(); - }, [renameOpened]); - - const exportConversation = async () => { - if (!remoteId || exporting) return; - setExporting(true); - try { - const conversation = await fetchApi.assistant.getThread.query({ threadId: remoteId }); - const markdown = buildAssistantConversationMarkdown(conversation); - downloadAssistantMarkdown( - markdown, - getAssistantConversationExportFilename(conversation.thread.title ?? title, remoteId), - ); - } catch { - showErrorNotification({ - title: t("exportConversation.failedTitle"), - message: t("exportConversation.failedDescription"), - }); - } finally { - setExporting(false); - } - }; - - const renameConversation = async () => { - const trimmedTitle = nextTitle.trim(); - if (!trimmedTitle || renaming) return; - setRenaming(true); - try { - await aui.threadListItem().rename(trimmedTitle); - setRenameOpened(false); - setActionsOpened(false); - } catch { - showErrorNotification({ - title: t("renameConversation.failedTitle"), - message: t("renameConversation.failedDescription"), - }); - } finally { - setRenaming(false); - } - }; - - const deleteConversation = async () => { - try { - await aui.threadListItem().delete(); - } catch { - showErrorNotification({ - title: t("deleteConversation.failedTitle"), - message: t("deleteConversation.failedDescription"), - }); - } - }; - - if (renameOpened) { - return ( - - setNextTitle(event.currentTarget.value)} - aria-label={t("renameConversation.label")} - maxLength={72} - size="xs" - onKeyDown={(event) => { - if (event.key === "Enter") void renameConversation(); - if (event.key === "Escape" && !renaming) setRenameOpened(false); - }} - /> - void renameConversation()} - > - - - setRenameOpened(false)} - > - - - - ); - } - - return ( - - - - - - - - - {actionsOpened && ( - - { - setNextTitle(title ?? ""); - setRenameOpened(true); - }} - > - - - void exportConversation()} - > - - - } - onConfirm={deleteConversation} - > - - - - )} - setActionsOpened((current) => !current)} - > - {actionsOpened ? : } - - - ); -}; - -const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => { - const t = useI18n("assistant"); - const [query, setQuery] = useState(""); - const normalizedQuery = query.trim().toLocaleLowerCase(); - const threadItems = useAuiState((state) => state.threads.threadItems); - const hasMatchingConversation = threadItems.some((item) => { - const itemTitle = item.title ?? t("newConversation"); - return itemTitle.toLocaleLowerCase().includes(normalizedQuery); - }); - return ( - - - - - - - - - - - {t("conversations")} - - - setQuery(event.currentTarget.value)} - leftSection={} - placeholder={t("searchConversations")} - aria-label={t("searchConversations")} - size="xs" - /> - - - - {({ threadListItem }) => { - const itemTitle = threadListItem.title ?? t("newConversation"); - if (normalizedQuery && !itemTitle.toLocaleLowerCase().includes(normalizedQuery)) return null; - return ; - }} - - {normalizedQuery && !hasMatchingConversation && ( - - {t("noMatchingConversations")} - - )} - - - - - ); -}; - -const ConversationHistory = () => { - const t = useI18n("assistant"); - const [opened, setOpened] = useState(false); - return ( - - - - - - setOpened(false)} /> - - - ); -}; - -const AutoApprovalControl = () => { - const t = useI18n("assistant.autoApproval"); - const [opened, setOpened] = useState(false); - const { enabled, setEnabled } = useAssistantAutoApproval(); - - return ( - setOpened(false)} - position="bottom-end" - width="min(19rem, calc(100vw - 1rem))" - shadow="md" - withinPortal - > - - setOpened((current) => !current)} - aria-label={t("label")} - aria-pressed={enabled} - title={enabled ? t("enabled") : t("label")} - > - - - - - - - - {t("title")} - - - {t("description")} - - - - - - - ); -}; - -const ViewRefreshAction = ({ - isRefreshing, - onRefresh, -}: Pick) => { - const t = useI18n("assistant.refresh"); - - const refresh = async () => { - try { - await onRefresh(); - showSuccessNotification({ title: t("completeTitle"), message: t("completeDescription") }); - } catch { - showErrorNotification({ title: t("failedTitle"), message: t("failedDescription") }); - } - }; - - return ( - - void refresh()} - aria-label={isRefreshing ? t("working") : t("action")} - > - - - - ); -}; - -const EmptyThread = () => { - const t = useI18n("assistant"); - return ( - - - - - - - - - {t("emptyTitle")} - - - {t("emptyDescription")} - - - - - - - - - - - - - - - - - - - - ); -}; - -const TriggerPopoverAutoScroll = ({ viewportRef }: { viewportRef: RefObject }) => { - const { activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query } = - unstable_useTriggerPopoverScopeContext(); - - useLayoutEffect(() => { - if (!open) return; - - const viewport = viewportRef.current; - const highlightedItem = viewport?.querySelector("[data-highlighted]"); - if (!viewport || !highlightedItem) return; - - const viewportRect = viewport.getBoundingClientRect(); - const itemRect = highlightedItem.getBoundingClientRect(); - const viewportTop = viewportRect.top + viewport.clientTop; - const viewportBottom = viewportTop + viewport.clientHeight; - - viewport.scrollTop = getNearestTriggerScrollTop({ - scrollTop: viewport.scrollTop, - viewportTop, - viewportBottom, - itemTop: itemRect.top, - itemBottom: itemRect.bottom, - }); - }, [activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query, viewportRef]); - - return null; -}; - -const TriggerItem = ({ item, index }: { item: Unstable_TriggerItem; index: number }) => { - const Icon = - typeof item.metadata?.icon === "string" && item.metadata.icon in contextIcons - ? contextIcons[item.metadata.icon as keyof typeof contextIcons] - : IconAt; - return ( - - - - -
- - {item.label} - - {item.description && ( - - {item.description} - - )} -
-
- ); -}; - -const ComposerTriggers = () => { - const t = useI18n("assistant"); - const aui = useAui(); - const mentionViewportRef = useRef(null); - const slashViewportRef = useRef(null); - const { entities, isLoading } = useContext(AssistantDirectiveEntitiesContext); - const categories = useMemo( - () => - (["app", "integration", "board", "widget"] as const).map((type) => ({ - id: type, - label: t(`mentions.${type}`), - items: entities - .filter((entity) => entity.type === type) - .map((entity) => ({ - id: entity.id, - type: entity.type, - label: entity.label, - description: entity.description, - icon: entity.type, - })), - })), - [entities, t], - ); - const mention = unstable_useMentionAdapter({ - categories, - includeModelContextTools: { - category: { id: "tools", label: t("mentions.tools") }, - formatLabel: (name) => name.replaceAll("_", " "), - icon: "tools", - }, - iconMap: contextIcons, - fallbackIcon: IconAt, - }); - const slash = unstable_useSlashCommandAdapter({ - removeOnExecute: true, - commands: [ - { - id: "health", - label: "/health", - description: t("commands.health"), - execute: () => aui.composer().setText(t("suggestions.health.prompt")), - }, - { - id: "explore", - label: "/explore", - description: t("commands.explore"), - execute: () => aui.composer().setText(t("suggestions.explore.prompt")), - }, - { - id: "media", - label: "/media", - description: t("commands.media"), - execute: () => aui.composer().setText(t("suggestions.media.prompt")), - }, - { - id: "style", - label: "/style", - description: t("commands.style"), - execute: () => aui.composer().setText(t("suggestions.style.prompt")), - }, - ], - }); - - return ( - <> - - - - - {(items) => - items.map((category) => { - const Icon = contextIcons[category.id as keyof typeof contextIcons] ?? IconAt; - return ( - - - - - - {category.label} - - - - ); - }) - } - - - {(items) => - items.map((item, index) => ) - } - - - - - - - {(items) => items.map((item, index) => )} - - - - ); -}; - -type ComposerProps = AssistantConversationControls & { pendingAction: AssistantPendingAction | undefined }; - -const getModelProviderLabel = (modelId: string, fallback: string) => { - const separator = modelId.indexOf("/"); - if (separator <= 0) return fallback; - 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)}`; -}; - -const formatCompactModelNumber = (value: number) => - new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(value); - -const RuntimeControls = ({ - modelId, - models, - modelOptionsLoading, - reasoning, - onModelChange, - onReasoningChange, -}: ComposerProps) => { - const t = useI18n("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 groupedModels = visibleModels.reduce((groups, model) => { - const provider = getModelProviderLabel(model.id, t("runtime.otherModels")); - const providerModels = groups.get(provider); - if (providerModels) providerModels.push(model); - else groups.set(provider, [model]); - return groups; - }, new Map()); - 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 ( - - - modelCombobox.toggleDropdown()} - aria-label={`${t("runtime.model")}: ${selectedModel?.name ?? t("runtime.noModels")}. ${t("runtime.thinking")}: ${t(`runtime.reasoning.${reasoning}`)}`} - aria-expanded={modelCombobox.dropdownOpened} - > - - {modelOptionsLoading && } - - {selectedModel?.name ?? t("runtime.model")} - - - {t(`runtime.reasoning.${reasoning}`)} - - - - - - - { - setModelSearch(event.currentTarget.value); - modelCombobox.updateSelectedOptionIndex(); - }} - placeholder={t("runtime.searchModels")} - aria-label={t("runtime.searchModels")} - size="xs" - /> - - {[...groupedModels.entries()].map(([provider, providerModels]) => ( - - {providerModels.map((model) => ( - - - - - - {model.name} - - - {model.description?.trim() || model.id} - - - - {model.inputModalities.includes("image") && ( - - - - )} - {model.contextLength && ( - - {formatCompactModelNumber(model.contextLength)} - - )} - {model.id === modelId && } - - - - ))} - - ))} - {visibleModels.length === 0 && {t("runtime.noModels")}} - - - - - {t("runtime.thinking")} - - { - if (assistantReasoningModes.includes(value as AssistantReasoningMode)) { - onReasoningChange(value as AssistantReasoningMode); - } - }} - size="xs" - fullWidth - data={assistantReasoningModes.map((mode) => ({ - value: mode, - label: t(`runtime.reasoning.${mode}`), - }))} - /> - - - - ); -}; - -const providerQuotaIcons = { - ok: IconBatteryFilled, - warning: IconBattery3, - bad: IconBattery1, - dead: IconBatteryOff, -}; - -const providerQuotaColors = { - ok: "green", - warning: "yellow", - bad: "orange", - dead: "red", -} as const; - -const HomarrProviderQuota = () => { - const t = useI18n("assistant"); - const locale = useCurrentIntlLocale(); - const preferences = useAssistantPreferences(); - const quota = preferences.quota; - const resetAt = useMemo(() => new Date(quota?.resetsAt ?? Date.now()), [quota?.resetsAt]); - const resetRelative = useTimeAgo(resetAt, 30_000); - if (preferences.provider !== "homarr") return null; - - const level = preferences.quota ? getAssistantProviderQuotaLevel(preferences.quota) : "dead"; - const Icon = providerQuotaIcons[level]; - const color = providerQuotaColors[level]; - const percentage = preferences.quota - ? Math.min(100, Math.max(0, (preferences.quota.remaining / Math.max(preferences.quota.limit, 1)) * 100)) - : 0; - const label = preferences.providerUser - ? quota - ? t("providerQuota.remainingLabel", { - remaining: quota.remaining, - limit: quota.limit, - }) - : t("providerQuota.loading") - : t("providerQuota.signInRequired"); - const resetTime = quota ? ( - - ) : null; - - return ( - - - - {preferences.quotaLoading && !preferences.quota ? : } - - {preferences.quota?.remaining ?? "–"} - - - - - - - - - - - - - {t("providerQuota.title")} - - - {preferences.providerUser?.name || t("providerQuota.communityWorkshop")} - - - - {preferences.quota && ( - - {t(`providerQuota.level.${level}`)} - - )} - - - {!preferences.providerUser ? ( - <> - - {t("providerQuota.signInDescription")} - - - - ) : preferences.quota ? ( - <> - - - - {t("providerQuota.dailyAllowance")} - - - {preferences.quota.remaining} / {preferences.quota.limit} - - - - - - {t.rich("providerQuota.reset", { - relative: resetRelative, - time: () => resetTime, - })} - - - {t("providerQuota.toolCalls")} - - - ) : ( - - )} - {preferences.quotaError && ( - - {preferences.quotaError} - - )} - - - - ); -}; - -const Composer = (props: ComposerProps) => { - const t = useI18n("assistant"); - const preferences = useAssistantPreferences(); - const running = useAuiState((state) => state.thread.isRunning); - const hasPendingAction = props.pendingAction !== undefined; - const providerUnavailable = isAssistantProviderUnavailable({ - provider: preferences.provider, - signedIn: preferences.providerUser !== null, - remaining: preferences.quota?.remaining, - }); - const sendBlocked = hasPendingAction || providerUnavailable; - const composerInputRef = useRef(null); - const composerLabel = t("composerPlaceholder"); - - useLayoutEffect(() => { - const editor = composerInputRef.current?.querySelector(".aui-lexical-input"); - if (!editor) return; - editor.setAttribute("aria-label", composerLabel); - return () => { - if (editor.getAttribute("aria-label") === composerLabel) editor.removeAttribute("aria-label"); - }; - }, [composerLabel]); - - return ( - - - - { - if (sendBlocked) event.preventDefault(); - }} - > - - - - - - - - - - - {() => } - - - - - - - - - - - - {running ? ( - - - - - - ) : ( - - - - - - )} - - - - - - - - - - - - - ); -}; - -const usePendingActionCopy = (action: AssistantPendingAction | undefined) => { - const t = useI18n("assistant"); - if (!action) return undefined; - if (action.kind === "question") { - return { title: t("pendingAction.answerTitle"), detail: action.detail ?? t("pendingAction.answerFallback") }; - } - if (action.kind === "form") { - const isBoardSettings = action.toolName === "configure_board_settings"; - return { - title: isBoardSettings ? t("pendingAction.boardFormTitle") : t("pendingAction.formTitle"), - detail: action.detail ?? (isBoardSettings ? t("configureBoardSettings.title") : t("configureApp.title")), - }; - } - const tool = action.toolName.replaceAll("_", " "); - return { title: t("activity.approval"), detail: action.detail ? `${tool} · ${action.detail}` : tool }; -}; - -const PendingActionBanner = ({ pendingAction }: { pendingAction: AssistantPendingAction | undefined }) => { - const t = useI18n("assistant"); - const copy = usePendingActionCopy(pendingAction); - if (!copy || pendingAction?.kind === "question") return null; - - return ( - - - - - - - - {copy.title} - - - {copy.detail} - - - - - - - - ); -}; - -const PendingQuestionDock = ({ - pendingAction, - setTarget, -}: { - pendingAction: AssistantPendingAction | undefined; - setTarget: (target: HTMLDivElement | null) => void; -}) => { - const t = useI18n("assistant"); - if (pendingAction?.kind !== "question") return null; - - return ( - -
- - ); +const getActivityColor = (needsApproval: boolean, failed: boolean, isRunning: boolean) => { + if (needsApproval) return "yellow"; + if (failed || isRunning) return "red"; + return "green"; }; const AssistantActivityBar = ({ @@ -3078,25 +479,24 @@ const AssistantActivityBar = ({ if (!visible) return null; - const title = isRunning - ? t("activity.thinking") - : pendingCopy - ? pendingCopy.title - : needsApproval - ? t("activity.approval") - : failed - ? t("activity.failed") - : t("activity.ready"); - const detail = isRunning - ? latestUserText || t("activity.working") - : pendingCopy?.detail || latestAssistantText || (failed ? t("responseError.description") : t("activity.completed")); + let title = t("activity.ready"); + if (failed) title = t("activity.failed"); + if (needsApproval) title = t("activity.approval"); + if (pendingCopy) title = pendingCopy.title; + if (isRunning) title = t("activity.thinking"); + + let fallbackDetail = t("activity.completed"); + if (failed) fallbackDetail = t("responseError.description"); + + let detail = pendingCopy?.detail || latestAssistantText || fallbackDetail; + if (isRunning) detail = latestUserText || t("activity.working"); const activityState = getAssistantActivityState({ isRunning, latestPartType: latestAssistantPartType, needsApproval, failed, }); - const activityColor = needsApproval ? "yellow" : failed ? "red" : isRunning ? "red" : "green"; + const activityColor = getActivityColor(needsApproval, failed, isRunning); return ( diff --git a/apps/nextjs/src/components/assistant/assistant-runtime-controls.tsx b/apps/nextjs/src/components/assistant/assistant-runtime-controls.tsx new file mode 100644 index 000000000..1916ebf36 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-runtime-controls.tsx @@ -0,0 +1,350 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useMemo, useState } from "react"; +import { + Badge, + Box, + Button, + Combobox, + Divider, + Group, + Loader, + Popover, + Progress, + SegmentedControl, + Stack, + Text, + ThemeIcon, + Tooltip, + UnstyledButton, + useCombobox, +} from "@mantine/core"; +import { + IconBattery1, + IconBattery3, + IconBatteryFilled, + IconBatteryOff, + IconCheck, + IconPhoto, +} from "@tabler/icons-react"; + +import { useTimeAgo } from "@homarr/common"; +import { assistantReasoningModes } from "@homarr/definitions"; +import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; + +import classes from "./assistant-panel.module.css"; +import type { AssistantConversationControls } from "./assistant-conversation-controls"; +import { useAssistantPreferences } from "./assistant-context"; +import type { AssistantPendingAction } from "./assistant-pending-action"; +import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences"; +import { getAssistantProviderQuotaLevel } from "./assistant-provider-quota"; + +type ComposerProps = AssistantConversationControls & { pendingAction: AssistantPendingAction | undefined }; + +const getModelProviderLabel = (modelId: string, fallback: string) => { + const separator = modelId.indexOf("/"); + if (separator <= 0) return fallback; + 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)}`; +}; + +const formatCompactModelNumber = (value: number) => + new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(value); + +export const RuntimeControls = ({ + modelId, + models, + modelOptionsLoading, + reasoning, + onModelChange, + onReasoningChange, +}: ComposerProps) => { + const t = useI18n("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 groupedModels = visibleModels.reduce((groups, model) => { + const provider = getModelProviderLabel(model.id, t("runtime.otherModels")); + const providerModels = groups.get(provider); + if (providerModels) providerModels.push(model); + else groups.set(provider, [model]); + return groups; + }, new Map()); + 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 ( + + + modelCombobox.toggleDropdown()} + aria-label={`${t("runtime.model")}: ${selectedModel?.name ?? t("runtime.noModels")}. ${t("runtime.thinking")}: ${t(`runtime.reasoning.${reasoning}`)}`} + aria-expanded={modelCombobox.dropdownOpened} + > + + {modelOptionsLoading && } + + {selectedModel?.name ?? t("runtime.model")} + + + {t(`runtime.reasoning.${reasoning}`)} + + + + + + + { + setModelSearch(event.currentTarget.value); + modelCombobox.updateSelectedOptionIndex(); + }} + placeholder={t("runtime.searchModels")} + aria-label={t("runtime.searchModels")} + size="xs" + /> + + {[...groupedModels.entries()].map(([provider, providerModels]) => ( + + {providerModels.map((model) => ( + + + + + + {model.name} + + + {model.description?.trim() || model.id} + + + + {model.inputModalities.includes("image") && ( + + + + )} + {model.contextLength && ( + + {formatCompactModelNumber(model.contextLength)} + + )} + {model.id === modelId && } + + + + ))} + + ))} + {visibleModels.length === 0 && {t("runtime.noModels")}} + + + + + {t("runtime.thinking")} + + { + if (assistantReasoningModes.includes(value as AssistantReasoningMode)) { + onReasoningChange(value as AssistantReasoningMode); + } + }} + size="xs" + fullWidth + data={assistantReasoningModes.map((mode) => ({ + value: mode, + label: t(`runtime.reasoning.${mode}`), + }))} + /> + + + + ); +}; + +const providerQuotaIcons = { + ok: IconBatteryFilled, + warning: IconBattery3, + bad: IconBattery1, + dead: IconBatteryOff, +}; + +const providerQuotaColors = { + ok: "green", + warning: "yellow", + bad: "orange", + dead: "red", +} as const; + +export const HomarrProviderQuota = () => { + const t = useI18n("assistant"); + const locale = useCurrentIntlLocale(); + const preferences = useAssistantPreferences(); + const quota = preferences.quota; + const resetAt = useMemo(() => new Date(quota?.resetsAt ?? Date.now()), [quota?.resetsAt]); + const resetRelative = useTimeAgo(resetAt, 30_000); + if (preferences.provider !== "homarr") return null; + + const level = preferences.quota ? getAssistantProviderQuotaLevel(preferences.quota) : "dead"; + const Icon = providerQuotaIcons[level]; + const color = providerQuotaColors[level]; + const percentage = preferences.quota + ? Math.min(100, Math.max(0, (preferences.quota.remaining / Math.max(preferences.quota.limit, 1)) * 100)) + : 0; + let label = t("providerQuota.signInRequired"); + if (preferences.providerUser && !quota) label = t("providerQuota.loading"); + if (preferences.providerUser && quota) { + label = t("providerQuota.remainingLabel", { + remaining: quota.remaining, + limit: quota.limit, + }); + } + const resetTime = quota ? ( + + ) : null; + let quotaContent: ReactNode; + if (!preferences.providerUser) { + quotaContent = ( + <> + + {t("providerQuota.signInDescription")} + + + + ); + } else if (quota) { + quotaContent = ( + <> + + + + {t("providerQuota.dailyAllowance")} + + + {quota.remaining} / {quota.limit} + + + + + + {t.rich("providerQuota.reset", { + relative: resetRelative, + time: () => resetTime, + })} + + + {t("providerQuota.toolCalls")} + + + ); + } else { + quotaContent = ( + + ); + } + + return ( + + + + {preferences.quotaLoading && !preferences.quota ? : } + + {preferences.quota?.remaining ?? "–"} + + + + + + + + + + + + + {t("providerQuota.title")} + + + {preferences.providerUser?.name || t("providerQuota.communityWorkshop")} + + + + {preferences.quota && ( + + {t(`providerQuota.level.${level}`)} + + )} + + + {quotaContent} + {preferences.quotaError && ( + + {preferences.quotaError} + + )} + + + + ); +}; diff --git a/apps/nextjs/src/components/assistant/assistant-tool-rendering.tsx b/apps/nextjs/src/components/assistant/assistant-tool-rendering.tsx new file mode 100644 index 000000000..afbe5c55f --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-tool-rendering.tsx @@ -0,0 +1,438 @@ +"use client"; + +import type { ReactNode } from "react"; +import { createContext, useContext, useId, useState } from "react"; +import type { ToolCallMessagePartProps } from "@assistant-ui/react"; +import { ChainOfThoughtPrimitive } from "@assistant-ui/react"; +import { + Badge, + Box, + Button, + Collapse, + Group, + Image, + Loader, + Stack, + Text, + ThemeIcon, + UnstyledButton, +} from "@mantine/core"; +import { IconCheck, IconChevronDown, IconLink, IconRobot, IconX } from "@tabler/icons-react"; +import { useRouter } from "next/navigation"; + +import { useI18n } from "@homarr/translation/client"; + +import classes from "./assistant-panel.module.css"; +import { useAssistantAutomaticAction } from "./assistant-auto-approval"; +import { AssistantDotMatrix } from "./assistant-dot-matrix"; +import { hasModifiedLinkClick, ReasoningVisibilityContext } from "./assistant-message-content"; +import { useAssistantReasoningState } from "./assistant-reasoning-state"; +import { getAssistantIconSearchQuery } from "./assistant-tool-label"; +import { getToolResultPresentation } from "./assistant-tool-result"; +import { getAssistantToolTraceTarget } from "./assistant-tool-trace"; + +const formatToolResultValue = (value: string | number | boolean) => + typeof value === "number" ? value.toLocaleString() : String(value); + +const ToolResultPresentation = ({ result, toolName }: { result: unknown; toolName: string }) => { + const presentation = getToolResultPresentation(result, { toolName }); + if (!presentation) return null; + + if (presentation.type === "text") { + return ( + + {presentation.text} + + ); + } + + if (presentation.type === "properties") { + return ( + + {presentation.fields.map((field) => ( + + + {field.label} + + + {formatToolResultValue(field.value)} + + + ))} + + ); + } + + if (presentation.type === "icons") { + if (presentation.items.length === 0) return null; + const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length); + + return ( + + {presentation.items.map((item) => ( + + + {item.name} + + + + {item.name} + + + {item.variant} + + + {item.repository && ( + + {item.repository} + + )} + + ))} + {hiddenCount > 0 && ( + + +{hiddenCount} + + )} + + ); + } + + const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length); + return ( + + {presentation.items.map((item, index) => ( + + + + + {item.title} + + {item.description && ( + + {item.description} + + )} + + {item.badges.length > 0 && ( + + {item.badges.map((badge) => ( + + {badge} + + ))} + + )} + + {item.fields.length > 0 && ( + + {item.fields.map((field) => ( + + {field.label}:{" "} + + {formatToolResultValue(field.value)} + + + ))} + + )} + + ))} + {hiddenCount > 0 && ( + + +{hiddenCount} + + )} + + ); +}; + +const getToolResultNavigation = (result: unknown) => { + if (typeof result !== "object" || result === null || Array.isArray(result)) return []; + + const record = result as Record; + const candidates = [ + { href: record.previewPath, translationKey: "toolNavigation.openPreview" as const }, + { href: record.managementPath, translationKey: "toolNavigation.openWidget" as const }, + ]; + + return candidates.filter( + (candidate): candidate is { href: string; translationKey: (typeof candidate)["translationKey"] } => + typeof candidate.href === "string" && candidate.href.startsWith("/") && !candidate.href.startsWith("//"), + ); +}; + +const ToolResultNavigation = ({ result }: { result: unknown }) => { + const t = useI18n("assistant"); + const router = useRouter(); + const links = getToolResultNavigation(result); + + if (links.length === 0) return null; + + return ( + + {links.map((link) => ( + + ))} + + ); +}; + +const ToolResultPreview = ({ result, toolName }: { result: unknown; toolName: string }) => { + const navigation = getToolResultNavigation(result); + if (navigation.length === 0) return ; + + return ( + + + + + ); +}; + +const AgentTraceToolContext = createContext(false); + +const getToolStatusPresentation = (denied: boolean, failed: boolean, successful: boolean) => { + if (denied || failed) return { color: "red", Icon: IconX } as const; + if (successful) return { color: "green", Icon: IconCheck } as const; + return { color: "gray", Icon: IconRobot } as const; +}; + +export const ToolPart = ({ + toolCallId, + toolName, + args, + result, + isError, + status, + approval, + respondToApproval, + timing, +}: ToolCallMessagePartProps) => { + const t = useI18n("assistant"); + const compact = useContext(AgentTraceToolContext); + const [opened, setOpened] = useState(false); + const [approvalResponse, setApprovalResponse] = useState<"approve" | "deny" | null>(null); + const completed = status?.type === "complete"; + const awaitingApproval = approval !== undefined && approval.approved === undefined && !approval.resolution; + const denied = approval?.approved === false; + const failed = + !denied && + (isError === true || + status?.type === "incomplete" || + (typeof result === "object" && result !== null && "error" in result)); + const successful = completed && !denied && !failed; + const compactPresentation = compact && !awaitingApproval; + const traceTarget = getAssistantToolTraceTarget(args); + const iconSearchQuery = getAssistantIconSearchQuery(toolName, args); + let displayName = toolName.replaceAll("_", " "); + if (iconSearchQuery !== null) { + displayName = + iconSearchQuery.length > 0 + ? t("toolActivity.iconSearch", { query: iconSearchQuery }) + : t("toolActivity.iconBrowse"); + } + const duration = timing?.completedAt !== undefined ? Math.max(0, timing.completedAt - timing.startedAt) : undefined; + const { color: statusColor, Icon: StatusIcon } = getToolStatusPresentation(denied, failed, successful); + let statusLabel = t("working"); + if (successful) statusLabel = t("complete"); + if (failed) statusLabel = t("failed"); + if (denied) statusLabel = t("denied"); + if (awaitingApproval) statusLabel = t("approvalRequired"); + const autoApprovalInProgress = useAssistantAutomaticAction({ + toolCallId, + ready: awaitingApproval, + completed: !awaitingApproval, + confirm: () => { + respondToApproval({ approved: true, reason: "Approved automatically by the user." }); + }, + }); + + return ( + + setOpened((current) => !current)} + aria-expanded={opened} + > + + + + + + + + {displayName} + + {compactPresentation && traceTarget && ( + + {traceTarget} + + )} + + + + {duration !== undefined && ( + + {duration < 1000 ? `${duration} ms` : `${(duration / 1000).toFixed(1)} s`} + + )} + + {statusLabel} + + + + + + {awaitingApproval && ( + + + {t("approvalDescription")} + + + {autoApprovalInProgress ? ( + + + + {t("autoApproval.approving")} + + + ) : ( + + + + + )} + + )} + {!compactPresentation && successful && result !== undefined && ( + + )} + + + {compactPresentation && successful && result !== undefined && ( + + )} + + + {t("toolInput")} + + + {JSON.stringify(args, null, 2)} + + + {!denied && result !== undefined && ( + + + {t("toolOutput")} + + + {typeof result === "string" ? result : JSON.stringify(result, null, 2)} + + + )} + + + + ); +}; + +export const AgentTraceToolGroup = ({ children }: { children?: ReactNode }) => ( + + + {children} + + +); + +const ChainOfThoughtLayout = ({ children }: { children?: ReactNode }) => ( + + {children} + +); + +export const AssistantChainOfThought = ({ children }: { children?: ReactNode }) => { + const t = useI18n("assistant"); + const contentId = useId(); + const { collapsed: preferredCollapsed, setCollapsed: setPreferredCollapsed } = useContext(ReasoningVisibilityContext); + const { chainStatus, collapsed } = useAssistantReasoningState(preferredCollapsed); + const running = chainStatus.type === "running"; + + return ( + + + setPreferredCollapsed(!collapsed)} + > + + {(running || chainStatus.type === "requires-action") && ( + + )} + + {t("reasoning")} + + + + + + + + ); +};