feat!: prepare Homarr v2 release (#6746)
This commit is contained in:
1 parent
90c7493f15
commit
38f4b0e1f8
52 files changed
+3446
-4480
No files matched your search
@@ -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`.
|
||||
@@ -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."
|
||||
@@ -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 -- <args>` 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.
|
||||
@@ -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<T>`
|
||||
- `AtLeastOneOf<T>`
|
||||
- `Modify<T, R>`
|
||||
- `RemoveReadonly<T>`
|
||||
- `MaybeArray<T>`
|
||||
- `Inverse<T>`
|
||||
- `inferSearchParamsFromSchema<TSchema>`
|
||||
|
||||
## 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.
|
||||
@@ -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/<slug>/index.mdx` and `index.ts` |
|
||||
| New widget | `apps/docs/docs/widgets/<slug>/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 "<term>" apps/docs/docs apps/docs/src
|
||||
```
|
||||
|
||||
## Follow existing typed patterns
|
||||
|
||||
For an integration:
|
||||
|
||||
- Add or update `apps/docs/docs/integrations/<slug>/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/<slug>/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.
|
||||
@@ -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."
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
@@ -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/<transport>`, use a scoped API key and avoid printing credentials or secret-bearing results.
|
||||
@@ -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."
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
@@ -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 10.34.1, Node >= 24.16.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/<slug>/index.mdx` + `index.ts`
|
||||
- Widget docs: `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
|
||||
- Strict link checking: broken links/anchors cause build failure
|
||||
@@ -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/<slug>/index.mdx` + `index.ts` |
|
||||
| New widget | `apps/docs/docs/widgets/<slug>/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/<slug>/`:
|
||||
|
||||
- `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/<slug>/`:
|
||||
|
||||
- `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
|
||||
@@ -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<MyRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
size: 200,
|
||||
enableSorting: true,
|
||||
Cell: ({ row }) => <Text>{row.original.name}</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
// AFTER (mantine-datatable)
|
||||
const columns: DataTableColumn<MyRow>[] = [
|
||||
{
|
||||
accessor: "name", // not accessorKey
|
||||
title: "Name", // not header
|
||||
width: 200, // not size
|
||||
sortable: true, // not enableSorting
|
||||
render: (record) => <Text>{record.name}</Text>, // 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 <MantineReactTable table={table} />;
|
||||
|
||||
// AFTER
|
||||
return (
|
||||
<DataTable
|
||||
records={data}
|
||||
columns={columns}
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
...
|
||||
/>
|
||||
);
|
||||
```
|
||||
|
||||
### Sorting is manual
|
||||
|
||||
mantine-datatable does NOT sort for you. You must sort the data yourself:
|
||||
|
||||
```typescript
|
||||
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<MyRow>>({
|
||||
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
|
||||
<DataTable records={sortedData} sortStatus={sortStatus} onSortStatusChange={setSortStatus} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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<Record<string, (ctx: ColumnContext) => 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<MyRow>({ 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<string, number> = {};
|
||||
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<ContextMenuState | null>(null);
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
({ record, event }: { record: MyRow; event: React.MouseEvent }) => {
|
||||
event.preventDefault();
|
||||
setContextMenu({ x: event.clientX, y: event.clientY, item: record });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// On DataTable:
|
||||
<DataTable onRowContextMenu={handleContextMenu} />
|
||||
|
||||
// Render:
|
||||
{contextMenu && <RowContextMenu state={contextMenu} onClose={closeContextMenu} />}
|
||||
```
|
||||
|
||||
The menu component uses `Portal` for correct z-index:
|
||||
|
||||
```tsx
|
||||
function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: () => void }) {
|
||||
return (
|
||||
<Portal>
|
||||
<Menu opened onClose={onClose} closeOnItemClick={false} position="right-start" offset={0}>
|
||||
<Menu.Target>
|
||||
<Box style={{ position: "fixed", left: state.x, top: state.y, width: 0, height: 0 }} />
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => { /* action */ onClose(); }}>Action</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**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
|
||||
<DataTable
|
||||
rowExpansion={{
|
||||
trigger: "click",
|
||||
allowMultiple: true,
|
||||
collapseProps: {
|
||||
transitionDuration: 200,
|
||||
animateOpacity: true,
|
||||
transitionTimingFunction: "ease-out",
|
||||
},
|
||||
content: ({ record, collapse }) => <ExpandedRow item={record} collapse={collapse} />,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
**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 `<Badge variant="dot" />` without children — it renders a pill with empty space. Use a plain `Box`:
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
w={8}
|
||||
h={8}
|
||||
style={{
|
||||
borderRadius: "50%",
|
||||
backgroundColor: `var(--mantine-color-${color}-filled)`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### 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
|
||||
<Tooltip position="bottom-start" ...>
|
||||
<Group style={{ overflow: "hidden" }}>
|
||||
...
|
||||
</Group>
|
||||
</Tooltip>
|
||||
```
|
||||
|
||||
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<T>(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;
|
||||
|
||||
<DataTable style={{ pointerEvents: tablePointerEvents }} />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 12. DataTable Props Reference (Commonly Used)
|
||||
|
||||
```typescript
|
||||
<DataTable
|
||||
records={sortedData}
|
||||
columns={effectiveColumns}
|
||||
storeColumnsKey={storeKey}
|
||||
withTableBorder={false}
|
||||
borderRadius={0}
|
||||
highlightOnHover
|
||||
striped="odd"
|
||||
stripedColor={{ dark: "dark.7", light: "gray.0" }}
|
||||
highlightOnHoverColor={{ dark: "dark.5", light: "gray.1" }}
|
||||
verticalAlign="center"
|
||||
fetching={isFetching && data.length === 0}
|
||||
loaderBackgroundBlur={2}
|
||||
fz={size.fontSize}
|
||||
textSelectionDisabled
|
||||
sortStatus={sortStatus}
|
||||
onSortStatusChange={setSortStatus}
|
||||
noRecordsText={t("errors.noItems")}
|
||||
idAccessor={(record) => 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
|
||||
@@ -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`
|
||||
@@ -33,6 +33,10 @@ body:
|
||||
options:
|
||||
# The below comment is used to insert a new version with on-release.yml
|
||||
#NEXT_VERSION#
|
||||
- 1.76.2
|
||||
- 1.76.1
|
||||
- 1.76.0
|
||||
- 1.75.0
|
||||
- 1.74.0
|
||||
- 1.73.0
|
||||
- 1.72.0
|
||||
|
||||
@@ -20,7 +20,7 @@ runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Prebuilt debian dependencies
|
||||
uses: homarr-labs/homarr/.github/actions/prebuilt-debian@dev
|
||||
uses: ./.github/actions/prebuilt-debian
|
||||
id: prebuilt-debian
|
||||
with:
|
||||
architecture: ${{ inputs.architecture }}
|
||||
|
||||
@@ -11,9 +11,6 @@ outputs:
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Formatting
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
branches: ["*"]
|
||||
branches: ["**"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -436,7 +436,7 @@ jobs:
|
||||
const targetTags = target.metadata?.container?.tags ?? [];
|
||||
const extraTags = targetTags.filter((targetTag) => targetTag !== tag);
|
||||
if (extraTags.length > 0) {
|
||||
core.setFailed(
|
||||
core.warning(
|
||||
`Package version ${target.id} also carries tag(s) ${extraTags.map((targetTag) => `"${targetTag}"`).join(", ")}; refusing cleanup.`,
|
||||
);
|
||||
return;
|
||||
|
||||
@@ -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
|
||||
@@ -38,10 +38,10 @@ jobs:
|
||||
SKIP_RELEASE: ${{ github.event_name == 'workflow_dispatch' || github.ref_name == 'dev' || github.ref_name == 'next' }}
|
||||
outputs:
|
||||
version: ${{ steps.read-semver.outputs.version || steps.version-fallback.outputs.version }}
|
||||
git_ref: ${{ steps.read-git-ref.outputs.ref || github.ref }}
|
||||
git_ref: ${{ steps.read-git-ref.outputs.ref || steps.read-semver.outputs.version || 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"
|
||||
@@ -58,8 +58,9 @@ jobs:
|
||||
permission-contents: write # required to commit package.json & changelog changes, merge them to dev / next and publish the release
|
||||
|
||||
- uses: actions/checkout@v7
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
if: env.SKIP_RELEASE == 'false' || github.ref_name == 'main' || github.ref_name == 'beta'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@v6
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
@@ -68,12 +69,29 @@ jobs:
|
||||
with:
|
||||
node-version: 24.18.0
|
||||
package-manager-cache: false
|
||||
- run: npm i -g pnpm
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
- name: Install dependencies
|
||||
- name: Verify major release intent
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
run: |
|
||||
pnpm install
|
||||
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
|
||||
- name: Run Semantic Release
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
env:
|
||||
@@ -205,9 +223,14 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: needs.release.outputs.skipped == 'false'
|
||||
with:
|
||||
ref: ${{ needs.release.outputs.git_ref }}
|
||||
persist-credentials: false
|
||||
- name: Extract amd64
|
||||
if: needs.release.outputs.skipped == 'false'
|
||||
uses: homarr-labs/homarr/.github/actions/extract-build-artifact@dev
|
||||
uses: ./.github/actions/extract-build-artifact
|
||||
with:
|
||||
digest: "${{ env.GHCR_REPO }}@${{ needs.build-amd64.outputs.digest }}"
|
||||
architecture: amd64
|
||||
@@ -221,9 +244,14 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
if: needs.release.outputs.skipped == 'false'
|
||||
with:
|
||||
ref: ${{ needs.release.outputs.git_ref }}
|
||||
persist-credentials: false
|
||||
- name: Extract arm64
|
||||
if: needs.release.outputs.skipped == 'false'
|
||||
uses: homarr-labs/homarr/.github/actions/extract-build-artifact@dev
|
||||
uses: ./.github/actions/extract-build-artifact
|
||||
with:
|
||||
digest: "${{ env.GHCR_REPO }}@${{ needs.build-arm64.outputs.digest }}"
|
||||
architecture: arm64
|
||||
@@ -284,6 +312,7 @@ jobs:
|
||||
gh release edit --repo ${{ github.repository }} ${{ needs.release.outputs.version }} --draft=false
|
||||
|
||||
- name: Discord notification
|
||||
if: github.event_name != 'workflow_dispatch' || inputs['send-notifications']
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
|
||||
@@ -5,7 +5,7 @@ permissions:
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["*"]
|
||||
branches: ["**"]
|
||||
paths: [".github/actions/prebuilt-debian/**", "deployments/prebuilt-debian/**"]
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -5,7 +5,7 @@ permissions:
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["*"]
|
||||
branches: ["**"]
|
||||
paths: [".github/renovate.json5"]
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -15,9 +15,6 @@ jobs:
|
||||
update-contributors:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [22]
|
||||
steps:
|
||||
- name: Obtain token
|
||||
id: obtainToken
|
||||
@@ -34,10 +31,10 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ steps.obtainToken.outputs.token }}
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
node-version: 24.18.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Run update script
|
||||
|
||||
@@ -8,6 +8,10 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.ref_name == 'v2' || github.ref_name == 'release/v2') && 'v2-preview' || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -102,6 +106,19 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Verify branch head before publication
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const branchName = context.ref.replace("refs/heads/", "");
|
||||
const { data: branch } = await github.rest.repos.getBranch({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
branch: branchName,
|
||||
});
|
||||
if (branch.commit.sha !== context.sha) {
|
||||
core.setFailed(`Branch ${branchName} moved from ${context.sha} to ${branch.commit.sha}.`);
|
||||
}
|
||||
- name: Publish multi-platform image
|
||||
env:
|
||||
AMD64_DIGEST: ${{ needs.publish-amd64.outputs.digest }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"mantine": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@mantine/mcp-server@9.5.2"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,22 +7,23 @@ 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
|
||||
│ ├── db/ # Drizzle schema (3 DB drivers), migrations, queries
|
||||
│ ├── core/ # Env validation, DB/Redis driver factories, logging
|
||||
│ ├── definitions/ # Domain enums: WidgetKind, IntegrationKind, permissions
|
||||
│ ├── widgets/ # All 39 dashboard widgets (definitions + components)
|
||||
│ ├── widgets/ # Dashboard widget definitions and components
|
||||
│ ├── 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/ # Cron job implementations
|
||||
│ ├── cron-jobs-core/ # Cron scheduling primitives
|
||||
│ ├── cron-job-status/ # Cron status via Redis
|
||||
│ ├── boards/ # Board context, edit mode, cache updater
|
||||
@@ -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/<slug>/index.mdx` + `index.ts`
|
||||
- New widget → `apps/docs/docs/widgets/<slug>/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,19 @@ 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 <name>` from a Homarr checkout to build `homarr:<name>` with rebuild provenance.
|
||||
- Run `pnpm dev:cli -- build --pr <number>` 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
|
||||
- `homarr-custom-widget` — safe Custom JSX v2 authoring
|
||||
@@ -1,3 +1,11 @@
|
||||
## [1.76.2](https://github.com/homarr-labs/homarr/compare/v1.76.1...v1.76.2) (2026-08-27)
|
||||
|
||||
## [1.76.1](https://github.com/homarr-labs/homarr/compare/v1.76.0...v1.76.1) (2026-08-26)
|
||||
|
||||
## [1.76.0](https://github.com/homarr-labs/homarr/compare/v1.75.0...v1.76.0) (2026-08-21)
|
||||
|
||||
## [1.75.0](https://github.com/homarr-labs/homarr/compare/v1.74.0...v1.75.0) (2026-08-14)
|
||||
|
||||
## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07)
|
||||
|
||||
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
|
||||
|
||||
-475
@@ -1,475 +0,0 @@
|
||||
# Mantine UI - LLM Documentation
|
||||
|
||||
This index lists Mantine documentation pages formatted for LLMs.
|
||||
Each link points to a standalone Markdown file under the /llms path.
|
||||
|
||||
For a single consolidated file with all content, use:
|
||||
|
||||
- https://mantine.dev/llms-full.txt
|
||||
|
||||
## FAQ
|
||||
|
||||
- [Are Mantine components accessible?](https://mantine.dev/llms/q-are-mantine-components-accessible.md): Learn about Mantine components accessibility features
|
||||
- [Browser zooms in when input is focused. What should I do?](https://mantine.dev/llms/q-browser-zooms-on-focus.md): Use meta tag to disable browser scaling or increase input size
|
||||
- [Can I have color schemes other than light and dark?](https://mantine.dev/llms/q-light-dark-is-not-enough.md): Learn about the difference between color scheme and theme
|
||||
- [Can I have different primary color for light and dark color schemes?](https://mantine.dev/llms/q-primary-virtual-color.md): Learn how to use virtual color with primary color in theme object
|
||||
- [Can I remove MultiSelect placeholder when the component has selected values?](https://mantine.dev/llms/q-multiselect-value-placeholder.md): Learn why MultiSelect placeholder is not removed when values are selected and how to remove it with CSS
|
||||
- [Can I use an array of strings as a list in use-form?](https://mantine.dev/llms/q-list-of-strings-in-use-form.md): Learn about use-form lists limitations
|
||||
- [Can I use Mantine components as server components?](https://mantine.dev/llms/q-server-components.md): Learn about use client directive and server components usage
|
||||
- [Can I use Mantine with Astro?](https://mantine.dev/llms/q-can-i-use-mantine-with-astro.md): No, Astro does not support React context
|
||||
- [Can I use Mantine with Create React App (CRA)?](https://mantine.dev/llms/q-can-i-use-mantine-with-cra.md): Learn how to use Mantine without postcss-preset-mantine or how to eject CRA and customize webpack.config.js and use postcss-preset-mantine
|
||||
- [Can I use Mantine with Emotion/styled-components/tailwindcss?](https://mantine.dev/llms/q-third-party-styles.md): Learn about limitations of third-party styles
|
||||
- [Can I use Mantine with Vue/Svelte/Angular/etc.?](https://mantine.dev/llms/q-vue-svelte-angular.md): No, Mantine is a React library and does not support other frameworks/libraries
|
||||
- [Can I use nested inline styles with Mantine components?](https://mantine.dev/llms/q-nested-inline-styles.md): Nested styles are supported only in CSS files
|
||||
- [Can I use PostCSS function in inline styles?](https://mantine.dev/llms/q-postcss-fns-inline.md): Learn where PostCSS functions can be used in Mantine
|
||||
- [Can I use private CSS variables to style components?](https://mantine.dev/llms/q-private-css-variables.md): No, it is not safe and will not work with future versions of Mantine.
|
||||
- [Can I use SegmentedControl with empty value?](https://mantine.dev/llms/q-segmented-control-no-value.md): SegmentedControl cannot be used without a value
|
||||
- [How can I add dynamic CSS styles?](https://mantine.dev/llms/q-dynamic-css-styles.md): Use data attributes, CSS variables or inline styles
|
||||
- [How can I add fuzzy search to Select component?](https://mantine.dev/llms/q-select-fuzzy.md): Learn how to integrate third-party fuzzy search libraries with Mantine Select component
|
||||
- [How can I add hover styles to an element?](https://mantine.dev/llms/q-how-to-add-hover-styles.md): Learn how to add hover classes to an element with CSS modules, &:hover or @mixin hover
|
||||
- [How can I apply styles to all Mantine components?](https://mantine.dev/llms/q-apply-styles-to-all.md): Learn how to use attribute selector to apply styles to all Mantine components
|
||||
- [How can I change body background color?](https://mantine.dev/llms/q-body-background.md): Use CSS to change body background color
|
||||
- [How can I change component color prop value depending on the color scheme?](https://mantine.dev/llms/q-color-scheme-color.md): Learn how to use CSS variables resolver to change color value depending on the color scheme
|
||||
- [How can I change input focus styles?](https://mantine.dev/llms/q-input-focus-styles.md): Learn how to use Styles API with Mantine inputs
|
||||
- [How can I change Tabs border color?](https://mantine.dev/llms/q-tabs-border-color.md): Learn how to use Styles API with Tabs component
|
||||
- [How can I contribute to the library?](https://mantine.dev/llms/q-how-can-i-contribute.md): There are multiple ways to contribute even without writing code
|
||||
- [How can I disable all inputs/input groups inside a form?](https://mantine.dev/llms/q-disable-all-inputs-in-form.md): Learn how to disable all inputs/input groups inside a form with the unstyled Fieldset component
|
||||
- [How can I display different elements in light and dark color schemes?](https://mantine.dev/llms/q-light-dark-elements.md): Learn how to hide/show elements based on color scheme
|
||||
- [How can I focus the first input with error with use-form?](https://mantine.dev/llms/q-focus-first-input-with-error.md): Learn how to handle focus with use-form hook
|
||||
- [How can I get current color scheme value in JavaScript?](https://mantine.dev/llms/q-how-to-get-color-scheme-value-in-js.md): How to use useMantineColorScheme and useComputedColorScheme hooks to get current color scheme value in JavaScript
|
||||
- [How can I lint CSS files?](https://mantine.dev/llms/q-how-to-setup-stylelint.md): Learn how to set up Stylelint for Mantine postcss syntax
|
||||
- [How can I load fonts in Next.js?](https://mantine.dev/llms/q-next-load-fonts.md): A guide to load custom fonts in Next.js with CSS and next/font package
|
||||
- [How can I load fonts in React Router?](https://mantine.dev/llms/q-react-router-load-fonts.md): A guide to load custom fonts in React Router
|
||||
- [How can I load fonts in Vite?](https://mantine.dev/llms/q-vite-load-fonts.md): A guide to load custom fonts in Vite
|
||||
- [How can I lock scroll in my application?](https://mantine.dev/llms/q-how-to-lock-scroll.md): Use react-remove-scroll library to lock scroll in your application
|
||||
- [How can I submit a template to Mantine documentation?](https://mantine.dev/llms/q-submit-template.md): Learn how to create and submit a template to Mantine documentation
|
||||
- [How can I test Modal/Drawer/Popover components?](https://mantine.dev/llms/q-portals-testing.md): Learn how to use react-testing-library to test components that use portals and transitions.
|
||||
- [How can I test Select/MultiSelect components?](https://mantine.dev/llms/q-combobox-testing.md): Learn how to use react-testing-library to test Select and MultiSelect components.
|
||||
- [How can I upload files from Dropzone component?](https://mantine.dev/llms/q-dropzone-upload.md): Learn how to process files dropped into Dropzone component
|
||||
- [How is that thing done on the mantine.dev website?](https://mantine.dev/llms/q-how-that-thing-is-done.md): Learn how various elements are implemented on the mantine.dev website
|
||||
- [How to align input with a button in a flex container?](https://mantine.dev/llms/q-align-input-button.md): Learn how to align Mantine inputs with buttons using Group component
|
||||
- [How to call a function when Modal/Drawer closes and animation completes?](https://mantine.dev/llms/q-how-to-call-function-when-modal-closes.md): How to use transitionProps in Modal/Drawer components
|
||||
- [How to change background color for FiveM?](https://mantine.dev/llms/q-fivem-background-color.md): Unset body background color and color-scheme styles for FiveM
|
||||
- [How to change input placeholder color?](https://mantine.dev/llms/q-inputs-placeholder-color.md): Learn how to change placeholder color with Styles API
|
||||
- [How to integrate custom input with use-form hook?](https://mantine.dev/llms/q-custom-input-use-form.md): Learn how to add use-form support for custom inputs
|
||||
- [How to prevent Modal from closing?](https://mantine.dev/llms/q-how-to-prevent-modal-from-closing.md): Learn how to prevent Modal from closing when the user clicks outside of it or presses the Escape key
|
||||
- [How to scroll to the top of the form if the form is submitted with errors?](https://mantine.dev/llms/q-scroll-to-the-top-of-the-form.md): Learn how to call a function when the form is submitted with errors
|
||||
- [How to update Mantine dependencies?](https://mantine.dev/llms/q-how-to-update-dependencies.md): Learn how to update `@mantine/*`, `@mantinex/*` and `postcss-preset-mantine` dependencies manually or with ncu script
|
||||
- [How to use Dropzone with @mantine/form?](https://mantine.dev/llms/q-how-to-use-dropzone-with-form.md): Learn how to use Dropzone with @mantine/form to handle file selection state
|
||||
- [How to use Mantine template on GitHub?](https://mantine.dev/llms/q-templates-usage.md): Learn how to create a new repository based on Mantine template
|
||||
- [I get hydration warning about data-mantine-color-scheme attribute, what does it mean?](https://mantine.dev/llms/q-color-scheme-hydration-warning.md): Learn how hydration works and how to fix warnings
|
||||
- [Is there a comparison with other libraries?](https://mantine.dev/llms/q-other-libs.md): Comparison with other libraries is not provided, you are trusted to make your own decision based on documentation examples
|
||||
- [Is there a floating action button component?](https://mantine.dev/llms/q-floating-action-button.md): No, but you can build it with Affix and ActionIcon components
|
||||
- [Is there DataGrid component that I can use with Mantine?](https://mantine.dev/llms/q-data-grid-i-need.md): A list of community packages that provide DataGrid component that can be used with Mantine.
|
||||
- [It is not possible to pinch to zoom when a Modal is opened. What should I do?](https://mantine.dev/llms/q-pinch-to-zoom-modal.md): Use removeScrollProps to configure react-remove-scroll options
|
||||
- [MantineProvider was not found in component tree. What should I do?](https://mantine.dev/llms/q-mantine-provider-missing.md): Learn how to resolve MantineProvider issues
|
||||
- [My buttons are transparent and the background is visible only on hover, what is wrong?](https://mantine.dev/llms/q-transparent-buttons.md): You have installed a third-party library that overrides Mantine styles
|
||||
- [My Popover dropdown closes when I click on the dropdown of a nested Popover](https://mantine.dev/llms/q-nested-popover-closes.md): Popover dropdown is closed when it detects click-outside events
|
||||
- [My styles are broken with disabled JavaScript. What should I do?](https://mantine.dev/llms/q-disabled-js.md): Learn how to use Mantine without JavaScript enabled
|
||||
- [My styles are overridden by Mantine components styles, what should I do?](https://mantine.dev/llms/q-styles-order.md): Learn how to use CSS layers to control styles order and prevent Mantine components from overriding your styles
|
||||
- [Native browser validation does not work in some components, what should I do?](https://mantine.dev/llms/q-native-required.md): Learn why native browser validation does not work in some components
|
||||
- [The use-local-storage hook returns the real value only after mounting. Is it a bug?](https://mantine.dev/llms/q-local-storage-effect.md): Learn how Mantine retrieves local storage value
|
||||
- [What is the difference between searchable Select and Autocomplete?](https://mantine.dev/llms/q-select-autocomplete-difference.md): Searchable Select and Autocomplete are similar components, but they serve different purposes.
|
||||
- [Where can I find the roadmap?](https://mantine.dev/llms/q-roadmap.md): The roadmap is private and only available to the team.
|
||||
- [Why are my Carousel slides in vertical orientation?](https://mantine.dev/llms/q-carousel-missing-styles.md): You forgot to import carousel styles
|
||||
- [Why are my notifications displayed at the wrong position?](https://mantine.dev/llms/q-notifications-missing-styles.md): Because you did not import styles
|
||||
- [Why can I not use one polymorphic component in the component prop of another polymorphic component?](https://mantine.dev/llms/q-polymorphic-in-polymorphic.md): Learn how polymorphic components types work
|
||||
- [Why can I not use value/label data structure with Autocomplete/TagsInput?](https://mantine.dev/llms/q-autocomplete-value-label.md): Learn the difference between Autocomplete and Select components
|
||||
- [Why can VSCode not autoimport the Text component?](https://mantine.dev/llms/q-why-vscode-cannot-autoimport-text.md): It is confused by the native Text constructor
|
||||
- [Why do I see color scheme flickering on page load?](https://mantine.dev/llms/q-color-scheme-flickering.md): Color scheme flickering is caused by incorrect usage of ColorSchemeScript
|
||||
- [Why do my dates components look broken?](https://mantine.dev/llms/q-dates-missing-styles.md): Because you did not import styles
|
||||
- [Why does FileButton not work in Menu?](https://mantine.dev/llms/q-file-button-in-menu.md): Learn how to use FileButton in Menu component
|
||||
- [Why is it required to have 10 shades per color?](https://mantine.dev/llms/q-ten-shades-per-color.md): Learn how Mantine uses theme colors
|
||||
- [Why is my screen completely empty after I](https://mantine.dev/llms/q-notifications-empty-screen.md): You have used Notifications component incorrectly
|
||||
|
||||
## Core
|
||||
|
||||
- [Accordion](https://mantine.dev/llms/core-accordion.md): Divide content into collapsible sections
|
||||
- [ActionIcon](https://mantine.dev/llms/core-action-icon.md): Icon button
|
||||
- [Affix](https://mantine.dev/llms/core-affix.md): Renders children inside portal at fixed position
|
||||
- [Alert](https://mantine.dev/llms/core-alert.md): Attract user attention with important static message
|
||||
- [AlphaSlider](https://mantine.dev/llms/core-alpha-slider.md): Slider component for selecting alpha channel in color pickers (0 – 1)
|
||||
- [Anchor](https://mantine.dev/llms/core-anchor.md): Display link with theme styles
|
||||
- [AngleSlider](https://mantine.dev/llms/core-angle-slider.md): Pick angle value between 0 and 360
|
||||
- [AppShell](https://mantine.dev/llms/core-app-shell.md): Responsive shell for your application with header, navbar, aside and footer
|
||||
- [AspectRatio](https://mantine.dev/llms/core-aspect-ratio.md): Maintain responsive consistent width/height ratio
|
||||
- [Autocomplete](https://mantine.dev/llms/core-autocomplete.md): Autocomplete user input with any list of options
|
||||
- [Avatar](https://mantine.dev/llms/core-avatar.md): Display user profile image, initials or fallback icon
|
||||
- [BackgroundImage](https://mantine.dev/llms/core-background-image.md): Displays image as background
|
||||
- [Badge](https://mantine.dev/llms/core-badge.md): Display badge, pill or tag
|
||||
- [Blockquote](https://mantine.dev/llms/core-blockquote.md): Blockquote with optional cite
|
||||
- [Box](https://mantine.dev/llms/core-box.md): Base component for all Mantine components
|
||||
- [Breadcrumbs](https://mantine.dev/llms/core-breadcrumbs.md): Separates list of react nodes with given separator
|
||||
- [Burger](https://mantine.dev/llms/core-burger.md): Open/close navigation button
|
||||
- [Button](https://mantine.dev/llms/core-button.md): Button component to render button or link
|
||||
- [Card](https://mantine.dev/llms/core-card.md): Card with sections
|
||||
- [Cascader](https://mantine.dev/llms/core-cascader.md): Select a path from hierarchical data with cascading columns
|
||||
- [Center](https://mantine.dev/llms/core-center.md): Centers content vertically and horizontally
|
||||
- [Checkbox](https://mantine.dev/llms/core-checkbox.md): Capture boolean input from user
|
||||
- [Chip](https://mantine.dev/llms/core-chip.md): Pick one or multiple values with inline controls
|
||||
- [CloseButton](https://mantine.dev/llms/core-close-button.md): Button with close icon
|
||||
- [Code](https://mantine.dev/llms/core-code.md): Inline and block code
|
||||
- [Collapse](https://mantine.dev/llms/core-collapse.md): Animate presence with slide down/up transition
|
||||
- [ColorInput](https://mantine.dev/llms/core-color-input.md): Capture color from user
|
||||
- [ColorPicker](https://mantine.dev/llms/core-color-picker.md): Pick colors in hex(a), rgb(a), hsl(a) and hsv(a) formats
|
||||
- [ColorSwatch](https://mantine.dev/llms/core-color-swatch.md): Displays color
|
||||
- [Combobox](https://mantine.dev/llms/core-combobox.md): Create custom select, autocomplete or multiselect inputs
|
||||
- [ComboboxPopover](https://mantine.dev/llms/core-combobox-popover.md): Add combobox dropdown to any button element
|
||||
- [Container](https://mantine.dev/llms/core-container.md): Center content with padding and max-width
|
||||
- [CopyButton](https://mantine.dev/llms/core-copy-button.md): Copies given text to clipboard
|
||||
- [CorePackage](https://mantine.dev/llms/core-package.md)
|
||||
- [DataList](https://mantine.dev/llms/core-data-list.md): Display label-value pairs as a description list
|
||||
- [Dialog](https://mantine.dev/llms/core-dialog.md): Display a fixed overlay dialog at any side of the screen
|
||||
- [Divider](https://mantine.dev/llms/core-divider.md): Horizontal line with optional label or vertical divider
|
||||
- [Drawer](https://mantine.dev/llms/core-drawer.md): Display overlay area at any side of the screen
|
||||
- [EmptyState](https://mantine.dev/llms/core-empty-state.md): Display a placeholder for no data, empty results and first-run states
|
||||
- [Fieldset](https://mantine.dev/llms/core-fieldset.md): Group related elements in a form
|
||||
- [FileButton](https://mantine.dev/llms/core-file-button.md): Open file picker with a button click
|
||||
- [FileInput](https://mantine.dev/llms/core-file-input.md): Capture files from user
|
||||
- [Flex](https://mantine.dev/llms/core-flex.md): Compose elements in a flex container
|
||||
- [FloatingIndicator](https://mantine.dev/llms/core-floating-indicator.md): Display a floating indicator over a group of elements
|
||||
- [FloatingWindow](https://mantine.dev/llms/core-floating-window.md): Draggable floating area
|
||||
- [FocusTrap](https://mantine.dev/llms/core-focus-trap.md): Trap focus at child node
|
||||
- [Grid](https://mantine.dev/llms/core-grid.md): Responsive 12 columns grid system
|
||||
- [Group](https://mantine.dev/llms/core-group.md): Compose elements and components in a horizontal flex container
|
||||
- [Highlight](https://mantine.dev/llms/core-highlight.md): Highlight given part of a string with mark
|
||||
- [HoverCard](https://mantine.dev/llms/core-hover-card.md): Display popover section when target element is hovered
|
||||
- [HueSlider](https://mantine.dev/llms/core-hue-slider.md): Slider component for selecting hue channel in color pickers (0 – 360 degrees)
|
||||
- [Image](https://mantine.dev/llms/core-image.md): Image with optional fallback
|
||||
- [Indicator](https://mantine.dev/llms/core-indicator.md): Display element at the corner of another element
|
||||
- [Input](https://mantine.dev/llms/core-input.md): Base component to create custom inputs
|
||||
- [JsonInput](https://mantine.dev/llms/core-json-input.md): Capture json data from user
|
||||
- [Kbd](https://mantine.dev/llms/core-kbd.md): Display keyboard key
|
||||
- [List](https://mantine.dev/llms/core-list.md): Display ordered or unordered list
|
||||
- [Loader](https://mantine.dev/llms/core-loader.md): Indicate loading state
|
||||
- [LoadingOverlay](https://mantine.dev/llms/core-loading-overlay.md): An overlay with centered loader
|
||||
- [Mark](https://mantine.dev/llms/core-mark.md): Highlight part of the text
|
||||
- [Marquee](https://mantine.dev/llms/core-marquee.md): Create continuous scrolling animation for content
|
||||
- [MaskInput](https://mantine.dev/llms/core-mask-input.md): Input with mask pattern for formatted text entry
|
||||
- [Menu](https://mantine.dev/llms/core-menu.md): Combine a list of secondary actions into single interactive area
|
||||
- [Menubar](https://mantine.dev/llms/core-menubar.md): Desktop application style menubar with a row of menu triggers
|
||||
- [Modal](https://mantine.dev/llms/core-modal.md): An accessible overlay dialog
|
||||
- [MultiSelect](https://mantine.dev/llms/core-multi-select.md): Custom searchable multi select
|
||||
- [NativeSelect](https://mantine.dev/llms/core-native-select.md): Native select element based on Input
|
||||
- [NavLink](https://mantine.dev/llms/core-nav-link.md): Navigation link
|
||||
- [Notification](https://mantine.dev/llms/core-notification.md): Show dynamic notifications and alerts to user, part of notifications system
|
||||
- [NumberFormatter](https://mantine.dev/llms/core-number-formatter.md): Format number with thousands/decimal separators and suffix/prefix
|
||||
- [NumberInput](https://mantine.dev/llms/core-number-input.md): Capture number from user
|
||||
- [OverflowList](https://mantine.dev/llms/core-overflow-list.md): Hide items that do not fit in a container and display them as a single collapsed item
|
||||
- [Overlay](https://mantine.dev/llms/core-overlay.md): Overlays parent element with div element with any color and opacity
|
||||
- [Pagination](https://mantine.dev/llms/core-pagination.md): Display active page and navigate between multiple pages
|
||||
- [Paper](https://mantine.dev/llms/core-paper.md): Renders white or dark background depending on color scheme
|
||||
- [PasswordInput](https://mantine.dev/llms/core-password-input.md): Capture password data from user
|
||||
- [Pill](https://mantine.dev/llms/core-pill.md): Removable and non-removable tags
|
||||
- [PillsInput](https://mantine.dev/llms/core-pills-input.md): Base component for custom tags inputs and multi selects
|
||||
- [PinInput](https://mantine.dev/llms/core-pin-input.md): Capture pin code or one time password from the user
|
||||
- [Popover](https://mantine.dev/llms/core-popover.md): Display popover section relative to given target element
|
||||
- [Portal](https://mantine.dev/llms/core-portal.md): Renders component outside of parent element tree
|
||||
- [Progress](https://mantine.dev/llms/core-progress.md): Give user feedback for status of the task
|
||||
- [Radio](https://mantine.dev/llms/core-radio.md): Wrapper for input type radio
|
||||
- [RangeSlider](https://mantine.dev/llms/core-range-slider.md): RangeSlider component
|
||||
- [Rating](https://mantine.dev/llms/core-rating.md): Pick and display rating
|
||||
- [RingProgress](https://mantine.dev/llms/core-ring-progress.md): Give user feedback for status of the task with circle diagram
|
||||
- [RollingNumber](https://mantine.dev/llms/core-rolling-number.md): Animate value changes with rolling digits
|
||||
- [ScrollArea](https://mantine.dev/llms/core-scroll-area.md): Area with custom scrollbars
|
||||
- [Scroller](https://mantine.dev/llms/core-scroller.md): Horizontal scroll container with navigation controls
|
||||
- [SegmentedControl](https://mantine.dev/llms/core-segmented-control.md): A linear set of two or more segments
|
||||
- [Select](https://mantine.dev/llms/core-select.md): Custom searchable select
|
||||
- [SemiCircleProgress](https://mantine.dev/llms/core-semi-circle-progress.md): Represent progress with semi circle diagram
|
||||
- [SimpleGrid](https://mantine.dev/llms/core-simple-grid.md): Responsive grid in which each item takes equal amount of space
|
||||
- [Skeleton](https://mantine.dev/llms/core-skeleton.md): Indicate content loading state
|
||||
- [Slider](https://mantine.dev/llms/core-slider.md): Slider component
|
||||
- [Space](https://mantine.dev/llms/core-space.md): Add horizontal or vertical spacing from theme
|
||||
- [Splitter](https://mantine.dev/llms/core-splitter.md): Resizable split pane layout
|
||||
- [Spoiler](https://mantine.dev/llms/core-spoiler.md): Hide long sections of content under a spoiler
|
||||
- [Stack](https://mantine.dev/llms/core-stack.md): Compose elements and components in a vertical flex container
|
||||
- [Stepper](https://mantine.dev/llms/core-stepper.md): Display content divided into a steps sequence
|
||||
- [Switch](https://mantine.dev/llms/core-switch.md): Capture boolean input from user
|
||||
- [Table](https://mantine.dev/llms/core-table.md): Render table with theme styles
|
||||
- [TableOfContents](https://mantine.dev/llms/core-table-of-contents.md): Renders a list of headings on the page and tracks current heading visible in the viewport
|
||||
- [Tabs](https://mantine.dev/llms/core-tabs.md): Switch between different views
|
||||
- [TagsInput](https://mantine.dev/llms/core-tags-input.md): Capture a list of values from user with free input and suggestions
|
||||
- [Text](https://mantine.dev/llms/core-text.md): Display text
|
||||
- [Textarea](https://mantine.dev/llms/core-textarea.md): Autosize or regular textarea
|
||||
- [TextInput](https://mantine.dev/llms/core-text-input.md): Capture string input from user
|
||||
- [ThemeIcon](https://mantine.dev/llms/core-theme-icon.md): Render icon inside element with theme colors
|
||||
- [Timeline](https://mantine.dev/llms/core-timeline.md): Display list of events in chronological order
|
||||
- [Title](https://mantine.dev/llms/core-title.md): h1-h6 heading
|
||||
- [Tooltip](https://mantine.dev/llms/core-tooltip.md): Renders tooltip at given element on mouse over or other event
|
||||
- [Transition](https://mantine.dev/llms/core-transition.md): Animate presence of component with pre-made animations
|
||||
- [Tree](https://mantine.dev/llms/core-tree.md): Display a Tree structure
|
||||
- [TreeSelect](https://mantine.dev/llms/core-tree-select.md): Select with hierarchical tree data
|
||||
- [Typography](https://mantine.dev/llms/core-typography.md): Styles provider for html content
|
||||
- [UnstyledButton](https://mantine.dev/llms/core-unstyled-button.md): Unstyled polymorphic button
|
||||
- [VisuallyHidden](https://mantine.dev/llms/core-visually-hidden.md): Hide element visually but keep it accessible for screen readers
|
||||
|
||||
## Hooks
|
||||
|
||||
- [HooksPackage](https://mantine.dev/llms/hooks-package.md)
|
||||
- [use-element-size](https://mantine.dev/llms/hooks-use-element-size.md): Returns element width and height and observes changes with ResizeObserver
|
||||
- [useClickOutside](https://mantine.dev/llms/hooks-use-click-outside.md)
|
||||
- [useClipboard](https://mantine.dev/llms/hooks-use-clipboard.md)
|
||||
- [useCollapse](https://mantine.dev/llms/hooks-use-collapse.md)
|
||||
- [useColorScheme](https://mantine.dev/llms/hooks-use-color-scheme.md)
|
||||
- [useCounter](https://mantine.dev/llms/hooks-use-counter.md)
|
||||
- [useDebouncedCallback](https://mantine.dev/llms/hooks-use-debounced-callback.md)
|
||||
- [useDebouncedState](https://mantine.dev/llms/hooks-use-debounced-state.md)
|
||||
- [useDebouncedValue](https://mantine.dev/llms/hooks-use-debounced-value.md)
|
||||
- [useDidUpdate](https://mantine.dev/llms/hooks-use-did-update.md)
|
||||
- [useDisclosure](https://mantine.dev/llms/hooks-use-disclosure.md)
|
||||
- [useDocumentTitle](https://mantine.dev/llms/hooks-use-document-title.md)
|
||||
- [useDocumentVisibility](https://mantine.dev/llms/hooks-use-document-visibility.md)
|
||||
- [useDrag](https://mantine.dev/llms/hooks-use-drag.md)
|
||||
- [useEventListener](https://mantine.dev/llms/hooks-use-event-listener.md)
|
||||
- [useEyeDropper](https://mantine.dev/llms/hooks-use-eye-dropper.md)
|
||||
- [useFavicon](https://mantine.dev/llms/hooks-use-favicon.md)
|
||||
- [useFetch](https://mantine.dev/llms/hooks-use-fetch.md)
|
||||
- [useFileDialog](https://mantine.dev/llms/hooks-use-file-dialog.md)
|
||||
- [useFloatingWindow](https://mantine.dev/llms/hooks-use-floating-window.md)
|
||||
- [useFocusReturn](https://mantine.dev/llms/hooks-use-focus-return.md)
|
||||
- [useFocusTrap](https://mantine.dev/llms/hooks-use-focus-trap.md)
|
||||
- [useFocusWithin](https://mantine.dev/llms/hooks-use-focus-within.md)
|
||||
- [useForceUpdate](https://mantine.dev/llms/hooks-use-force-update.md)
|
||||
- [useFullscreen](https://mantine.dev/llms/hooks-use-fullscreen.md)
|
||||
- [useHash](https://mantine.dev/llms/hooks-use-hash.md)
|
||||
- [useHeadroom](https://mantine.dev/llms/hooks-use-headroom.md)
|
||||
- [useHotkeys](https://mantine.dev/llms/hooks-use-hotkeys.md)
|
||||
- [useHover](https://mantine.dev/llms/hooks-use-hover.md)
|
||||
- [useId](https://mantine.dev/llms/hooks-use-id.md)
|
||||
- [useIdle](https://mantine.dev/llms/hooks-use-idle.md)
|
||||
- [useInputState](https://mantine.dev/llms/hooks-use-input-state.md)
|
||||
- [useIntersection](https://mantine.dev/llms/hooks-use-intersection.md)
|
||||
- [useInterval](https://mantine.dev/llms/hooks-use-interval.md)
|
||||
- [useInViewport](https://mantine.dev/llms/hooks-use-in-viewport.md)
|
||||
- [useIsFirstRender](https://mantine.dev/llms/hooks-use-is-first-render.md)
|
||||
- [useIsomorphicEffect](https://mantine.dev/llms/hooks-use-isomorphic-effect.md)
|
||||
- [useListState](https://mantine.dev/llms/hooks-use-list-state.md)
|
||||
- [useLocalStorage](https://mantine.dev/llms/hooks-use-local-storage.md)
|
||||
- [useLogger](https://mantine.dev/llms/hooks-use-logger.md)
|
||||
- [useLongPress](https://mantine.dev/llms/hooks-use-long-press.md)
|
||||
- [useMap](https://mantine.dev/llms/hooks-use-map.md)
|
||||
- [useMask](https://mantine.dev/llms/hooks-use-mask.md)
|
||||
- [useMediaQuery](https://mantine.dev/llms/hooks-use-media-query.md)
|
||||
- [useMergedRef](https://mantine.dev/llms/hooks-use-merged-ref.md)
|
||||
- [useMounted](https://mantine.dev/llms/hooks-use-mounted.md)
|
||||
- [useMouse](https://mantine.dev/llms/hooks-use-mouse.md)
|
||||
- [useMove](https://mantine.dev/llms/hooks-use-move.md)
|
||||
- [useMutationObserver](https://mantine.dev/llms/hooks-use-mutation-observer.md)
|
||||
- [useNetwork](https://mantine.dev/llms/hooks-use-network.md)
|
||||
- [useOrientation](https://mantine.dev/llms/hooks-use-orientation.md)
|
||||
- [useOs](https://mantine.dev/llms/hooks-use-os.md)
|
||||
- [usePageLeave](https://mantine.dev/llms/hooks-use-page-leave.md)
|
||||
- [usePagination](https://mantine.dev/llms/hooks-use-pagination.md)
|
||||
- [usePrevious](https://mantine.dev/llms/hooks-use-previous.md)
|
||||
- [useQueue](https://mantine.dev/llms/hooks-use-queue.md)
|
||||
- [useRadialMove](https://mantine.dev/llms/hooks-use-radial-move.md)
|
||||
- [useReducedMotion](https://mantine.dev/llms/hooks-use-reduced-motion.md)
|
||||
- [useResizeObserver](https://mantine.dev/llms/hooks-use-resize-observer.md)
|
||||
- [useRovingIndex](https://mantine.dev/llms/hooks-use-roving-index.md)
|
||||
- [useScrollDirection](https://mantine.dev/llms/hooks-use-scroll-direction.md)
|
||||
- [useScroller](https://mantine.dev/llms/hooks-use-scroller.md)
|
||||
- [useScrollIntoView](https://mantine.dev/llms/hooks-use-scroll-into-view.md)
|
||||
- [useScrollSpy](https://mantine.dev/llms/hooks-use-scroll-spy.md)
|
||||
- [useSelection](https://mantine.dev/llms/hooks-use-selection.md)
|
||||
- [useSet](https://mantine.dev/llms/hooks-use-set.md)
|
||||
- [useSetState](https://mantine.dev/llms/hooks-use-set-state.md)
|
||||
- [useShallowEffect](https://mantine.dev/llms/hooks-use-shallow-effect.md)
|
||||
- [useSplitter](https://mantine.dev/llms/hooks-use-splitter.md)
|
||||
- [useStateHistory](https://mantine.dev/llms/hooks-use-state-history.md)
|
||||
- [useTextSelection](https://mantine.dev/llms/hooks-use-text-selection.md)
|
||||
- [useThrottledCallback](https://mantine.dev/llms/hooks-use-throttled-callback.md)
|
||||
- [useThrottledState](https://mantine.dev/llms/hooks-use-throttled-state.md)
|
||||
- [useThrottledValue](https://mantine.dev/llms/hooks-use-throttled-value.md)
|
||||
- [useTimeout](https://mantine.dev/llms/hooks-use-timeout.md)
|
||||
- [useToggle](https://mantine.dev/llms/hooks-use-toggle.md)
|
||||
- [useUncontrolled](https://mantine.dev/llms/hooks-use-uncontrolled.md)
|
||||
- [useValidatedState](https://mantine.dev/llms/hooks-use-validated-state.md)
|
||||
- [useViewportSize](https://mantine.dev/llms/hooks-use-viewport-size.md)
|
||||
- [useWindowEvent](https://mantine.dev/llms/hooks-use-window-event.md)
|
||||
- [useWindowScroll](https://mantine.dev/llms/hooks-use-window-scroll.md)
|
||||
|
||||
## Form
|
||||
|
||||
- [Form context](https://mantine.dev/llms/form-create-form-context.md): Add context support to use-form with createFormContext
|
||||
- [formActions](https://mantine.dev/llms/form-actions.md)
|
||||
- [formAllInputs](https://mantine.dev/llms/form-all-inputs.md)
|
||||
- [formErrors](https://mantine.dev/llms/form-errors.md)
|
||||
- [formGetInputProps](https://mantine.dev/llms/form-get-input-props.md)
|
||||
- [formNested](https://mantine.dev/llms/form-nested.md)
|
||||
- [FormPackage](https://mantine.dev/llms/form-package.md)
|
||||
- [formRecipes](https://mantine.dev/llms/form-recipes.md)
|
||||
- [formSchemaValidation](https://mantine.dev/llms/form-schema-validation.md)
|
||||
- [formStatus](https://mantine.dev/llms/form-status.md)
|
||||
- [formUncontrolled](https://mantine.dev/llms/form-uncontrolled.md)
|
||||
- [formValidation](https://mantine.dev/llms/form-validation.md)
|
||||
- [formValidators](https://mantine.dev/llms/form-validators.md)
|
||||
- [formValues](https://mantine.dev/llms/form-values.md)
|
||||
- [use-field](https://mantine.dev/llms/form-use-field.md): use-field hook – manage single field state
|
||||
- [use-form](https://mantine.dev/llms/form-use-form.md): Manage form state
|
||||
|
||||
## Dates
|
||||
|
||||
- [Calendar](https://mantine.dev/llms/dates-calendar.md): Base component for custom date pickers
|
||||
- [DateInput](https://mantine.dev/llms/dates-date-input.md): Free form date input
|
||||
- [DatePicker](https://mantine.dev/llms/dates-date-picker.md): Inline date, multiple dates and dates range picker
|
||||
- [DatePickerInput](https://mantine.dev/llms/dates-date-picker-input.md): Date, multiple dates and dates range picker input
|
||||
- [DateTimePicker](https://mantine.dev/llms/dates-date-time-picker.md): Capture datetime from the user
|
||||
- [GettingStartedDates](https://mantine.dev/llms/dates-getting-started.md)
|
||||
- [InlineDateTimePicker](https://mantine.dev/llms/dates-inline-date-time-picker.md): Inline date and time picker with range support
|
||||
- [MiniCalendar](https://mantine.dev/llms/dates-mini-calendar.md): Compact calendar to display a small number of days in a row
|
||||
- [MonthPicker](https://mantine.dev/llms/dates-month-picker.md): Inline month, multiple months and months range picker
|
||||
- [MonthPickerInput](https://mantine.dev/llms/dates-month-picker-input.md): Month, multiple months and months range picker input
|
||||
- [TimeGrid](https://mantine.dev/llms/dates-time-grid.md): Captures time value from the user with a predefined set of options
|
||||
- [TimeInput](https://mantine.dev/llms/dates-time-input.md): Capture time from the user
|
||||
- [TimePicker](https://mantine.dev/llms/dates-time-picker.md): Captures time value from the user
|
||||
- [TimeValue](https://mantine.dev/llms/dates-time-value.md): Display a formatted time value
|
||||
- [YearPicker](https://mantine.dev/llms/dates-year-picker.md): Inline year, multiple years and years range picker
|
||||
- [YearPickerInput](https://mantine.dev/llms/dates-year-picker-input.md): Inline year, multiple years and years range picker
|
||||
|
||||
## Charts
|
||||
|
||||
- [AreaChart](https://mantine.dev/llms/charts-area-chart.md): Area chart component with stacked, percent and split variants
|
||||
- [BarChart](https://mantine.dev/llms/charts-bar-chart.md): Bar chart component with stacked and percent variants
|
||||
- [BarsList](https://mantine.dev/llms/charts-bars-list.md): Display a list of bars with names and values
|
||||
- [BubbleChart](https://mantine.dev/llms/charts-bubble-chart.md): Bubble chart component
|
||||
- [BulletChart](https://mantine.dev/llms/charts-bullet-chart.md): Compact KPI chart with value, target, and qualitative ranges
|
||||
- [CompositeChart](https://mantine.dev/llms/charts-composite-chart.md): Composed chart with support for Area, Bar and Line charts
|
||||
- [DonutChart](https://mantine.dev/llms/charts-donut-chart.md): Donut chart component
|
||||
- [FunnelChart](https://mantine.dev/llms/charts-funnel-chart.md): Funnel chart component
|
||||
- [GettingStartedCharts](https://mantine.dev/llms/charts-getting-started.md)
|
||||
- [Heatmap](https://mantine.dev/llms/charts-heatmap.md): Heatmap chart component
|
||||
- [LineChart](https://mantine.dev/llms/charts-line-chart.md): Line chart component
|
||||
- [PieChart](https://mantine.dev/llms/charts-pie-chart.md): Pie chart component
|
||||
- [RadarChart](https://mantine.dev/llms/charts-radar-chart.md): Radar chart component
|
||||
- [RadialBarChart](https://mantine.dev/llms/charts-radial-bar-chart.md): Radial bar chart component
|
||||
- [SankeyChart](https://mantine.dev/llms/charts-sankey-chart.md): Sankey diagram component
|
||||
- [ScatterChart](https://mantine.dev/llms/charts-scatter-chart.md): Scatter chart component
|
||||
- [Sparkline](https://mantine.dev/llms/charts-sparkline.md): Simplified area chart to show trends
|
||||
- [SunburstChart](https://mantine.dev/llms/charts-sunburst-chart.md): Sunburst chart component for hierarchical data
|
||||
- [Treemap](https://mantine.dev/llms/charts-treemap.md): Treemap chart component
|
||||
|
||||
## Schedule
|
||||
|
||||
- [AgendaView](https://mantine.dev/llms/schedule-agenda-view.md): Vertical list view of events grouped by date
|
||||
- [DayView](https://mantine.dev/llms/schedule-day-view.md): Standalone schedule day view component
|
||||
- [GettingStartedSchedule](https://mantine.dev/llms/schedule-getting-started.md)
|
||||
- [MobileMonthView](https://mantine.dev/llms/schedule-mobile-month-view.md): Mobile-optimized schedule month view component
|
||||
- [MonthView](https://mantine.dev/llms/schedule-month-view.md): Standalone schedule month view component
|
||||
- [RecurringEventsSchedule](https://mantine.dev/llms/schedule-recurring-events.md)
|
||||
- [ResourcesDayView](https://mantine.dev/llms/schedule-resources-day-view.md): Day view with resource columns for scheduling across resources
|
||||
- [ResourcesMonthView](https://mantine.dev/llms/schedule-resources-month-view.md): Month view with resource lanes for scheduling across resources
|
||||
- [ResourcesSchedule](https://mantine.dev/llms/schedule-resources-schedule.md): Schedule wrapper component that combines resource day, week and month views
|
||||
- [ResourcesWeekView](https://mantine.dev/llms/schedule-resources-week-view.md): Week view with resource rows for scheduling across resources
|
||||
- [Schedule](https://mantine.dev/llms/schedule-schedule.md): Schedule component with day, week, month and year views
|
||||
- [ScheduleEventsData](https://mantine.dev/llms/schedule-events-data.md)
|
||||
- [WeekView](https://mantine.dev/llms/schedule-week-view.md): Standalone schedule week view component
|
||||
- [YearView](https://mantine.dev/llms/schedule-year-view.md): Standalone schedule year view component
|
||||
|
||||
## Guides
|
||||
|
||||
- [ControlledVsUncontrolled](https://mantine.dev/llms/guides-controlled-vs-uncontrolled.md)
|
||||
- [CustomComponents](https://mantine.dev/llms/guides-custom-components.md)
|
||||
- [EightToNine](https://mantine.dev/llms/guides-8x-to-9x.md)
|
||||
- [FunctionsReference](https://mantine.dev/llms/guides-functions-reference.md)
|
||||
- [Gatsby](https://mantine.dev/llms/guides-gatsby.md)
|
||||
- [Icons](https://mantine.dev/llms/guides-icons.md)
|
||||
- [JavaScript](https://mantine.dev/llms/guides-javascript.md)
|
||||
- [Jest](https://mantine.dev/llms/guides-jest.md)
|
||||
- [LLMDocumentation](https://mantine.dev/llms/guides-llms.md)
|
||||
- [NextJs](https://mantine.dev/llms/guides-next.md)
|
||||
- [Polymorphic](https://mantine.dev/llms/guides-polymorphic.md)
|
||||
- [ReactRouter](https://mantine.dev/llms/guides-react-router.md)
|
||||
- [Redwood](https://mantine.dev/llms/guides-redwood.md)
|
||||
- [SevenToEight](https://mantine.dev/llms/guides-7x-to-8x.md)
|
||||
- [SixToSeven](https://mantine.dev/llms/guides-6x-to-7x.md)
|
||||
- [Storybook](https://mantine.dev/llms/guides-storybook.md)
|
||||
- [Tiptap3Migration](https://mantine.dev/llms/guides-tiptap-3-migration.md)
|
||||
- [TypeScript](https://mantine.dev/llms/guides-typescript.md)
|
||||
- [Vite](https://mantine.dev/llms/guides-vite.md)
|
||||
- [Vitest](https://mantine.dev/llms/guides-vitest.md)
|
||||
|
||||
## Theming
|
||||
|
||||
- [Colors](https://mantine.dev/llms/theming-colors.md)
|
||||
- [ColorSchemes](https://mantine.dev/llms/theming-color-schemes.md)
|
||||
- [DefaultProps](https://mantine.dev/llms/theming-default-props.md)
|
||||
- [MantineProvider](https://mantine.dev/llms/theming-mantine-provider.md)
|
||||
- [ThemeObject](https://mantine.dev/llms/theming-theme-object.md)
|
||||
- [Typography](https://mantine.dev/llms/theming-typography.md): Styles provider for html content
|
||||
|
||||
## Styles
|
||||
|
||||
- [ColorFunctions](https://mantine.dev/llms/styles-color-functions.md)
|
||||
- [CSSFilesList](https://mantine.dev/llms/styles-css-files-list.md)
|
||||
- [CSSModules](https://mantine.dev/llms/styles-css-modules.md)
|
||||
- [CssVariables](https://mantine.dev/llms/styles-css-variables.md)
|
||||
- [CSSVariablesList](https://mantine.dev/llms/styles-css-variables-list.md)
|
||||
- [DataAttributes](https://mantine.dev/llms/styles-data-attributes.md)
|
||||
- [Emotion](https://mantine.dev/llms/styles-emotion.md)
|
||||
- [GlobalStyles](https://mantine.dev/llms/styles-global-styles.md)
|
||||
- [MantineStyles](https://mantine.dev/llms/styles-mantine-styles.md)
|
||||
- [PostCSSPreset](https://mantine.dev/llms/styles-postcss-preset.md)
|
||||
- [Rem](https://mantine.dev/llms/styles-rem.md)
|
||||
- [ResponsiveStyles](https://mantine.dev/llms/styles-responsive.md)
|
||||
- [Rtl](https://mantine.dev/llms/styles-rtl.md)
|
||||
- [StyleProp](https://mantine.dev/llms/styles-style.md)
|
||||
- [StyleProps](https://mantine.dev/llms/styles-style-props.md)
|
||||
- [StylesApi](https://mantine.dev/llms/styles-styles-api.md)
|
||||
- [StylesOverview](https://mantine.dev/llms/styles-styles-overview.md)
|
||||
- [StylesPerformance](https://mantine.dev/llms/styles-styles-performance.md)
|
||||
- [UnstyledComponents](https://mantine.dev/llms/styles-unstyled.md)
|
||||
- [UsageWithSass](https://mantine.dev/llms/styles-sass.md)
|
||||
- [VanillaExtract](https://mantine.dev/llms/styles-vanilla-extract.md)
|
||||
- [VariantsAndSizes](https://mantine.dev/llms/styles-variants-sizes.md)
|
||||
|
||||
## X
|
||||
|
||||
- [Carousel](https://mantine.dev/llms/x-carousel.md): Embla based carousel component
|
||||
- [CodeHighlight](https://mantine.dev/llms/x-code-highlight.md): Highlight code with shiki or highlight.js
|
||||
- [Dropzone](https://mantine.dev/llms/x-dropzone.md): Capture files from user with drag and drop
|
||||
- [ExtensionsPackage](https://mantine.dev/llms/x-extensions.md)
|
||||
- [Modals manager](https://mantine.dev/llms/x-modals.md): Centralized modals manager with option to handle state of multi-step modals
|
||||
- [NavigationProgress](https://mantine.dev/llms/x-nprogress.md): Navigation progress bar
|
||||
- [Notifications system](https://mantine.dev/llms/x-notifications.md): Mantine notifications system
|
||||
- [Rich text editor](https://mantine.dev/llms/x-tiptap.md): Tiptap based rich text editor
|
||||
- [Spotlight](https://mantine.dev/llms/x-spotlight.md): Command center for your application
|
||||
|
||||
## Other
|
||||
|
||||
- [About](https://mantine.dev/llms/about.md)
|
||||
- [AllReleases](https://mantine.dev/llms/changelog-all-releases.md)
|
||||
- [BrowserSupport](https://mantine.dev/llms/browser-support.md)
|
||||
- [Changelog700](https://mantine.dev/llms/changelog-7-0-0.md)
|
||||
- [Changelog710](https://mantine.dev/llms/changelog-7-1-0.md)
|
||||
- [Changelog7100](https://mantine.dev/llms/changelog-7-10-0.md)
|
||||
- [Changelog7110](https://mantine.dev/llms/changelog-7-11-0.md)
|
||||
- [Changelog7120](https://mantine.dev/llms/changelog-7-12-0.md)
|
||||
- [Changelog7130](https://mantine.dev/llms/changelog-7-13-0.md)
|
||||
- [Changelog7140](https://mantine.dev/llms/changelog-7-14-0.md)
|
||||
- [Changelog7150](https://mantine.dev/llms/changelog-7-15-0.md)
|
||||
- [Changelog7160](https://mantine.dev/llms/changelog-7-16-0.md)
|
||||
- [Changelog7170](https://mantine.dev/llms/changelog-7-17-0.md)
|
||||
- [Changelog720](https://mantine.dev/llms/changelog-7-2-0.md)
|
||||
- [Changelog730](https://mantine.dev/llms/changelog-7-3-0.md)
|
||||
- [Changelog740](https://mantine.dev/llms/changelog-7-4-0.md)
|
||||
- [Changelog750](https://mantine.dev/llms/changelog-7-5-0.md)
|
||||
- [Changelog760](https://mantine.dev/llms/changelog-7-6-0.md)
|
||||
- [Changelog770](https://mantine.dev/llms/changelog-7-7-0.md)
|
||||
- [Changelog780](https://mantine.dev/llms/changelog-7-8-0.md)
|
||||
- [Changelog790](https://mantine.dev/llms/changelog-7-9-0.md)
|
||||
- [Changelog800](https://mantine.dev/llms/changelog-8-0-0.md)
|
||||
- [Changelog810](https://mantine.dev/llms/changelog-8-1-0.md)
|
||||
- [Changelog820](https://mantine.dev/llms/changelog-8-2-0.md)
|
||||
- [Changelog830](https://mantine.dev/llms/changelog-8-3-0.md)
|
||||
- [Changelog900](https://mantine.dev/llms/changelog-9-0-0.md)
|
||||
- [Changelog910](https://mantine.dev/llms/changelog-9-1-0.md)
|
||||
- [Changelog920](https://mantine.dev/llms/changelog-9-2-0.md)
|
||||
- [Changelog930](https://mantine.dev/llms/changelog-9-3-0.md)
|
||||
- [Changelog940](https://mantine.dev/llms/changelog-9-4-0.md)
|
||||
- [Changelog950](https://mantine.dev/llms/changelog-9-5-0.md)
|
||||
- [Contribute](https://mantine.dev/llms/contribute.md)
|
||||
- [GettingStarted](https://mantine.dev/llms/getting-started.md)
|
||||
- [NotFound](https://mantine.dev/llms/404.md)
|
||||
- [OxcConfig](https://mantine.dev/llms/oxc-config-mantine.md)
|
||||
- [PreviousChangelogs](https://mantine.dev/llms/changelog-previous-versions.md)
|
||||
- [Support](https://mantine.dev/llms/support.md)
|
||||
+2
-1
@@ -108,4 +108,5 @@ pnpm verify:search
|
||||
|
||||
## Contributing
|
||||
|
||||
When making changes to the homarr codebase that affect user-facing behavior, **always update the corresponding documentation in this app**. See `.cursor/rules/documentation-sync.mdc` for the complete mapping of code changes to doc locations.
|
||||
When a Homarr change affects user-facing behavior, update the corresponding documentation in this app. See the
|
||||
[documentation-sync skill](../../.agents/skills/documentation-sync/SKILL.md) for the code-to-documentation mapping.
|
||||
@@ -40,6 +40,26 @@ Using the `PUID` and `PGID` will require you to set the correct permissions on t
|
||||
Workshop service operators can find its server-only configuration in the
|
||||
[Workshop operator guide](../../workshop/operator#environment-variables).
|
||||
|
||||
### Workshop service
|
||||
|
||||
These variables configure the separate Workshop service, not the Homarr application container.
|
||||
|
||||
| Environment Variable | Description | Default |
|
||||
| ------------------------------------------- | ------------------------------------------------------------------------- | --------------------------------- |
|
||||
| `WORKSHOP_PUBLIC_ORIGIN` | Public origin used in PocketBase links and email | Required in production |
|
||||
| `PB_ALLOWED_ORIGINS` | Browser origins allowed to call PocketBase | `*` for the public community |
|
||||
| `WORKSHOP_IMAGE` | Workshop image used by the staging Compose file | `ghcr.io/homarr-labs/workshop:v2` |
|
||||
| `WORKSHOP_EXPOSE_PORT` | Host port for the combined staging service | `8090` |
|
||||
| `PB_EXPOSE_PORT` | Host port for local PocketBase development | `8090` |
|
||||
| `DOCS_EXPOSE_PORT` | Host port for local documentation development | `3003` |
|
||||
| `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET` | Optional GitHub OAuth application credentials | - |
|
||||
| `OPENROUTER_API_KEY` | Server-only upstream key that enables the Homarr Assistant provider | Provider disabled |
|
||||
| `HOMARR_AI_DAILY_REQUEST_LIMIT` | Daily Assistant request units per user | `50` |
|
||||
| `HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT` | Daily Assistant request units shared by all users | `10000` |
|
||||
| `HOMARR_AI_OPENROUTER_BASE_URL` | OpenRouter-compatible provider endpoint | `https://openrouter.ai/api/v1` |
|
||||
| `HOMARR_AI_OPENROUTER_MODEL` | Upstream model exposed through the stable `homarr/model` alias | Selected by the Homarr team |
|
||||
| `HOMARR_AI_ALLOW_INSECURE_UPSTREAM` | Allows an HTTP upstream for local provider tests; never use in production | `false` |
|
||||
|
||||
## Authentication
|
||||
|
||||
See [Single Sign-On](/docs/advanced/single-sign-on) for more informations.
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Helm chart to deploy homarr for Kubernetes
|
||||
|
||||
@@ -437,7 +437,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
|
||||
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
|
||||
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
|
||||
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
|
||||
| image.tag | string | `"v1.74.0"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| image.tag | string | `"v1.76.1"` | Overrides the image tag whose default is the chart appVersion |
|
||||
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
|
||||
| ingress.annotations | object | `{}` | Ingress annotations |
|
||||
| ingress.enabled | bool | `false` | Enable ingress |
|
||||
|
||||
@@ -110,60 +110,3 @@ Agents can install existing widgets without regenerating them:
|
||||
6. Test the installed definition, then use `board_addItem` with kind `customApi` and the returned definition ID.
|
||||
|
||||
Homarr encrypts the plaintext immediately. Homarr does not return the plaintext.
|
||||
|
||||
## Evaluate the copied prompt
|
||||
|
||||
Homarr maintainers can run the same self-contained prompt that the workbench copies through the live generation-quality suite:
|
||||
|
||||
```sh
|
||||
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:ai
|
||||
```
|
||||
|
||||
The manifest suite covers nine complex and public-API prompts. Its deterministic fixture-backed cases include a fake
|
||||
homelab health API, CoinMarketCap's keyless public API, Bored, Agify, and a multi-request Pokédex. Before the configured
|
||||
model judges a result, the candidate must pass Homarr's real clipboard parser, canonical schema, and deterministic
|
||||
checks for documented endpoints, authentication, request triggers, query bindings, response fields, and essential
|
||||
runtime components.
|
||||
|
||||
To exercise the real Assistant lifecycle instead of accepting a returned JSON block, run:
|
||||
|
||||
```sh
|
||||
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:assistant-ai
|
||||
```
|
||||
|
||||
The same suites can target the Homarr provider with a Workshop PocketBase token instead of an OpenRouter key:
|
||||
|
||||
```sh
|
||||
AI_PROVIDER_BASE_URL=https://homarr.dev/api/ai/v1 \
|
||||
AI_PROVIDER_API_KEY=<workshop-token> \
|
||||
AI_PROVIDER_MODEL=homarr/model \
|
||||
pnpm --filter @homarr/custom-widgets test:assistant-ai
|
||||
```
|
||||
|
||||
`AI_PROVIDER_JUDGE_MODEL` can select a separate judge when the configured endpoint offers one. The Homarr provider
|
||||
offers exactly one model, so generation and review both use `homarr/model`.
|
||||
|
||||
The assistant test exposes the same lazy skill, live schema, component catalog, targeted component references,
|
||||
validation, preview-query, and create-from-preview tools as the product. It refuses to save a definition that was not
|
||||
validated exactly or whose preview queries were not all tested. Multi-request fixtures return a different payload for
|
||||
each documented path and require manual-query parameters. Use `--case=pokedex`, `--case=coinmarketcap-keyless`,
|
||||
`--case=bored-activity`, or `--case=agify-name` for a focused run.
|
||||
|
||||
Both suites use the configured provider models for generation and harsh review. They save tool traces, validated
|
||||
manifests, raw reviews, and detailed category scores under
|
||||
`packages/custom-widgets/.ai-evaluations/`. These live tests are maintainer-invoked rather than CI-required because
|
||||
they consume a configured provider account and exercise a nondeterministic model. The repository's ordinary tests
|
||||
cover the lifecycle enforcement, fixture routing, score calculation, and failure behavior without network access.
|
||||
|
||||
Each scenario stops as soon as it passes and has a hard limit of ten generation attempts. Override the endpoint,
|
||||
models, or per-scenario limit with `AI_PROVIDER_BASE_URL`, `AI_PROVIDER_API_KEY`, `AI_PROVIDER_MODEL`,
|
||||
`AI_PROVIDER_JUDGE_MODEL`, and
|
||||
`CUSTOM_WIDGET_AI_MAX_LOOPS`. Generated artifacts are local and ignored by Git; promote a reviewed, validated result
|
||||
into the bundled-widget source deliberately.
|
||||
|
||||
The reference API check uses a real PokeAPI list/detail request and a read-only Portainer container query when `PORTAINER_BASE_URL` and `PORTAINER_API_KEY` are set. Use `pnpm --filter @homarr/custom-widgets test:reference-apis -- --pokeapi-only` for the public PokeAPI check alone. To exercise start, restart, and stop against a disposable container, also set `PORTAINER_TEST_CONTAINER_ID` and the explicit safeguard `PORTAINER_ALLOW_MUTATIONS=1`; the script restores the container's original running state.
|
||||
|
||||
Use `pnpm --filter @homarr/custom-widgets test:reference-apis -- --public-ai-apis-only` to verify the live Bored and
|
||||
CoinMarketCap response shapes used by the assistant fixtures. The same command also verifies Agify when
|
||||
`AGIFY_API_KEY` is configured; otherwise it reports the authenticated check as skipped without weakening the
|
||||
deterministic Agify fixture tests.
|
||||
@@ -21,7 +21,8 @@ export const completeManifest = `{
|
||||
|
||||
:::warning Experimental feature
|
||||
|
||||
This feature is experimental and will probably change. If you encounter bugs, need more capabilities, or find something confusing, tell us in the [official discussion thread](https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets). Your feedback directly shapes what comes next. Happy hacking!
|
||||
This feature is experimental and may change. Share feedback in the
|
||||
[Custom Widgets discussion](https://github.com/homarr-labs/homarr/discussions/categories/custom-widgets).
|
||||
|
||||
:::
|
||||
|
||||
@@ -84,54 +85,35 @@ Mutations remain authenticated and enforce each request's view, modify, or full
|
||||
- [Use MCP and the skills.sh skill](./agent-authoring).
|
||||
- [Correct problems](./troubleshooting).
|
||||
|
||||
The workbench keeps one continuous layout with visual source, request, and option editors. Advanced raw JSON remains available for experts and AI paste. Invalid values stay inline and disable both preview and Save/Create until they are corrected.
|
||||
The workbench provides visual source, request, and option editors, with raw JSON available for advanced use. Invalid
|
||||
values remain marked and disable preview and saving until corrected.
|
||||
|
||||
Exports, imports, the official Workshop client, and MCP validate the same schema. PocketBase stores community
|
||||
submissions without interpreting their content, and Homarr validates them before installation. Widgets saved in the v1
|
||||
format remain visible to administrators with a **Migration required** badge after upgrading. Use **Copy migration
|
||||
prompt**, give the redacted prompt to an LLM, copy its single complete JSON block, then select **Paste migrated widget**
|
||||
from that widget's action menu. Homarr reads the `template` value into the JSX editor automatically. The prompt keeps safe
|
||||
request metadata but deliberately omits the v1 display configuration because legacy free-text and JSX can contain
|
||||
hard-coded credentials. Add a credential-free description of the widget's visible layout and behavior when sending the
|
||||
prompt.
|
||||
Exports, imports, Workshop, and MCP validate the same schema. Homarr also validates community content before
|
||||
installation.
|
||||
|
||||
Homarr keeps the original v1 definition and encrypted secret as a compatibility backup even after the validated v2
|
||||
replacement is created. The v2 record uses the same stable ID and takes precedence. The archived v1 copy stays hidden
|
||||
while the replacement exists; deleting the v2 record surfaces the v1 entry as migration-required again. Automatic
|
||||
legacy deletion is intentionally unavailable during v2 stabilization. Credentials are never included in prompts,
|
||||
logs, or exports.
|
||||
|
||||
For administrators adding widgets while editing a board, the Custom Widgets section includes **Import from
|
||||
Workshop**. Non-administrators do not see the Custom Widget picker or Workshop actions. Selecting **Install widget**
|
||||
opens a two-step modal flow: first review the Workshop entry and its complete source in the read-only editor, then
|
||||
confirm API sources and credentials. After import, the new definition appears in the picker. Its tile waits for the board
|
||||
to be saved before server requests run.
|
||||
Legacy definitions show a **Migration required** badge. Use **Copy migration prompt**, describe the visible layout
|
||||
without secrets, and import the returned v2 JSON with **Paste migrated widget**. Homarr preserves the v1 definition and
|
||||
encrypted secret for rollback; credentials never appear in prompts, logs, or exports.
|
||||
|
||||
## Import and Workshop
|
||||
|
||||
The install flow always links to the corresponding public Workshop entry under the configured Workshop origin. Import confirmation shows origins, authentication modes, network scopes, methods, actions, and permissions. Every source has a suggested URL, but private and loopback sources must be confirmed or replaced for the current Homarr installation. The same screen accepts API keys, tokens, usernames, and passwords; credentials remain optional at install time and missing values stay visible on the Custom Widgets page.
|
||||
|
||||
For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work.
|
||||
Administrators can select **Import from Workshop** while adding a board item. Review the complete source and requested
|
||||
permissions, then confirm deployment-specific URLs and credentials. Private and loopback URLs must be confirmed or
|
||||
replaced for the current Homarr installation.
|
||||
|
||||
Workshop publishes technically valid widgets immediately; users can vote or report them, and Workshop moderators can
|
||||
delete submissions. The report form expands within the Workshop detail page instead of interrupting the review with a
|
||||
dialog. Community moderation is separate from Homarr administration and is not a security boundary.
|
||||
Publishers should replace personal private-network hosts with a clear service placeholder before publishing. Homarr
|
||||
performs definitive validation again at install time.
|
||||
delete submissions. Community moderation is separate from Homarr administration and is not a security boundary. Review
|
||||
the source before installation and replace personal private-network hosts before publishing.
|
||||
|
||||
Connected agents can search, inspect, and install Workshop widgets with `customWidget_workshopSearch`, `customWidget_workshopGet`, and `customWidget_workshopInstall`. After installation they can set a source URL and credentials with `customWidget_sourceConfigure`, or create a secure user-facing setup URL with `customWidget_configurationRequestUser`. Secret values are never returned through MCP.
|
||||
|
||||
These tools are only exposed to API keys owned by Homarr administrators. Opening and completing a source-configuration
|
||||
URL also requires the same authenticated administrator; the URL is short-lived, single-use, and redacted.
|
||||
Connected agents can also search and install Workshop widgets. See [agent authoring](./agent-authoring) for the secure
|
||||
configuration flow; secret values are never returned through MCP.
|
||||
|
||||
## Reliability and rollback
|
||||
|
||||
Custom Widget response caches and action invalidation use shared Redis state, so multiple Homarr replicas see the same
|
||||
versioned cache generation. Deployments must point every replica at the same Redis service.
|
||||
Multiple Homarr replicas must use the same Redis service so Custom Widget response caches invalidate consistently.
|
||||
|
||||
Before upgrading, back up the database and `SECRET_ENCRYPTION_KEY`; a v1 binary can still read preserved v1 tables
|
||||
during application rollback. Widgets created only in v2 may be unavailable to the rolled-back binary, but do not
|
||||
replace the v1 backup.
|
||||
Before upgrading, back up the database and `SECRET_ENCRYPTION_KEY`. A v1 rollback can read preserved v1 definitions,
|
||||
but widgets created only in v2 may be unavailable.
|
||||
|
||||
## Bundled examples
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ In the configuration section you can select between two options.
|
||||
|
||||
#### Search template
|
||||
|
||||
The template must be a valid URL that incldues the `%s` placeholder.
|
||||
The template must be a valid URL that includes the `%s` placeholder.
|
||||
Homarr will replace the `%s` with the term that the user entered.
|
||||
|
||||
##### Examples
|
||||
|
||||
@@ -11,14 +11,11 @@ import assistantWidgetImage from "../../management/img/assistant-widget.png";
|
||||
|
||||
<WidgetHeader widget={assistantWidget} categories={["Productivity"]} />
|
||||
|
||||
The Assistant widget brings the same conversation available from the user menu and command menu onto a
|
||||
board. Messages, conversations, tool calls, approvals, files, mentions, model controls, reasoning controls,
|
||||
usage details, and response actions remain synchronized with the app-wide assistant.
|
||||
The Assistant widget puts the app-wide assistant on a board. It uses the same active conversation as the user-menu and
|
||||
command-menu surfaces, so messages and approvals stay synchronized.
|
||||
|
||||
The assistant must be enabled by an administrator in **Management → Assistant**. Signed-out users and users
|
||||
on an instance without an enabled model see an unavailable state instead of a broken composer. Signed-out users see
|
||||
**Sign in**; a configured provider without a saved model or with **Enable Assistant** off remains unavailable until
|
||||
the administrator saves a working connection, selects a model, and enables the assistant.
|
||||
An administrator must first configure and enable Assistant in **Management → Assistant**. Signed-out users are prompted
|
||||
to sign in, while an incomplete or disabled configuration shows an unavailable state.
|
||||
|
||||
<img
|
||||
src={assistantWidgetImage}
|
||||
@@ -26,44 +23,14 @@ the administrator saves a working connection, selects a model, and enables the a
|
||||
style={{ maxWidth: "100%" }}
|
||||
/>
|
||||
|
||||
The current preview capture labels the board-embedded surface: its header exposes conversation history, refresh,
|
||||
automatic approvals, and new-conversation controls, while the footer exposes the model, thinking mode, and composer.
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
The default widget size is ten columns by four rows and automatically fits boards with fewer columns. Controls
|
||||
and labels condense as the widget becomes narrower, while the conversation remains scrollable and usable on
|
||||
touch devices. When a board layout does not provide enough height for conversation controls, the widget shows
|
||||
a compact **Open assistant** action instead of squeezing an unusable composer into the card. Increase its height
|
||||
in that layout to keep the complete conversation inline.
|
||||
The default size is ten columns by four rows and shrinks to fit narrower boards. Short layouts show an **Open
|
||||
assistant** action instead of compressing the conversation controls; increase the widget height to keep the complete
|
||||
conversation inline.
|
||||
|
||||
The widget follows whichever conversation is active in the user-menu panel, `Shift + A`, or the command menu. This
|
||||
keeps every assistant entry point synchronized without storing a user's private conversation details in board settings.
|
||||
|
||||
### Conversation features
|
||||
|
||||
- Create, switch, rename, and delete conversations.
|
||||
- Send text and supported file or image attachments by picker, drag and drop, or image paste.
|
||||
- Mention permitted apps, integrations, boards, widgets, and tools with `@`.
|
||||
- Use `/` shortcuts for common dashboard tasks.
|
||||
- Review tool calls, approve mutations, and answer structured assistant questions inline.
|
||||
- Answer pending structured questions from the dock above the composer without scrolling through the conversation.
|
||||
- Enable conversation-scoped automatic approvals from the shield control to approve protected mutations, use presented form details, and choose answers categorized as affirmative without individual clicks.
|
||||
- Edit user messages and copy, regenerate, branch, quote, export, or rate responses.
|
||||
- Inspect syntax-highlighted Markdown, sources, generated files, tool results, streaming chain-of-thought,
|
||||
cumulative conversation usage, per-turn token and context use, timing, speed, provider routing, and cost.
|
||||
- Change the model and reasoning effort for the active conversation. A model choice is saved per persisted thread,
|
||||
does not change the administrator's default, and is restored when that thread is selected again; a new thread
|
||||
starts from the configured default.
|
||||
- Refresh the current board from the conversation header. Approved mutations also refresh visible board data automatically.
|
||||
|
||||
The expanded assistant and board widget use the same persisted thread runtime. A response continues in the
|
||||
background when the widget leaves the viewport or the expanded panel is minimized. Each visible surface keeps an
|
||||
independent draft, attachments, selection, and focus, so typing in a board widget cannot move or overwrite the
|
||||
floating panel composer.
|
||||
|
||||
Automatic approvals are off by default, reset when switching conversations, and never select a neutral choice. A
|
||||
structured question shows **Other** only when the tool allows it (`allowOther` is not `false`); selecting **Other**
|
||||
opens a manual freeform response even when automatic approvals are enabled.
|
||||
Responses continue when the widget leaves the viewport. Drafts and attachments remain local to each visible Assistant
|
||||
surface, and private conversation data is never stored in board settings. Normal Homarr permissions and approval rules
|
||||
still apply to every action.
|
||||
@@ -13,35 +13,20 @@ import { timerWidget } from ".";
|
||||
|
||||
<WidgetHeader widget={timerWidget} categories={["Productivity", "Time"]} />
|
||||
|
||||
The Timer / Pomodoro widget runs entirely in your browser and does not require an integration. The compact widget
|
||||
always keeps its start or pause button and reset button visible. A running timer uses a saved deadline, so reopening
|
||||
the board or returning to a background tab restores the correct remaining time instead of restarting the countdown.
|
||||
The Timer / Pomodoro widget runs in your browser and does not require an integration. A running timer uses a saved
|
||||
deadline, so reopening the board or returning from a background tab restores the correct remaining time.
|
||||
|
||||
Timer mode uses one configured duration. Pomodoro mode starts with a focus session, follows most completed focus
|
||||
sessions with a short break, and uses a long break after the configured number of focus sessions. Automatic break and
|
||||
focus starts are separate options and are disabled by default. When automatic starts are enabled, elapsed phases are
|
||||
reconciled from their original deadlines after the board returns to the foreground.
|
||||
|
||||
Advanced view shows the current sequence, configured durations, and local alert preferences. Browser notifications are
|
||||
requested only when enabled. Notification and sound preferences, live progress, deadlines, and cycle position are
|
||||
stored locally using the board and widget identifiers. They are separate for each browser or device, are available to
|
||||
guest viewers, and are removed by clearing site data. Widget previews are ephemeral and do not write local timer state.
|
||||
Advanced view shows the sequence, durations, and local alert preferences. Sounds are enabled by default, while browser
|
||||
notifications are requested only after you enable them. Progress and alert preferences are stored per board and widget
|
||||
in the current browser; clearing site data resets them, and widget previews do not persist timer state.
|
||||
|
||||
Timer sounds are enabled by default and use the same low-volume Tiks sound system as onboarding: start and pause use
|
||||
rising and falling soft-theme cues, reset uses a short click, and phase completion uses the glass theme's resonant
|
||||
notification ding. You can disable them in advanced view. The cues respect the device's reduced-motion preference and
|
||||
supplement the visible timer state rather than replacing it.
|
||||
|
||||
When automatic start is disabled, a completed phase labels the next step as ready and flashes the widget edges with the
|
||||
board's secondary color. The border glow and completion chime run together as a five-beat alarm burst at
|
||||
300-millisecond intervals, then stop. Reduced-motion and forced-colors modes replace the flash with a static border.
|
||||
The current phase stays pinned to the compact widget's top-left corner and wraps when needed. Timer text and controls
|
||||
use the dashboard theme's responsive size scale, with secondary details hidden when the widget preview is narrow or
|
||||
short.
|
||||
|
||||
Alerts can fire while Homarr is open. Browser and operating-system notification settings may still suppress them.
|
||||
The timer's mode, durations, session count, and automatic-start behavior are permanent board settings and are edited
|
||||
from the widget context menu.
|
||||
Alerts can fire while Homarr is open, but browser or operating-system settings may suppress notifications. Reduced-motion
|
||||
and forced-colors preferences replace the animated completion effect with a static border.
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
|
||||
|
||||
@@ -92,10 +92,6 @@ pnpm test:workshop-live-provider
|
||||
This optional check consumes one provider request. The test client needs neither the OpenRouter key nor the private
|
||||
upstream model ID. Do not run it in CI or with a normal user's Workshop token.
|
||||
|
||||
The Custom Widget AI tests can use this proxy. Set `AI_PROVIDER_BASE_URL` to the `/api/ai/v1` endpoint and
|
||||
`AI_PROVIDER_API_KEY` to a temporary Workshop user token. See
|
||||
[Agent authoring](../management/custom-widgets/agent-authoring#evaluate-the-copied-prompt) for the command.
|
||||
|
||||
## Staging and demo deployment
|
||||
|
||||
Use `apps/workshop/docker-compose.staging.yml` with the published `ghcr.io/homarr-labs/workshop:v2` image for a v2
|
||||
@@ -131,17 +127,34 @@ GitHub OAuth requires both credentials and the callback `https://v2.preview.homa
|
||||
after changing environment values, then verify the health endpoint, `/workshop`, a public listing request, GitHub
|
||||
sign-in, and an installation from the Homarr preview.
|
||||
|
||||
Back up application and PocketBase data before deployment. Publish the combined image by immutable digest, test the
|
||||
exact digest in staging, rehearse application rollback and PocketBase restore, then promote that same digest.
|
||||
## Backup and restore
|
||||
|
||||
## Client architecture
|
||||
PocketBase data lives in `/pb_data`. Stop writes and copy it to a new, empty backup directory:
|
||||
|
||||
Workshop access is centralized in `@homarr/workshop/backend`. It exports the typed `WorkshopBackend`, its typed
|
||||
PocketBase instance, stable TanStack Query option factories, and the `useWorkshop…` query and mutation hooks used by
|
||||
Homarr. The documentation site uses the same backend rather than maintaining a second API contract.
|
||||
```sh
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml stop workshop
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml cp workshop:/pb_data/. ./workshop-backup
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml start workshop
|
||||
```
|
||||
|
||||
Listing fields derived by the `workshop_listings` view—such as submission type, score, and counts—are normalized by the
|
||||
shared client. Healthy deployments keep filtering, sorting, and pagination in PocketBase. If a persistent development
|
||||
volume still has an older view that rejects those fields, the client retries without them and applies the same
|
||||
operations locally; it can also fall back to the base submissions collection. Production deployments should
|
||||
nevertheless apply the current migration so the view exposes the complete metadata.
|
||||
Restore only after preserving the current volume and confirming the backup contains `data.db`:
|
||||
|
||||
```sh
|
||||
test -f ./workshop-backup/data.db
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml stop workshop
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml run --rm --no-deps --entrypoint sh workshop \
|
||||
-c 'find /pb_data -mindepth 1 -maxdepth 1 -exec rm -rf {} +'
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml cp ./workshop-backup/. workshop:/pb_data
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml start workshop
|
||||
curl --fail http://127.0.0.1:8090/api/health
|
||||
```
|
||||
|
||||
The restore empties the selected volume. Rehearse it in staging, then verify sign-in and one installation. Publish by
|
||||
immutable digest, validate that exact digest in staging, and promote the same digest.
|
||||
@@ -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<HTMLDivElement | null> }) => {
|
||||
const { activeCategoryId, categories, highlightedIndex, isSearchMode, items, open, query } =
|
||||
unstable_useTriggerPopoverScopeContext();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const viewport = viewportRef.current;
|
||||
const highlightedItem = viewport?.querySelector<HTMLElement>("[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 (
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItem className={classes.triggerItem} item={item} index={index}>
|
||||
<ThemeIcon size="sm" variant="light" color="gray">
|
||||
<Icon size={13} />
|
||||
</ThemeIcon>
|
||||
<div className={classes.triggerItemText}>
|
||||
<Text size="sm" fw={600} lineClamp={1}>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItem>
|
||||
);
|
||||
};
|
||||
|
||||
export const ComposerTriggers = () => {
|
||||
const t = useI18n("assistant");
|
||||
const aui = useAui();
|
||||
const mentionViewportRef = useRef<HTMLDivElement>(null);
|
||||
const slashViewportRef = useRef<HTMLDivElement>(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 (
|
||||
<>
|
||||
<ComposerPrimitive.Unstable_TriggerPopover
|
||||
ref={mentionViewportRef}
|
||||
className={classes.triggerPopover}
|
||||
char="@"
|
||||
adapter={mention.adapter}
|
||||
isLoading={isLoading}
|
||||
aria-label={t("mentions.menu")}
|
||||
>
|
||||
<TriggerPopoverAutoScroll viewportRef={mentionViewportRef} />
|
||||
<ComposerPrimitive.Unstable_TriggerPopover.Directive {...mention.directive} />
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverCategories className={classes.triggerList}>
|
||||
{(items) =>
|
||||
items.map((category) => {
|
||||
const Icon = contextIcons[category.id as keyof typeof contextIcons] ?? IconAt;
|
||||
return (
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverCategoryItem
|
||||
key={category.id}
|
||||
categoryId={category.id}
|
||||
className={classes.triggerItem}
|
||||
>
|
||||
<ThemeIcon size="sm" variant="light" color="gray">
|
||||
<Icon size={13} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} flex={1}>
|
||||
{category.label}
|
||||
</Text>
|
||||
<IconChevronRight size={14} />
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverCategoryItem>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverCategories>
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItems className={classes.triggerList}>
|
||||
{(items) =>
|
||||
items.map((item, index) => <TriggerItem key={`${item.type}:${item.id}`} item={item} index={index} />)
|
||||
}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
</ComposerPrimitive.Unstable_TriggerPopover>
|
||||
<ComposerPrimitive.Unstable_TriggerPopover
|
||||
ref={slashViewportRef}
|
||||
className={classes.triggerPopover}
|
||||
char="/"
|
||||
adapter={slash.adapter}
|
||||
aria-label={t("commands.menu")}
|
||||
>
|
||||
<TriggerPopoverAutoScroll viewportRef={slashViewportRef} />
|
||||
<ComposerPrimitive.Unstable_TriggerPopover.Action {...slash.action} />
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverItems className={classes.triggerList}>
|
||||
{(items) => items.map((item, index) => <TriggerItem key={item.id} item={item} index={index} />)}
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverItems>
|
||||
</ComposerPrimitive.Unstable_TriggerPopover>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const composerLabel = t("composerPlaceholder");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const editor = composerInputRef.current?.querySelector<HTMLElement>(".aui-lexical-input");
|
||||
if (!editor) return;
|
||||
editor.setAttribute("aria-label", composerLabel);
|
||||
return () => {
|
||||
if (editor.getAttribute("aria-label") === composerLabel) editor.removeAttribute("aria-label");
|
||||
};
|
||||
}, [composerLabel]);
|
||||
|
||||
return (
|
||||
<Box className={classes.composerWrap}>
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
||||
<ComposerPrimitive.AttachmentDropzone className={classes.composerDropzone}>
|
||||
<ComposerPrimitive.Root
|
||||
className={classes.composer}
|
||||
data-pending-action={hasPendingAction || undefined}
|
||||
onSubmit={(event) => {
|
||||
if (sendBlocked) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ComposerTriggers />
|
||||
<ComposerPrimitive.Quote className={classes.composerQuote}>
|
||||
<IconQuote size={15} />
|
||||
<ComposerPrimitive.QuoteText className={classes.composerQuoteText} />
|
||||
<ComposerPrimitive.QuoteDismiss asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="xs" aria-label={t("dismissQuote")}>
|
||||
<IconX size={12} />
|
||||
</ActionIcon>
|
||||
</ComposerPrimitive.QuoteDismiss>
|
||||
</ComposerPrimitive.Quote>
|
||||
<ComposerPrimitive.Attachments>{() => <Attachment removable />}</ComposerPrimitive.Attachments>
|
||||
<Group className={classes.composerRow} gap="xs" wrap="nowrap" align="flex-end">
|
||||
<Group gap={2} wrap="nowrap">
|
||||
<Tooltip label={t("attachments.add")}>
|
||||
<ComposerPrimitive.AddAttachment asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="lg" aria-label={t("attachments.add")}>
|
||||
<IconPaperclip size={17} />
|
||||
</ActionIcon>
|
||||
</ComposerPrimitive.AddAttachment>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<LexicalComposerInput
|
||||
ref={composerInputRef}
|
||||
className={classes.composerInput}
|
||||
data-assistant-composer-input
|
||||
placeholder={composerLabel}
|
||||
directiveChip={ContextDirectiveChip}
|
||||
// oxlint-disable-next-line jsx-a11y/no-autofocus -- opening the assistant is an explicit intent to compose
|
||||
autoFocus={props.autoFocusComposer}
|
||||
data-autofocus={props.autoFocusComposer ? true : undefined}
|
||||
/>
|
||||
{running ? (
|
||||
<ComposerPrimitive.Cancel asChild>
|
||||
<ActionIcon color="red" variant="light" size="lg" aria-label={t("stop")}>
|
||||
<IconPlayerStop size={18} />
|
||||
</ActionIcon>
|
||||
</ComposerPrimitive.Cancel>
|
||||
) : (
|
||||
<ComposerPrimitive.Send asChild>
|
||||
<ActionIcon variant="filled" size="lg" aria-label={sendLabel} disabled={sendBlocked}>
|
||||
<IconArrowUp size={18} />
|
||||
</ActionIcon>
|
||||
</ComposerPrimitive.Send>
|
||||
)}
|
||||
</Group>
|
||||
<Group className={classes.composerFooter} justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Group className={classes.composerControls} gap={5} wrap="nowrap">
|
||||
<RuntimeControls {...props} />
|
||||
<HomarrProviderQuota />
|
||||
<ConversationContext />
|
||||
</Group>
|
||||
</Group>
|
||||
</ComposerPrimitive.Root>
|
||||
</ComposerPrimitive.AttachmentDropzone>
|
||||
</ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Box component="output" className={classes.pendingActionBanner}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="yellow" radius="xl">
|
||||
<IconMessage size={17} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} flex={1} miw={0}>
|
||||
<Text size="sm" fw={700}>
|
||||
{copy.title}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{copy.detail}
|
||||
</Text>
|
||||
</Stack>
|
||||
<ThreadPrimitive.ScrollToBottom asChild>
|
||||
<Button variant="light" color="yellow" size="compact-sm">
|
||||
{t("pendingAction.review")}
|
||||
</Button>
|
||||
</ThreadPrimitive.ScrollToBottom>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const PendingQuestionDock = ({
|
||||
pendingAction,
|
||||
setTarget,
|
||||
}: {
|
||||
pendingAction: AssistantPendingAction | undefined;
|
||||
setTarget: (target: HTMLDivElement | null) => void;
|
||||
}) => {
|
||||
const t = useI18n("assistant");
|
||||
if (pendingAction?.kind !== "question") return null;
|
||||
|
||||
return (
|
||||
<Box component="section" className={classes.pendingQuestionDock} aria-label={t("pendingAction.answerTitle")}>
|
||||
<div ref={setTarget} className={classes.pendingQuestionTarget} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -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 ? (
|
||||
<Box component="img" className={classes.providerIcon} src={source} alt="" aria-hidden w={size} h={size} />
|
||||
) : (
|
||||
<IconRobot size={size} aria-hidden />
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Tooltip
|
||||
label={
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" fw={700} tt="capitalize">
|
||||
{telemetry.provider}
|
||||
</Text>
|
||||
<Text size="xs">{details.join(" · ")}</Text>
|
||||
</Stack>
|
||||
}
|
||||
multiline
|
||||
maw="min(24rem, calc(100vw - 2rem))"
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
className={classes.providerAction}
|
||||
// oxlint-disable-next-line jsx-a11y/prefer-tag-over-role -- the logo and its focused tooltip form one labelled image
|
||||
role="img"
|
||||
tabIndex={0}
|
||||
aria-label={`${telemetry.provider}: ${telemetry.modelId}`}
|
||||
>
|
||||
<ProviderIcon provider={telemetry.provider} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Box className={classes.conversationTurn} data-opened={opened || undefined}>
|
||||
<UnstyledButton
|
||||
className={classes.conversationTurnTrigger}
|
||||
aria-expanded={opened}
|
||||
aria-controls={detailsId}
|
||||
onClick={() => setOpened((value) => !value)}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" gap="xs" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" miw={0}>
|
||||
<Box className={classes.conversationTurnProvider}>
|
||||
<ProviderIcon provider={turn.provider} size={15} />
|
||||
</Box>
|
||||
<Box miw={0}>
|
||||
<Text size="xs" fw={650} truncate>
|
||||
{t("usage.step", { number: index + 1 })} · {turn.modelId}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{[
|
||||
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")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<IconChevronDown size={14} className={classes.disclosureIcon} data-opened={opened || undefined} />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
<Collapse id={detailsId} expanded={opened}>
|
||||
<Stack gap="sm" pt="sm">
|
||||
{generationQuery.isFetching && generationQuery.data?.complete !== true && (
|
||||
<Group gap="xs">
|
||||
<Loader size="xs" type="bars" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("usage.loadingProviderDetails")}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
{!generationQuery.isFetching && generationQuery.data?.complete === false && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("usage.providerDetailsPending")}
|
||||
</Text>
|
||||
)}
|
||||
<Box className={classes.usageGrid}>
|
||||
{[
|
||||
[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]) => (
|
||||
<div key={label}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{value ?? t("usage.notReported")}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</Box>
|
||||
{(telemetry.contextUsed !== undefined || telemetry.contextLength !== undefined) && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("usage.contextWindow")}: {telemetry.contextUsed?.toLocaleString() ?? t("usage.notReported")} /{" "}
|
||||
{telemetry.contextLength?.toLocaleString() ?? t("usage.notReported")}
|
||||
</Text>
|
||||
)}
|
||||
{steps.length > 0 && (
|
||||
<Stack gap={4}>
|
||||
<Text size="xs" fw={650}>
|
||||
{t("usage.agentSteps")}
|
||||
</Text>
|
||||
{steps.map((step) => (
|
||||
<Box key={step.index} className={classes.stepRow}>
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Text size="xs" fw={600}>
|
||||
{t("usage.step", { number: step.index })}
|
||||
{step.routedProvider ? ` · ${step.routedProvider}` : ""}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatDuration(step.durationMs)}
|
||||
{formatStepThroughput(step.providerOutputTokensPerSecond, step.outputTokensPerSecond)}
|
||||
{step.cost !== undefined ? ` · ${formatCost(step.cost)}` : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{[
|
||||
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")}
|
||||
</Text>
|
||||
{(step.normalizedInputTokens !== undefined || step.normalizedOutputTokens !== undefined) && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("usage.normalizedTokens")}: {step.normalizedInputTokens?.toLocaleString() ?? "–"} /{" "}
|
||||
{step.normalizedOutputTokens?.toLocaleString() ?? "–"}
|
||||
</Text>
|
||||
)}
|
||||
{(step.routerStrategy || step.routerRegion || step.serviceTier || step.dataRegion || step.isByok) && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{[
|
||||
step.routerStrategy,
|
||||
step.routerRegion,
|
||||
step.serviceTier,
|
||||
step.dataRegion,
|
||||
step.isByok ? t("usage.byok") : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
</Text>
|
||||
)}
|
||||
{step.generationId && (
|
||||
<Text size="xs" c="dimmed" className={classes.generationId} title={step.generationId}>
|
||||
{t("usage.generation")}: {step.generationId}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Box className={classes.contextBreakdown}>
|
||||
<RingProgress
|
||||
className={classes.contextBreakdownRing}
|
||||
size={76}
|
||||
thickness={7}
|
||||
roundCaps
|
||||
sections={
|
||||
hasContext ? [{ value: breakdown.percentage ?? 0, color: getContextColor(breakdown.percentage ?? 0) }] : []
|
||||
}
|
||||
label={
|
||||
<Text ta="center" size="xs" fw={700}>
|
||||
{hasContext ? `${breakdown.percentage}%` : "–"}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<Box className={classes.contextBreakdownWindow}>
|
||||
{[
|
||||
{ label: t("usage.used"), value: breakdown.contextUsed },
|
||||
{ label: t("usage.remaining"), value: breakdown.remaining },
|
||||
{ label: t("usage.capacity"), value: breakdown.contextLength },
|
||||
].map((item) => (
|
||||
<Box key={item.label}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text size="sm" fw={650}>
|
||||
{item.value?.toLocaleString() ?? t("usage.notReported")}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box className={classes.contextBreakdownCategories}>
|
||||
{categories.map((category) => (
|
||||
<Box key={category.label}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{category.label}
|
||||
</Text>
|
||||
<Text size="xs" fw={650}>
|
||||
{category.value?.toLocaleString() ?? t("usage.notReported")}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
width="min(28rem, calc(100vw - 1rem))"
|
||||
position="top-end"
|
||||
shadow="md"
|
||||
withinPortal
|
||||
trapFocus
|
||||
returnFocus
|
||||
>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
className={classes.composerContext}
|
||||
type="button"
|
||||
aria-label={quickLabel ? `${t("usage.contextWindow")}: ${quickLabel}` : t("usage.contextWindow")}
|
||||
title={quickLabel || t("usage.contextWindow")}
|
||||
aria-expanded={opened}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => setOpened((value) => !value)}
|
||||
>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<RingProgress
|
||||
size={22}
|
||||
thickness={3}
|
||||
roundCaps
|
||||
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
|
||||
/>
|
||||
<Text className={classes.composerContextLabel} component="span" size="xs" fw={650}>
|
||||
{hasContext ? `${contextPercentage}%` : t("mentions.context")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown className={classes.conversationContextPopover}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Box miw={0}>
|
||||
<Text size="sm" fw={700}>
|
||||
{t("usage.contextWindow")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("usage.requestDetails")}
|
||||
</Text>
|
||||
</Box>
|
||||
{hasContext && (
|
||||
<Badge size="sm" variant="light" color={getContextColor(contextPercentage)}>
|
||||
{contextPercentage}%
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Box className={classes.conversationUsageSummary}>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("usage.tokens")}
|
||||
</Text>
|
||||
<Text size="sm" fw={650}>
|
||||
{hasTokenUsage ? usage.totalTokens.toLocaleString() : t("usage.notReported")}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("usage.cost")}
|
||||
</Text>
|
||||
<Text size="sm" fw={650}>
|
||||
{hasCost ? formatCost(usage.cost) : t("usage.notReported")}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("usage.agentSteps")}
|
||||
</Text>
|
||||
<Text size="sm" fw={650}>
|
||||
{usage.turns.reduce((total, turn) => total + turn.telemetry.steps.length, 0).toLocaleString()}
|
||||
</Text>
|
||||
</div>
|
||||
</Box>
|
||||
<ConversationContextBreakdown breakdown={breakdown} />
|
||||
<Divider />
|
||||
<ScrollArea.Autosize mah="min(14rem, 30dvh)" type="auto" offsetScrollbars>
|
||||
<Stack gap={5}>
|
||||
{usage.turns.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" py="xs">
|
||||
{t("usage.notReported")}
|
||||
</Text>
|
||||
) : (
|
||||
usage.turns.map((turn, index) => (
|
||||
<ConversationTurn
|
||||
key={turn.requestId}
|
||||
index={index}
|
||||
turn={turn}
|
||||
threadId={threadId ?? undefined}
|
||||
messageId={messageIdByRequestId.get(turn.requestId)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -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<void>;
|
||||
onModelChange: (modelId: string) => void;
|
||||
onReasoningChange: (reasoning: AssistantReasoningMode) => void;
|
||||
}
|
||||
@@ -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<HTMLInputElement>(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 (
|
||||
<ThreadListItemPrimitive.Root className={classes.historyItem}>
|
||||
<TextInput
|
||||
ref={renameInputRef}
|
||||
className={classes.historyRenameInput}
|
||||
value={nextTitle}
|
||||
onChange={(event) => 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);
|
||||
}}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="green"
|
||||
size="sm"
|
||||
aria-label={t("renameConversation.save")}
|
||||
disabled={!nextTitle.trim()}
|
||||
loading={renaming}
|
||||
loaderProps={{ type: "bars" }}
|
||||
onClick={() => void renameConversation()}
|
||||
>
|
||||
<IconCheck size={15} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label={actionT("cancel")}
|
||||
disabled={renaming}
|
||||
onClick={() => setRenameOpened(false)}
|
||||
>
|
||||
<IconX size={15} />
|
||||
</ActionIcon>
|
||||
</ThreadListItemPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreadListItemPrimitive.Root className={classes.historyItem}>
|
||||
<ThreadListItemPrimitive.Trigger asChild>
|
||||
<UnstyledButton className={classes.historyItemTrigger} title={title} onClick={onSelect}>
|
||||
<Text component="span" size="sm" fw={500} truncate>
|
||||
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
</ThreadListItemPrimitive.Trigger>
|
||||
{actionsOpened && (
|
||||
<Group className={classes.historyItemActions} gap={1} wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
title={t("renameConversation.action")}
|
||||
aria-label={t("renameConversation.action")}
|
||||
onClick={() => {
|
||||
setNextTitle(title ?? "");
|
||||
setRenameOpened(true);
|
||||
}}
|
||||
>
|
||||
<IconPencil size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
loading={exporting}
|
||||
loaderProps={{ type: "bars" }}
|
||||
disabled={!remoteId}
|
||||
title={t("exportConversation.action")}
|
||||
aria-label={t("exportConversation.action")}
|
||||
onClick={() => void exportConversation()}
|
||||
>
|
||||
<IconFileExport size={14} />
|
||||
</ActionIcon>
|
||||
<InlineConfirmActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
title={actionT("delete")}
|
||||
aria-label={actionT("delete")}
|
||||
confirmLabel={t("deleteConversation.description", { title: title ?? t("newConversation") })}
|
||||
confirmationAriaLabel={t("deleteConversation.title")}
|
||||
confirmationChildren={<IconCheck size={14} />}
|
||||
onConfirm={deleteConversation}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</InlineConfirmActionIcon>
|
||||
</Group>
|
||||
)}
|
||||
<ActionIcon
|
||||
className={classes.historyItemMenu}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
title={t("conversationActions")}
|
||||
aria-label={t("conversationActions")}
|
||||
aria-expanded={actionsOpened}
|
||||
onClick={() => setActionsOpened((current) => !current)}
|
||||
>
|
||||
{actionsOpened ? <IconX size={15} /> : <IconDotsVertical size={15} />}
|
||||
</ActionIcon>
|
||||
</ThreadListItemPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<HistorySelectContext.Provider value={onSelect}>
|
||||
<Stack className={classes.historyMenu} gap="xs" p="xs">
|
||||
<Group gap="xs" wrap="nowrap" px={4} pt={4}>
|
||||
<ThreadListPrimitive.New asChild>
|
||||
<Button variant="light" leftSection={<IconPlus size={16} />} fullWidth onClick={onSelect}>
|
||||
{t("newConversation")}
|
||||
</Button>
|
||||
</ThreadListPrimitive.New>
|
||||
</Group>
|
||||
<Group gap="xs" px="xs" mt="xs">
|
||||
<IconHistory size={14} />
|
||||
<Text size="sm" fw={600} c="dimmed">
|
||||
{t("conversations")}
|
||||
</Text>
|
||||
</Group>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={15} />}
|
||||
placeholder={t("searchConversations")}
|
||||
aria-label={t("searchConversations")}
|
||||
size="xs"
|
||||
/>
|
||||
<ScrollArea.Autosize mah="min(24rem, 55dvh)" type="auto" scrollbars="y" offsetScrollbars>
|
||||
<Stack gap={3}>
|
||||
<ThreadListPrimitive.Items>
|
||||
{({ threadListItem }) => {
|
||||
const itemTitle = threadListItem.title ?? t("newConversation");
|
||||
if (normalizedQuery && !itemTitle.toLocaleLowerCase().includes(normalizedQuery)) return null;
|
||||
return <ThreadListItem />;
|
||||
}}
|
||||
</ThreadListPrimitive.Items>
|
||||
{normalizedQuery && !hasMatchingConversation && (
|
||||
<Text size="xs" c="dimmed" ta="center" py="md">
|
||||
{t("noMatchingConversations")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
</HistorySelectContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const ConversationHistory = () => {
|
||||
const t = useI18n("assistant");
|
||||
const [opened, setOpened] = useState(false);
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
position="bottom-end"
|
||||
width="min(22rem, calc(100vw - 1rem))"
|
||||
shadow="md"
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>
|
||||
<Button
|
||||
className={classes.historyButton}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<IconHistory size={16} />}
|
||||
classNames={{ section: classes.historyButtonSection, label: classes.historyButtonLabel }}
|
||||
onClick={() => setOpened((current) => !current)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape" && opened) {
|
||||
event.stopPropagation();
|
||||
setOpened(false);
|
||||
}
|
||||
}}
|
||||
aria-label={opened ? t("closeHistory") : t("openHistory")}
|
||||
>
|
||||
{t("conversations")}
|
||||
</Button>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={0}>
|
||||
<ThreadHistory onSelect={() => setOpened(false)} />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<span
|
||||
className={classes.directiveChip}
|
||||
data-directive-type={directiveType}
|
||||
data-directive-id={directiveId}
|
||||
title={`${typeLabel}: ${label}`}
|
||||
>
|
||||
{safeIconUrl ? (
|
||||
<img className={classes.directiveChipImage} src={safeIconUrl} alt="" aria-hidden referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
<Icon size={12} aria-hidden />
|
||||
)}
|
||||
<span className={classes.directiveChipLabel}>{label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
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<HTMLAnchorElement>) =>
|
||||
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 <span>{children}</span>;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={httpLink ? "_blank" : undefined}
|
||||
rel={httpLink ? "noreferrer" : undefined}
|
||||
{...props}
|
||||
onMouseEnter={(event) => {
|
||||
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}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
const MarkdownTable = ({ children, ...props }: ComponentPropsWithoutRef<"table">) => (
|
||||
<div className={classes.markdownTableWrap}>
|
||||
<table {...props}>{children}</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
const MarkdownImage = ({ src, alt = "", ...props }: ComponentPropsWithoutRef<"img">) => {
|
||||
const t = useI18n("assistant.image");
|
||||
const safeSource = getSafeAssistantMarkdownImageSource(src);
|
||||
if (!safeSource) return null;
|
||||
|
||||
return (
|
||||
<AssistantImage
|
||||
{...props}
|
||||
source={safeSource}
|
||||
alt={alt}
|
||||
className={classes.markdownImage}
|
||||
loadingLabel={t("loading")}
|
||||
failedLabel={t("failed")}
|
||||
retryLabel={t("retry")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Pre>
|
||||
<Code>{code}</Code>
|
||||
</Pre>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Pre>
|
||||
<Code>{code}</Code>
|
||||
</Pre>
|
||||
}
|
||||
>
|
||||
<ShikiHighlighter
|
||||
className={classes.syntaxHighlighter}
|
||||
language={language === "unknown" ? "text" : language}
|
||||
theme={colorScheme === "dark" ? "github-dark" : "github-light"}
|
||||
showLanguage={false}
|
||||
addDefaultStyles={false}
|
||||
>
|
||||
{code}
|
||||
</ShikiHighlighter>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Group component="output" className={classes.mermaidStatus} gap="xs">
|
||||
<Loader type="bars" size="xs" />
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("markdown.renderingDiagram")}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
if (rendered.failed || !rendered.svg) {
|
||||
const Pre = components.Pre;
|
||||
const Code = components.Code;
|
||||
return (
|
||||
<Box className={classes.mermaidFallback}>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t("markdown.diagramUnavailable")}
|
||||
</Text>
|
||||
<Pre>
|
||||
<Code>{code}</Code>
|
||||
</Pre>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
component="figure"
|
||||
className={classes.mermaidDiagram}
|
||||
aria-label={t("markdown.diagram")}
|
||||
dangerouslySetInnerHTML={{ __html: rendered.svg }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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<AssistantDirectiveEntitiesState>({
|
||||
entities: [],
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
export const AssistantDirectiveEntitiesProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { data: entities = [], isLoading } = clientApi.assistant.getContextEntities.useQuery(undefined, {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<AssistantDirectiveEntitiesContext.Provider value={{ entities, isLoading }}>
|
||||
{children}
|
||||
</AssistantDirectiveEntitiesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<ContextDirectiveChip
|
||||
directiveId={entity?.id ?? directiveId}
|
||||
directiveType={directiveType}
|
||||
label={entity?.label ?? directiveLabel}
|
||||
iconUrl={entity?.iconUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span {...props}>{children}</span>;
|
||||
};
|
||||
|
||||
const assistantMarkdownComponents = unstable_memoizeMarkdownComponents({
|
||||
a: MarkdownLink,
|
||||
img: MarkdownImage,
|
||||
span: AssistantMarkdownSpan,
|
||||
table: MarkdownTable,
|
||||
SyntaxHighlighter: AssistantSyntaxHighlighter,
|
||||
});
|
||||
|
||||
export const AssistantTextPart = () => (
|
||||
<MarkdownTextPrimitive
|
||||
className={classes.messageMarkdown}
|
||||
preprocess={normalizeAssistantMarkdown}
|
||||
remarkPlugins={markdownRemarkPlugins}
|
||||
components={assistantMarkdownComponents}
|
||||
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
|
||||
defer
|
||||
/>
|
||||
);
|
||||
|
||||
export const UserTextPart = () => {
|
||||
const { text } = useMessagePartText();
|
||||
const { entities } = useContext(AssistantDirectiveEntitiesContext);
|
||||
const segments = parseAssistantDirectives(text);
|
||||
return (
|
||||
<span className={classes.messageText}>
|
||||
{segments.map((segment, index) => {
|
||||
if (segment.kind === "text") return <span key={`${index}:${segment.text}`}>{segment.text}</span>;
|
||||
const entity = resolveAssistantDirectiveEntity(entities, segment);
|
||||
return (
|
||||
<ContextDirectiveChip
|
||||
key={`${index}:${segment.type}:${segment.id}`}
|
||||
directiveId={entity?.id ?? segment.id}
|
||||
directiveType={segment.type}
|
||||
label={entity?.label ?? segment.label}
|
||||
iconUrl={entity?.iconUrl}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const AttachmentPreview = () => {
|
||||
const attachment = useAuiState((state) => state.attachment);
|
||||
const [filePreview, setFilePreview] = useState<string | null>(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 <Box component="img" className={classes.attachmentImage} src={source} alt="" aria-hidden />;
|
||||
}
|
||||
return (
|
||||
<Box className={classes.attachmentFileIcon}>
|
||||
{attachment.type === "image" ? <IconPhoto size={18} /> : <IconFile size={18} />}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const Attachment = ({ removable = false }: { removable?: boolean }) => {
|
||||
const t = useI18n("assistant");
|
||||
const attachment = useAuiState((state) => state.attachment);
|
||||
return (
|
||||
<AttachmentPrimitive.Root className={classes.attachment}>
|
||||
<AttachmentPreview />
|
||||
<Box className={classes.attachmentCopy}>
|
||||
<Text size="xs" fw={600} lineClamp={1} className={classes.attachmentName}>
|
||||
<AttachmentPrimitive.Name />
|
||||
</Text>
|
||||
{attachment.contentType && (
|
||||
<Text size="xs" truncate className={classes.attachmentType}>
|
||||
{attachment.contentType}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{attachment.status.type === "running" && <Loader type="bars" size="xs" />}
|
||||
{removable && (
|
||||
<AttachmentPrimitive.Remove asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="xs" aria-label={t("removeAttachment")}>
|
||||
<IconX size={12} />
|
||||
</ActionIcon>
|
||||
</AttachmentPrimitive.Remove>
|
||||
)}
|
||||
</AttachmentPrimitive.Root>
|
||||
);
|
||||
};
|
||||
export const SentAttachment = () => <Attachment />;
|
||||
export const HiddenMessagePart = () => null;
|
||||
|
||||
export const ReasoningPart = (_props: ReasoningMessagePartProps) => (
|
||||
<AssistantDirectiveEntitiesContext.Provider value={{ entities: [], isLoading: false }}>
|
||||
<MarkdownTextPrimitive
|
||||
className={`${classes.messageMarkdown} ${classes.reasoningText}`}
|
||||
preprocess={normalizeAssistantMarkdown}
|
||||
remarkPlugins={markdownRemarkPlugins}
|
||||
components={assistantMarkdownComponents}
|
||||
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
|
||||
/>
|
||||
</AssistantDirectiveEntitiesContext.Provider>
|
||||
);
|
||||
|
||||
export const AssistantMessagePending = ({ status }: EmptyMessagePartProps) => {
|
||||
const t = useI18n("assistant");
|
||||
if (status.type !== "running") return null;
|
||||
|
||||
return (
|
||||
<Group component="output" className={classes.messagePending} gap="xs" wrap="nowrap" aria-live="polite">
|
||||
<AssistantDotMatrix state="thinking" role="presentation" aria-hidden />
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("activity.thinking")}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
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 = (
|
||||
<>
|
||||
<IconLink size={13} />
|
||||
{source.title}
|
||||
</>
|
||||
);
|
||||
|
||||
return safeUrl ? (
|
||||
<Anchor className={classes.source} href={safeUrl} target="_blank" rel="noreferrer" size="xs">
|
||||
{content}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text component="span" className={classes.source} c="dimmed" size="xs">
|
||||
{content}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export const FilePart = ({ data, filename, mimeType }: FileMessagePartProps) => {
|
||||
const t = useI18n("assistant");
|
||||
const downloadable = data.startsWith("data:");
|
||||
const displayName = filename ?? mimeType;
|
||||
return (
|
||||
<Group className={classes.messageFile} gap="xs" wrap="nowrap">
|
||||
<IconFile size={16} />
|
||||
<Text size="xs" lineClamp={1} flex={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
{downloadable && (
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={data}
|
||||
download={filename ?? "assistant-file"}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="xs"
|
||||
aria-label={t("downloadFile", { name: displayName })}
|
||||
>
|
||||
<IconDownload size={13} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export const ImagePart = ({ image, filename }: ImageMessagePartProps) => {
|
||||
const t = useI18n("assistant");
|
||||
const source = getSafeAssistantAttachmentImageSource(
|
||||
image,
|
||||
typeof window === "undefined" ? undefined : window.location.origin,
|
||||
);
|
||||
if (!source) {
|
||||
return (
|
||||
<Group className={classes.messageFile} gap="xs" wrap="nowrap">
|
||||
<IconAlertTriangle size={16} />
|
||||
<Text size="xs">{t("externalImageBlocked")}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AssistantImage
|
||||
messagePart
|
||||
source={source}
|
||||
alt={filename ?? t("attachedImage")}
|
||||
caption={filename}
|
||||
className={classes.messageImage}
|
||||
loadingLabel={t("image.loading")}
|
||||
failedLabel={t("image.failed")}
|
||||
retryLabel={t("image.retry")}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
onDismiss={() => setOpened(false)}
|
||||
position="bottom-end"
|
||||
width="min(19rem, calc(100vw - 1rem))"
|
||||
shadow="md"
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
variant={enabled ? "light" : "subtle"}
|
||||
color={enabled ? "green" : "gray"}
|
||||
onClick={() => setOpened((current) => !current)}
|
||||
aria-label={t("label")}
|
||||
aria-pressed={enabled}
|
||||
title={enabled ? t("enabled") : t("label")}
|
||||
>
|
||||
<IconShieldCheck size={17} />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="sm">
|
||||
<Box>
|
||||
<Text size="sm" fw={700}>
|
||||
{t("title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={3}>
|
||||
{t("description")}
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
fullWidth
|
||||
variant={enabled ? "default" : "light"}
|
||||
color={enabled ? "gray" : "green"}
|
||||
leftSection={<IconShieldCheck size={16} />}
|
||||
onClick={() => {
|
||||
setEnabled(!enabled);
|
||||
setOpened(false);
|
||||
}}
|
||||
>
|
||||
{enabled ? t("disable") : t("enable")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export const ViewRefreshAction = ({
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
}: Pick<AssistantConversationControls, "isRefreshing" | "onRefresh">) => {
|
||||
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 (
|
||||
<Tooltip label={isRefreshing ? t("working") : t("action")}>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
loading={isRefreshing}
|
||||
loaderProps={{ type: "bars" }}
|
||||
onClick={() => void refresh()}
|
||||
aria-label={isRefreshing ? t("working") : t("action")}
|
||||
>
|
||||
<IconRefresh size={17} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmptyThread = () => {
|
||||
const t = useI18n("assistant");
|
||||
return (
|
||||
<ThreadPrimitive.Empty>
|
||||
<Box className={classes.empty}>
|
||||
<Stack align="center" gap="lg" maw={560} w="100%">
|
||||
<Stack align="center" gap="xs" maw={430}>
|
||||
<ThemeIcon size={52} radius="xl" variant="light">
|
||||
<IconRobot size={27} />
|
||||
</ThemeIcon>
|
||||
<Text size="xl" fw={700}>
|
||||
{t("emptyTitle")}
|
||||
</Text>
|
||||
<Text size="sm" className={classes.emptyDescription}>
|
||||
{t("emptyDescription")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box className={classes.suggestions}>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send={false} clearComposer asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className={classes.suggestion}
|
||||
leftSection={<IconActivityHeartbeat size={18} />}
|
||||
>
|
||||
{t("suggestions.health.label")}
|
||||
</Button>
|
||||
</ThreadPrimitive.Suggestion>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send={false} clearComposer asChild>
|
||||
<Button variant="default" className={classes.suggestion} leftSection={<IconApps size={18} />}>
|
||||
{t("suggestions.explore.label")}
|
||||
</Button>
|
||||
</ThreadPrimitive.Suggestion>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send={false} clearComposer asChild>
|
||||
<Button variant="default" className={classes.suggestion} leftSection={<IconSearch size={18} />}>
|
||||
{t("suggestions.media.label")}
|
||||
</Button>
|
||||
</ThreadPrimitive.Suggestion>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send={false} clearComposer asChild>
|
||||
<Button variant="default" className={classes.suggestion} leftSection={<IconPalette size={18} />}>
|
||||
{t("suggestions.style.label")}
|
||||
</Button>
|
||||
</ThreadPrimitive.Suggestion>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</ThreadPrimitive.Empty>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<string, AssistantRuntimeModelOption[]>());
|
||||
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 (
|
||||
<Combobox store={modelCombobox} onOptionSubmit={selectModel} withinPortal position="top-start" width={340}>
|
||||
<Combobox.Target>
|
||||
<UnstyledButton
|
||||
className={classes.runtimeSelectorTrigger}
|
||||
type="button"
|
||||
disabled={modelOptionsLoading || models.length === 0}
|
||||
onClick={() => modelCombobox.toggleDropdown()}
|
||||
aria-label={`${t("runtime.model")}: ${selectedModel?.name ?? t("runtime.noModels")}. ${t("runtime.thinking")}: ${t(`runtime.reasoning.${reasoning}`)}`}
|
||||
aria-expanded={modelCombobox.dropdownOpened}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{modelOptionsLoading && <Loader size={12} />}
|
||||
<Text className={classes.runtimeSelectorName} size="xs" fw={650} lineClamp={1}>
|
||||
{selectedModel?.name ?? t("runtime.model")}
|
||||
</Text>
|
||||
<Badge className={classes.runtimeSelectorEffort} size="xs" variant="light" color="gray">
|
||||
{t(`runtime.reasoning.${reasoning}`)}
|
||||
</Badge>
|
||||
<Combobox.Chevron size="xs" />
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Combobox.Target>
|
||||
<Combobox.Dropdown className={classes.modelDropdown}>
|
||||
<Combobox.Search
|
||||
value={modelSearch}
|
||||
onChange={(event) => {
|
||||
setModelSearch(event.currentTarget.value);
|
||||
modelCombobox.updateSelectedOptionIndex();
|
||||
}}
|
||||
placeholder={t("runtime.searchModels")}
|
||||
aria-label={t("runtime.searchModels")}
|
||||
size="xs"
|
||||
/>
|
||||
<Combobox.Options className={classes.modelOptions}>
|
||||
{[...groupedModels.entries()].map(([provider, providerModels]) => (
|
||||
<Combobox.Group key={provider} label={provider}>
|
||||
{providerModels.map((model) => (
|
||||
<Combobox.Option
|
||||
className={classes.modelOption}
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
active={model.id === modelId}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Divider
|
||||
className={classes.modelOptionDivider}
|
||||
orientation="vertical"
|
||||
data-active={model.id === modelId || undefined}
|
||||
/>
|
||||
<Stack gap={1} className={classes.modelOptionText}>
|
||||
<Text size="sm" fw={model.id === modelId ? 650 : 500} lineClamp={1}>
|
||||
{model.name}
|
||||
</Text>
|
||||
<Text size="xs" className={classes.modelOptionDescription} lineClamp={1}>
|
||||
{model.description?.trim() || model.id}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Group className={classes.modelOptionMeta} gap={4} wrap="nowrap">
|
||||
{model.inputModalities.includes("image") && (
|
||||
<Tooltip label={t("runtime.imageInput")}>
|
||||
<IconPhoto size={14} aria-label={t("runtime.imageInput")} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{model.contextLength && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{formatCompactModelNumber(model.contextLength)}
|
||||
</Badge>
|
||||
)}
|
||||
{model.id === modelId && <IconCheck size={15} className={classes.runtimeOptionCheck} />}
|
||||
</Group>
|
||||
</Group>
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</Combobox.Group>
|
||||
))}
|
||||
{visibleModels.length === 0 && <Combobox.Empty>{t("runtime.noModels")}</Combobox.Empty>}
|
||||
</Combobox.Options>
|
||||
<Divider />
|
||||
<Box className={classes.reasoningSelector}>
|
||||
<Text size="xs" fw={650} mb={5}>
|
||||
{t("runtime.thinking")}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
className={classes.reasoningSegmentedControl}
|
||||
value={reasoning}
|
||||
onChange={(value) => {
|
||||
if (assistantReasoningModes.includes(value as AssistantReasoningMode)) {
|
||||
onReasoningChange(value as AssistantReasoningMode);
|
||||
}
|
||||
}}
|
||||
size="xs"
|
||||
fullWidth
|
||||
data={assistantReasoningModes.map((mode) => ({
|
||||
value: mode,
|
||||
label: t(`runtime.reasoning.${mode}`),
|
||||
}))}
|
||||
/>
|
||||
</Box>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
};
|
||||
|
||||
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 ? (
|
||||
<time dateTime={quota.resetsAt} title={new Date(quota.resetsAt).toLocaleString(locale)}>
|
||||
{new Date(quota.resetsAt).toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
timeZoneName: "short",
|
||||
})}
|
||||
</time>
|
||||
) : null;
|
||||
let quotaContent: ReactNode;
|
||||
if (!preferences.providerUser) {
|
||||
quotaContent = (
|
||||
<>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("providerQuota.signInDescription")}
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
loading={preferences.quotaLoading}
|
||||
onClick={() => void preferences.signInToProvider().catch(() => undefined)}
|
||||
>
|
||||
{t("providerQuota.signIn")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
} else if (quota) {
|
||||
quotaContent = (
|
||||
<>
|
||||
<Box>
|
||||
<Group justify="space-between" gap="xs" mb={5}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("providerQuota.dailyAllowance")}
|
||||
</Text>
|
||||
<Text size="xs" fw={650}>
|
||||
{quota.remaining} / {quota.limit}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={percentage} color={color} size="sm" aria-label={label} />
|
||||
</Box>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t.rich("providerQuota.reset", {
|
||||
relative: resetRelative,
|
||||
time: () => resetTime,
|
||||
})}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("providerQuota.toolCalls")}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
quotaContent = (
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
loading={preferences.quotaLoading}
|
||||
onClick={() => void preferences.refreshQuota()}
|
||||
>
|
||||
{t("providerQuota.retry")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover position="top" width={290} shadow="md" withinPortal>
|
||||
<Popover.Target>
|
||||
<UnstyledButton className={classes.providerQuotaTrigger} data-level={level} type="button" aria-label={label}>
|
||||
{preferences.quotaLoading && !preferences.quota ? <Loader size={14} /> : <Icon size={16} aria-hidden />}
|
||||
<Text component="span" size="xs" fw={650}>
|
||||
{preferences.quota?.remaining ?? "–"}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon color={color} variant="light" size="sm" radius="xl">
|
||||
<Icon size={15} aria-hidden />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text size="sm" fw={700}>
|
||||
{t("providerQuota.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{preferences.providerUser?.name || t("providerQuota.communityWorkshop")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
{preferences.quota && (
|
||||
<Badge color={color} variant="light" size="sm">
|
||||
{t(`providerQuota.level.${level}`)}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{quotaContent}
|
||||
{preferences.quotaError && (
|
||||
<Text size="xs" c="red">
|
||||
{preferences.quotaError}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Text size="sm" className={classes.toolResultText}>
|
||||
{presentation.text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (presentation.type === "properties") {
|
||||
return (
|
||||
<Box className={classes.toolResultProperties}>
|
||||
{presentation.fields.map((field) => (
|
||||
<Box key={field.label} className={classes.toolResultProperty}>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{field.label}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} lineClamp={2}>
|
||||
{formatToolResultValue(field.value)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (presentation.type === "icons") {
|
||||
if (presentation.items.length === 0) return null;
|
||||
const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length);
|
||||
|
||||
return (
|
||||
<Box className={classes.toolResultIconCollection}>
|
||||
{presentation.items.map((item) => (
|
||||
<Box key={`${item.repository ?? "icon"}-${item.url}`} className={classes.toolResultIconItem}>
|
||||
<Box className={classes.toolResultIconPreview}>
|
||||
<Image
|
||||
src={item.url}
|
||||
alt={item.name}
|
||||
className={classes.toolResultIconImage}
|
||||
fit="contain"
|
||||
loading="lazy"
|
||||
/>
|
||||
</Box>
|
||||
<Group gap={4} mt="xs" wrap="nowrap" justify="space-between">
|
||||
<Text size="xs" fw={650} lineClamp={1} title={item.name}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="gray" flex="0 0 auto">
|
||||
{item.variant}
|
||||
</Badge>
|
||||
</Group>
|
||||
{item.repository && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1} title={item.repository}>
|
||||
{item.repository}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{hiddenCount > 0 && (
|
||||
<Badge className={classes.toolResultMore} size="sm" variant="outline" color="gray">
|
||||
+{hiddenCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const hiddenCount = Math.max(0, presentation.totalCount - presentation.items.length);
|
||||
return (
|
||||
<Box className={classes.toolResultCollection}>
|
||||
{presentation.items.map((item, index) => (
|
||||
<Box key={`${item.title}-${index}`} className={classes.toolResultItem}>
|
||||
<Group gap="xs" justify="space-between" wrap="nowrap">
|
||||
<Box className={classes.toolResultIdentity}>
|
||||
<Text size="sm" fw={650} lineClamp={1}>
|
||||
{item.title}
|
||||
</Text>
|
||||
{item.description && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
{item.badges.length > 0 && (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{item.badges.map((badge) => (
|
||||
<Badge key={badge} size="xs" variant="light" color="gray">
|
||||
{badge}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
{item.fields.length > 0 && (
|
||||
<Group gap="xs" mt={6} wrap="wrap">
|
||||
{item.fields.map((field) => (
|
||||
<Text key={field.label} size="xs" c="dimmed">
|
||||
{field.label}:{" "}
|
||||
<Text component="span" inherit c="var(--mantine-color-text)" fw={600}>
|
||||
{formatToolResultValue(field.value)}
|
||||
</Text>
|
||||
</Text>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{hiddenCount > 0 && (
|
||||
<Badge className={classes.toolResultMore} size="sm" variant="outline" color="gray">
|
||||
+{hiddenCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const getToolResultNavigation = (result: unknown) => {
|
||||
if (typeof result !== "object" || result === null || Array.isArray(result)) return [];
|
||||
|
||||
const record = result as Record<string, unknown>;
|
||||
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 (
|
||||
<Group className={classes.toolResultNavigation} gap="xs" wrap="wrap">
|
||||
{links.map((link) => (
|
||||
<Button
|
||||
key={`${link.translationKey}-${link.href}`}
|
||||
component="a"
|
||||
href={link.href}
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconLink size={13} aria-hidden />}
|
||||
onMouseEnter={() => router.prefetch(link.href)}
|
||||
onFocus={() => router.prefetch(link.href)}
|
||||
onClick={(event) => {
|
||||
if (hasModifiedLinkClick(event)) return;
|
||||
event.preventDefault();
|
||||
router.push(link.href);
|
||||
}}
|
||||
>
|
||||
{t(link.translationKey)}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolResultPreview = ({ result, toolName }: { result: unknown; toolName: string }) => {
|
||||
const navigation = getToolResultNavigation(result);
|
||||
if (navigation.length === 0) return <ToolResultPresentation result={result} toolName={toolName} />;
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<ToolResultPresentation result={result} toolName={toolName} />
|
||||
<ToolResultNavigation result={result} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Box className={classes.tool} data-compact={compactPresentation || undefined}>
|
||||
<UnstyledButton
|
||||
className={classes.toolHeader}
|
||||
onClick={() => setOpened((current) => !current)}
|
||||
aria-expanded={opened}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon size="sm" radius="xl" variant="light" color={statusColor}>
|
||||
<StatusIcon size={13} />
|
||||
</ThemeIcon>
|
||||
<Box miw={0}>
|
||||
<Text size={compactPresentation ? "xs" : "sm"} fw={650} lineClamp={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
{compactPresentation && traceTarget && (
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{traceTarget}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{duration !== undefined && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{duration < 1000 ? `${duration} ms` : `${(duration / 1000).toFixed(1)} s`}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{statusLabel}
|
||||
</Text>
|
||||
<IconChevronDown
|
||||
size={14}
|
||||
className={classes.disclosureIcon}
|
||||
data-opened={opened || awaitingApproval || undefined}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
{awaitingApproval && (
|
||||
<Box className={classes.approvalPanel}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("approvalDescription")}
|
||||
</Text>
|
||||
<ToolResultPreview result={args} toolName={toolName} />
|
||||
{autoApprovalInProgress ? (
|
||||
<Group className={classes.autoApprovalProgress} gap="sm" wrap="nowrap">
|
||||
<Loader type="bars" size="sm" color="green" />
|
||||
<Text size="sm" fw={600}>
|
||||
{t("autoApproval.approving")}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Group className={classes.approvalActions} gap="sm" grow wrap="nowrap">
|
||||
<Button
|
||||
size="md"
|
||||
fullWidth
|
||||
leftSection={<IconCheck size={18} />}
|
||||
loading={approvalResponse === "approve"}
|
||||
disabled={approvalResponse !== null}
|
||||
onClick={() => {
|
||||
setApprovalResponse("approve");
|
||||
respondToApproval({ approved: true });
|
||||
}}
|
||||
>
|
||||
{t("approveAndRun")}
|
||||
</Button>
|
||||
<Button
|
||||
size="md"
|
||||
fullWidth
|
||||
variant="default"
|
||||
leftSection={<IconX size={18} />}
|
||||
loading={approvalResponse === "deny"}
|
||||
disabled={approvalResponse !== null}
|
||||
onClick={() => {
|
||||
setApprovalResponse("deny");
|
||||
respondToApproval({ approved: false });
|
||||
}}
|
||||
>
|
||||
{t("deny")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{!compactPresentation && successful && result !== undefined && (
|
||||
<ToolResultPreview result={result} toolName={toolName} />
|
||||
)}
|
||||
<Collapse expanded={opened}>
|
||||
<Stack gap="xs" mt="sm">
|
||||
{compactPresentation && successful && result !== undefined && (
|
||||
<ToolResultPreview result={result} toolName={toolName} />
|
||||
)}
|
||||
<Box>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("toolInput")}
|
||||
</Text>
|
||||
<Text className={classes.codeBlock} size="xs" component="pre">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
{!denied && result !== undefined && (
|
||||
<Box>
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("toolOutput")}
|
||||
</Text>
|
||||
<Text className={classes.codeBlock} size="xs" component="pre">
|
||||
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const AgentTraceToolGroup = ({ children }: { children?: ReactNode }) => (
|
||||
<AgentTraceToolContext.Provider value>
|
||||
<Stack className={classes.agentTraceTools} gap={4}>
|
||||
{children}
|
||||
</Stack>
|
||||
</AgentTraceToolContext.Provider>
|
||||
);
|
||||
|
||||
const ChainOfThoughtLayout = ({ children }: { children?: ReactNode }) => (
|
||||
<Stack className={classes.reasoningParts} gap={6}>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
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 (
|
||||
<ChainOfThoughtPrimitive.Root className={classes.reasoning} data-opened={!collapsed || undefined}>
|
||||
<ChainOfThoughtPrimitive.AccordionTrigger asChild>
|
||||
<UnstyledButton
|
||||
className={classes.reasoningToggle}
|
||||
type="button"
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={contentId}
|
||||
onClick={() => setPreferredCollapsed(!collapsed)}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{(running || chainStatus.type === "requires-action") && (
|
||||
<AssistantDotMatrix state={running ? "thinking" : "waiting"} role="presentation" aria-hidden />
|
||||
)}
|
||||
<Text size="xs" fw={650} c="dimmed">
|
||||
{t("reasoning")}
|
||||
</Text>
|
||||
</Group>
|
||||
<IconChevronDown size={14} className={classes.reasoningChevron} />
|
||||
</UnstyledButton>
|
||||
</ChainOfThoughtPrimitive.AccordionTrigger>
|
||||
<Box id={contentId} className={classes.reasoningContent} aria-busy={running} hidden={collapsed}>
|
||||
<ChainOfThoughtLayout>{children}</ChainOfThoughtLayout>
|
||||
</Box>
|
||||
</ChainOfThoughtPrimitive.Root>
|
||||
);
|
||||
};
|
||||
+33
-178
@@ -1,16 +1,10 @@
|
||||
# Homarr Workshop
|
||||
|
||||
Workshop is the public, community-driven PocketBase service behind the Workshop pages in Homarr's Docusaurus site. It
|
||||
stores Custom JSX v2 widgets and Custom CSS submissions. Installation-time validation inside Homarr remains
|
||||
authoritative: public content must be reviewed before it is installed.
|
||||
Workshop is the PocketBase service behind the community pages in Homarr's documentation site. It stores Custom JSX v2
|
||||
widgets and Custom CSS submissions. Homarr validates community content again before installation.
|
||||
|
||||
The runtime base is PocketBase 0.39.9 pinned by its multi-platform OCI digest in `apps/workshop/Dockerfile`. Update the
|
||||
version and digest together, then run the Workshop integration and production-image smoke suites.
|
||||
|
||||
The internal PocketBase field `users.isAdmin` means **Workshop moderator**. It does not grant Homarr administrator
|
||||
access. Signed-in moderators can open `/workshop/admin` from the visible **Moderation** button, review private report
|
||||
details, dismiss reports, and remove submissions or comments. Public submission cards show only the open report count;
|
||||
the submission author can read the report category and explanation without receiving the reporter identity.
|
||||
The PocketBase version and multi-platform image digest are pinned in `apps/workshop/Dockerfile`; update and validate
|
||||
them together.
|
||||
|
||||
## Run locally
|
||||
|
||||
@@ -22,39 +16,28 @@ pnpm install --frozen-lockfile
|
||||
docker compose --env-file apps/workshop/.env -f apps/workshop/docker-compose.yml up --build workshop
|
||||
```
|
||||
|
||||
This starts persistent PocketBase at `http://127.0.0.1:8090`. Start Docusaurus in another terminal:
|
||||
Start the documentation site in another terminal:
|
||||
|
||||
```sh
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
- PocketBase administration: `http://127.0.0.1:8090/_/`
|
||||
- Workshop: `http://127.0.0.1:3003/workshop`
|
||||
|
||||
Create the first PocketBase superuser in the administration UI, or use:
|
||||
Open PocketBase administration at `http://127.0.0.1:8090/_/` and Workshop at
|
||||
`http://127.0.0.1:3003/workshop`. Create the first PocketBase superuser in the administration UI or run:
|
||||
|
||||
```sh
|
||||
docker compose -f apps/workshop/docker-compose.yml exec workshop \
|
||||
pocketbase superuser create you@example.com 'replace-with-a-strong-password' --dir=/pb_data
|
||||
```
|
||||
|
||||
Stopping the Compose process preserves `homarr-workshop_pb_data`. To inspect or stop the service separately:
|
||||
Useful service commands:
|
||||
|
||||
```sh
|
||||
docker compose -f apps/workshop/docker-compose.yml logs -f workshop
|
||||
docker compose -f apps/workshop/docker-compose.yml stop workshop
|
||||
```
|
||||
|
||||
### Pre-release migration reset
|
||||
|
||||
The v2 branch consolidates the unreleased Workshop migrations into one initial migration. A PocketBase volume that ran
|
||||
an earlier v2 Workshop migration chain is unsupported. Back up anything you need, then use the intentional local reset
|
||||
command near the end of this document and start the stack again. Startup never deletes a volume automatically. This
|
||||
exception applies only to disposable pre-release volumes and must not be used for production data.
|
||||
|
||||
## Connect the Homarr application
|
||||
## Connect Homarr
|
||||
|
||||
Use the same URL contract in the repository-root `.env`, then restart Homarr:
|
||||
|
||||
@@ -66,54 +49,26 @@ WORKSHOP_PUBLIC_ORIGIN=http://127.0.0.1:3003
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
```
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
```
|
||||
`WORKSHOP_API_URL` controls API calls, `WORKSHOP_WEB_URL` controls links from Homarr, and
|
||||
`HOMARR_WEBSITE_URL` is the documentation-site base. All three must be absolute HTTP(S) URLs.
|
||||
|
||||
`WORKSHOP_API_URL` controls API calls. `WORKSHOP_WEB_URL` controls links from Homarr. `HOMARR_WEBSITE_URL` is the
|
||||
documentation/site base. All three must be absolute HTTP(S) URLs. The old Docusaurus-only `WORKSHOP_URL` variable
|
||||
remains a deprecated alias for one release. The production image writes these values into the browser runtime
|
||||
configuration at container startup, so changing them requires a restart but not an image rebuild.
|
||||
## OAuth and moderators
|
||||
|
||||
## Optional GitHub OAuth
|
||||
|
||||
Create a GitHub OAuth application with this callback:
|
||||
For optional GitHub OAuth, set both `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`. The callback is:
|
||||
|
||||
```text
|
||||
https://<workshop-host>/api/oauth2-redirect
|
||||
```
|
||||
|
||||
Set both credentials in `.env`:
|
||||
After a user signs in, a PocketBase superuser can promote them by setting `users.isAdmin` to `true`. This grants
|
||||
Workshop moderation only; it does not grant Homarr administrator access. The user must sign in again after the change.
|
||||
|
||||
```dotenv
|
||||
GITHUB_CLIENT_ID=...
|
||||
GITHUB_CLIENT_SECRET=...
|
||||
```
|
||||
The public community uses `PB_ALLOWED_ORIGINS=*` because self-hosted Homarr instances can run on any origin. Private
|
||||
deployments can use an explicit comma-separated allowlist.
|
||||
|
||||
OAuth settings synchronize on every PocketBase startup. Restart after rotation. A production deployment must never
|
||||
supply only one credential. `PB_ALLOWED_ORIGINS=*` is appropriate for the central community because arbitrary
|
||||
self-hosted Homarr origins must open its OAuth popup; a restricted list must include every allowed browser origin.
|
||||
Workshop stores only the OAuth provider username in `users.name`; users cannot rename it. PocketBase maps
|
||||
**OAuth2 username** to `name` and leaves the OAuth2 id, full name, and avatar mappings empty. GitHub profiles
|
||||
and avatars are derived from `https://github.com/<username>` and `https://github.com/<username>.png`, so no duplicate
|
||||
profile metadata or avatar file is stored.
|
||||
## Validate changes
|
||||
|
||||
## Promote a Workshop moderator
|
||||
|
||||
Sign in once through GitHub so the user record exists. In the PocketBase administration UI, open the `users` collection
|
||||
and set `isAdmin` to `true`. The user must sign in again. Set it back to `false` to remove moderator access.
|
||||
|
||||
Regular API rules cannot set this field. Never describe a Workshop moderator as a Homarr administrator: they are
|
||||
separate roles in separate services.
|
||||
|
||||
## Validation and tests
|
||||
|
||||
The official browser and shared client validate content with the canonical TypeScript schema and send the matching
|
||||
schema identifier. PocketBase stores submission content without parsing, normalizing, or validating it; it only owns
|
||||
revision fields and rejects stale compare-and-swap updates. Homarr validates downloaded content again before
|
||||
installation, so direct API submissions that bypass the official client can be rejected there.
|
||||
|
||||
Run the disposable integration suite:
|
||||
Run the focused Workshop checks:
|
||||
|
||||
```sh
|
||||
pnpm test:workshop
|
||||
@@ -121,125 +76,25 @@ pnpm --filter @homarr/workshop typecheck
|
||||
pnpm --filter @homarr/docs typecheck
|
||||
```
|
||||
|
||||
The integration suite uses a separate Compose project and deletes only its disposable volume.
|
||||
The integration suite uses a disposable Compose project. `pnpm test:workshop-image` additionally builds and smoke-tests
|
||||
the combined production image.
|
||||
|
||||
To smoke-test a deployed Workshop against its real configured upstream, use a dedicated low-quota test user:
|
||||
## Data safety
|
||||
|
||||
```sh
|
||||
HOMARR_AI_LIVE_SMOKE=1 \
|
||||
WORKSHOP_LIVE_TEST_URL=https://workshop.example.com \
|
||||
WORKSHOP_LIVE_TEST_TOKEN=<temporary-pocketbase-user-token> \
|
||||
pnpm test:workshop-live-provider
|
||||
```
|
||||
PocketBase data lives in `/pb_data`. Normal start, stop, and down operations retain the named volume. Back up the
|
||||
database before migrations or image promotion, and test restores in a separate empty volume.
|
||||
|
||||
This opt-in check consumes one real request. It verifies the stable model contract, an authenticated completion, quota
|
||||
headers, and server-side user accounting without requiring the OpenRouter key or upstream model on the test client.
|
||||
|
||||
## Backup and restore
|
||||
|
||||
PocketBase data lives in `/pb_data`. Stop writes and copy it into a new, empty host directory for each
|
||||
filesystem-level backup:
|
||||
|
||||
```sh
|
||||
docker compose -f apps/workshop/docker-compose.yml stop workshop
|
||||
docker compose -f apps/workshop/docker-compose.yml cp workshop:/pb_data/. ./workshop-backup
|
||||
docker compose -f apps/workshop/docker-compose.yml start workshop
|
||||
```
|
||||
|
||||
Never copy a backup over a non-empty PocketBase volume: files created after the backup could survive and make the
|
||||
restore inconsistent. For a development restore, first verify that the selected backup contains the database, then
|
||||
empty the stopped volume before copying it:
|
||||
|
||||
```sh
|
||||
test -f ./workshop-backup/data.db
|
||||
docker compose -f apps/workshop/docker-compose.yml stop workshop
|
||||
docker compose -f apps/workshop/docker-compose.yml run --rm --no-deps --entrypoint sh workshop \
|
||||
-c 'find /pb_data -mindepth 1 -maxdepth 1 -exec rm -rf {} +'
|
||||
docker compose -f apps/workshop/docker-compose.yml cp ./workshop-backup/. workshop:/pb_data
|
||||
docker compose -f apps/workshop/docker-compose.yml start workshop
|
||||
curl --fail http://127.0.0.1:8090/api/health
|
||||
curl --fail 'http://127.0.0.1:8090/api/collections/workshop_listings/records?perPage=1'
|
||||
```
|
||||
|
||||
The cleanup command irreversibly empties the selected Compose volume, so confirm the backup path and preserve the
|
||||
current volume separately before running it. After the HTTP checks, verify sign-in, reports, and a test installation.
|
||||
Production backups must be versioned, access-controlled, restored into a clean staging volume, and validated before a
|
||||
release.
|
||||
|
||||
## Production image and operations
|
||||
|
||||
The production target serves the built documentation from `/pb_public` and PocketBase from the same image. Required
|
||||
production configuration:
|
||||
|
||||
```dotenv
|
||||
HOMARR_WEBSITE_URL=https://homarr.dev
|
||||
WORKSHOP_API_URL=https://homarr.dev
|
||||
WORKSHOP_WEB_URL=https://homarr.dev/workshop
|
||||
WORKSHOP_PUBLIC_ORIGIN=https://homarr.dev
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
```
|
||||
|
||||
### Deploy a v2 staging or demo instance
|
||||
|
||||
The staging Compose file runs the published `ghcr.io/homarr-labs/workshop:v2` image, persists `/pb_data`, binds only to
|
||||
the host loopback interface, and expects TLS to terminate at a reverse proxy. It does not need a repository checkout to
|
||||
run; copy `docker-compose.staging.yml` and `.env.staging.example` to the deployment host, then:
|
||||
|
||||
```sh
|
||||
cp .env.staging.example .env.staging
|
||||
docker compose --env-file .env.staging -f docker-compose.staging.yml pull
|
||||
docker compose --env-file .env.staging -f docker-compose.staging.yml up --detach
|
||||
docker compose --env-file .env.staging -f docker-compose.staging.yml ps
|
||||
curl --fail http://127.0.0.1:8090/api/health
|
||||
```
|
||||
|
||||
The checked-in example is ready for `https://v2.preview.homarr.dev`:
|
||||
|
||||
```dotenv
|
||||
HOMARR_WEBSITE_URL=https://v2.preview.homarr.dev
|
||||
WORKSHOP_API_URL=https://v2.preview.homarr.dev
|
||||
WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop
|
||||
WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
```
|
||||
|
||||
Proxy the entire `v2.preview.homarr.dev` hostname to `http://127.0.0.1:8090`, including `/api`, `/_/`, and
|
||||
`/workshop-runtime-config.js`; do not proxy only `/workshop`. The combined image owns both the Docusaurus pages and
|
||||
PocketBase routes. If the hostname already serves the Homarr application, use a dedicated Workshop hostname instead
|
||||
and set all four public URL variables to that hostname (with `/workshop` only on `WORKSHOP_WEB_URL`).
|
||||
|
||||
The `v2` image tag is intentionally convenient and mutable for previews. Run `pull` followed by `up --detach` to test a
|
||||
new v2 image while retaining the named PocketBase volume. Use the immutable `sha-<commit>` image tag when promoting a
|
||||
tested build. The public central Workshop needs `PB_ALLOWED_ORIGINS=*` because Homarr instances on arbitrary origins
|
||||
call the API and open the OAuth flow; a private demo can instead use a comma-separated allowlist of its browser origins.
|
||||
|
||||
After startup, open `https://v2.preview.homarr.dev/_/` to create the first PocketBase superuser. If GitHub OAuth is
|
||||
enabled, configure its callback as `https://v2.preview.homarr.dev/api/oauth2-redirect`, set both GitHub credentials, and
|
||||
restart the service. Verify `/workshop`, `/api/health`, listing requests, sign-in, and one installation from the Homarr
|
||||
preview before sharing the demo.
|
||||
|
||||
Build and smoke-test the combined image locally:
|
||||
|
||||
```sh
|
||||
docker compose -f apps/workshop/docker-compose.yml --profile docs up --build --detach docs
|
||||
curl --fail http://127.0.0.1:3003/api/health
|
||||
curl --fail http://127.0.0.1:3003/
|
||||
curl --fail http://127.0.0.1:3003/workshop
|
||||
docker compose -f apps/workshop/docker-compose.yml --profile docs stop docs
|
||||
```
|
||||
|
||||
`pnpm test:workshop-image` builds the production target, starts it with the preview URL contract, and proves that the
|
||||
browser runtime configuration, Workshop page, health endpoint, migrations, public listing endpoint, and cross-origin
|
||||
browser access all work.
|
||||
|
||||
Normal `up`, `start`, `stop`, and `down` operations retain the named volume. The following command is an intentional,
|
||||
irreversible local reset:
|
||||
The v2 branch consolidates the unreleased Workshop migrations. Volumes that ran an earlier v2 migration chain are
|
||||
unsupported and must be backed up and recreated. The following command permanently removes the local Workshop volume:
|
||||
|
||||
```sh
|
||||
docker compose -f apps/workshop/docker-compose.yml down --volumes
|
||||
```
|
||||
|
||||
Never run the reset command against production. Release by immutable image digest, back up application and PocketBase
|
||||
data first, rehearse restore and rollback in staging, and promote the exact tested digest. The Algolia recrawl workflow
|
||||
remains manual until the external Homarr website host can dispatch it after promotion; publishing the image to GHCR does
|
||||
not make that image live.
|
||||
Never run that reset against production.
|
||||
|
||||
## Production
|
||||
|
||||
The production image serves the documentation site and PocketBase from one origin. See the
|
||||
[Workshop operator guide](../docs/docs/workshop/operator.mdx) for environment variables, proxying, staging, backups,
|
||||
and promotion. Publish by immutable digest, validate that exact digest in staging, then promote it.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.74.0",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
|
||||
@@ -67,7 +67,7 @@ Credentials are stored separately and never exported or returned to an agent. A
|
||||
|
||||
export const CUSTOM_WIDGET_SKILL_MD = `---
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user