Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
24294d139c | ||
|
|
cebb5dc2bb | ||
|
|
3318ddc8c3 | ||
|
|
e656731729 | ||
|
|
2257fe2a08 | ||
|
|
673589f6c8 | ||
|
|
c642f9a8d3 | ||
|
|
83968be07e | ||
|
|
019bd11017 | ||
|
|
c16e00f94c | ||
|
|
bffa3e723c | ||
|
|
0774b02878 | ||
|
|
47c349bcd4 | ||
|
|
874432becf | ||
|
|
958cc68cba | ||
|
|
abaf845e94 | ||
|
|
ea9075bde5 | ||
|
|
4ebaea29fd | ||
|
|
165201632d | ||
|
|
bc59355eab |
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"label": "Advanced",
|
||||
"collapsed": true,
|
||||
"position": 4,
|
||||
"position": 5,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"title": "Advanced Concepts or features"
|
||||
|
||||
@@ -57,50 +57,8 @@ Homarr becomes the instance administrator.
|
||||
Use `pnpm docker:dev:up` instead when you only need Redis in the background. Running the migration also seeds the
|
||||
default data; use `pnpm db:seed` to seed it explicitly later.
|
||||
|
||||
### Run PocketBase and documentation
|
||||
|
||||
Start PocketBase in another terminal:
|
||||
|
||||
```sh
|
||||
cp apps/workshop/.env.example apps/workshop/.env
|
||||
docker compose --env-file apps/workshop/.env -f apps/workshop/docker-compose.yml up --build workshop
|
||||
```
|
||||
|
||||
Then start Docusaurus in a third terminal:
|
||||
|
||||
```sh
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
|
||||
```
|
||||
|
||||
PocketBase runs on port 8090 and Docusaurus on port 3003. Stopping the services preserves the PocketBase volume.
|
||||
|
||||
Open `http://127.0.0.1:8090/_/` and create the first PocketBase superuser. The Workshop UI is available at
|
||||
`http://127.0.0.1:3003/workshop`.
|
||||
|
||||
Point Homarr at the local services with:
|
||||
|
||||
```dotenv
|
||||
HOMARR_WEBSITE_URL=http://127.0.0.1:3003
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090
|
||||
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
|
||||
```
|
||||
|
||||
Restart `pnpm dev` after changing `.env`. GitHub OAuth is optional locally. See
|
||||
[`apps/workshop/README.md`](https://github.com/homarr-labs/homarr/blob/HEAD/apps/workshop/README.md) for OAuth,
|
||||
Workshop moderator promotion, logs, tests, backup, restore, stopping, and intentional volume reset.
|
||||
|
||||
### URL testing
|
||||
|
||||
Docusaurus reads `HOMARR_WEBSITE_URL` and `WORKSHOP_API_URL` when it starts or builds. Homarr also reads
|
||||
`WORKSHOP_WEB_URL` when its server starts, so these values do not rely on `NEXT_PUBLIC_*` build variables. Test a
|
||||
separate API origin before release:
|
||||
|
||||
```sh
|
||||
HOMARR_WEBSITE_URL=http://127.0.0.1:3003 \
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 \
|
||||
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop \
|
||||
pnpm dev:docs
|
||||
```
|
||||
Workshop contributors can set up PocketBase and Docusaurus with the dedicated
|
||||
[Workshop development guide](../../workshop/development).
|
||||
|
||||
### Useful commands
|
||||
|
||||
@@ -112,7 +70,6 @@ pnpm dev:docs
|
||||
- `pnpm dev:cli -- rebuild <name>` — rebuild an image from its recorded checkout or pull request.
|
||||
- `pnpm dev:cli:install` — optionally install the developer CLI as a `homarr` binary.
|
||||
- `pnpm dev:docs` — Docusaurus only on port 3003.
|
||||
- `pnpm docker:workshop` — local PocketBase through Docker Compose.
|
||||
- `pnpm docker:dev:up` — Redis development service in the background.
|
||||
- `pnpm cli` — Homarr operations CLI.
|
||||
- `pnpm db:migration:sqlite:run` — create or update a SQLite database.
|
||||
@@ -122,7 +79,6 @@ pnpm dev:docs
|
||||
- `pnpm lint` / `pnpm lint:fix` — check or apply oxlint.
|
||||
- `pnpm typecheck` — typecheck all workspaces.
|
||||
- `pnpm test` — unit and contract suites.
|
||||
- `pnpm test:workshop` — disposable Workshop integration suite.
|
||||
- `pnpm build` — production monorepo build.
|
||||
- `docker build -t homarr .` — build a production Docker image.
|
||||
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>` — run it.
|
||||
@@ -37,6 +37,9 @@ Using the `PUID` and `PGID` will require you to set the correct permissions on t
|
||||
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone documentation. | HTTP(S) URL | `HOMARR_WEBSITE_URL` |
|
||||
| `WORKSHOP_WEB_URL` | Public Workshop page opened from Homarr. | HTTP(S) URL | `HOMARR_WEBSITE_URL/workshop` |
|
||||
|
||||
Workshop service operators can find its server-only configuration in the
|
||||
[Workshop operator guide](../../workshop/operator#environment-variables).
|
||||
|
||||
## Authentication
|
||||
|
||||
See [Single Sign-On](/docs/advanced/single-sign-on) for more informations.
|
||||
@@ -109,12 +112,9 @@ External redis is currently not supported with Proxmox Community Scripts
|
||||
The advanced deployments environment variables should only be used if you know what you are doing.
|
||||
:::
|
||||
|
||||
| Environment Variable | Description |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. |
|
||||
| `WORKSHOP_PUBLIC_ORIGIN` | Origin used in PocketBase-generated links and email. It must be set explicitly in production and must match the externally reachable Workshop origin. |
|
||||
| `PB_ALLOWED_ORIGINS` | Browser origins accepted by PocketBase. Use `*` for the central public community so arbitrary self-hosted Homarr instances can use the API and OAuth; private staging may use a comma-separated origin allowlist. |
|
||||
| Environment Variable | Description |
|
||||
| ------------------------ | --------------------------------------------------- |
|
||||
| `DB_MIGRATIONS_DISABLED` | Disable db migrations. For example for helm charts. |
|
||||
|
||||
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
|
||||
trailing slashes when Homarr or Docusaurus starts. The Docusaurus-only `WORKSHOP_URL` name is accepted as a deprecated alias for
|
||||
`WORKSHOP_API_URL` for one release. `WORKSHOP_API_URL` takes precedence.
|
||||
trailing slashes when Homarr or Docusaurus starts.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"label": "Community",
|
||||
"position": 5,
|
||||
"position": 6,
|
||||
"collapsed": true,
|
||||
"link": {
|
||||
"type": "generated-index"
|
||||
|
||||
@@ -1,308 +1,85 @@
|
||||
---
|
||||
description: Configure and use Homarr Assistant.
|
||||
tags:
|
||||
- Assistant
|
||||
- AI
|
||||
- Administration
|
||||
- Security
|
||||
---
|
||||
|
||||
# Assistant
|
||||
|
||||
Administrators can configure the instance-wide AI assistant from **Management → Assistant**.
|
||||
It is available to signed-in users from the user menu, with `Shift + A`, and from the
|
||||
`Cmd/Ctrl + K` command menu. **Ask Homarr Assistant** is always available as a local result in the default
|
||||
search. Selecting it with an existing query sends that query in the background; selecting it without a
|
||||
query turns the command-menu search field into a prompt composer. Press
|
||||
`Enter` to send the prompt in the background without opening the conversation. The search field receives
|
||||
focus whenever the command menu opens, including when it is opened by an assistant tool. Sending from the
|
||||
command menu preserves any draft in the expanded conversation. The local result is temporarily disabled while
|
||||
the assistant is responding or waiting for an approval.
|
||||
Homarr Assistant can answer questions about your Homarr instance and use Homarr tools on your behalf. It follows the
|
||||
permissions of the signed-in user and asks for approval before it changes anything.
|
||||
|
||||
While a response is running, a compact activity bar appears at the bottom of the page and the user-menu
|
||||
avatar shows a thinking indicator. The activity bar can be opened to follow the response. Completed
|
||||
responses add an unread count to the user menu until they are opened or dismissed. The expanded
|
||||
conversation has a minimize action that returns it to the activity bar without interrupting a response.
|
||||
Its adjacent close action hides the panel without leaving an activity bar behind. Both window actions use
|
||||
subtle buttons with an icon, tooltip, and accessible label.
|
||||
The transcript follows streamed output while it is already at the bottom, including fast responses. Scrolling
|
||||
up releases that follow state until **Scroll to latest** is selected or the user returns to the bottom.
|
||||

|
||||
|
||||
`Shift + A` opens the compact conversation panel directly. The shortcut is ignored while typing in text
|
||||
fields, selectors, and editable content.
|
||||
## Open the assistant
|
||||
|
||||
The **Assistant** board widget embeds the same persisted conversation and full interaction surface directly
|
||||
on a dashboard. By default it follows the current conversation. Widget settings can instead pin a specific
|
||||
conversation; the conversation picker stays hidden until **Pinned conversation** is selected.
|
||||
The Shift+A panel is non-modal: opening it focuses its composer, but it does not capture focus from the board.
|
||||
Users can continue typing in an Assistant widget or interacting with other dashboard controls while the panel
|
||||
is open. Every visible assistant surface keeps its own draft, attachment list, selection, and focus while sharing
|
||||
the same persisted conversation. Switching conversations restores the draft that belongs to that surface and
|
||||
conversation instead of copying text into another widget or the floating panel.
|
||||
- Select **Assistant** in the user menu.
|
||||
- Press `Shift + A` from anywhere in Homarr.
|
||||
- Type a question in `Cmd/Ctrl + K`, then select **Ask Homarr Assistant**.
|
||||
- Add the **Assistant** widget to a board for a permanent conversation view.
|
||||
|
||||
When the assistant is unavailable, its command-menu result remains visible but disabled and explains that
|
||||
an administrator must configure and enable it.
|
||||
You can minimize a conversation while it works. The activity bar shows its progress and the user menu shows unread
|
||||
responses.
|
||||
|
||||
The assistant uses the assistant-ui runtime for **AI SDK v7**. Homarr tracks the newest mutually compatible
|
||||
package set permitted by its supply-chain release-age policy. Persisted message rows can still report the upstream
|
||||
codec name `ai-sdk/v6`; assistant-ui's current v7 adapter deliberately retains that identifier for its compatible
|
||||
UI-message wire format. It does not mean the application is running AI SDK v6.
|
||||
## What it can do
|
||||
|
||||
## Configure a model provider
|
||||
- Find and update boards, apps, integrations, widgets, and dashboard settings.
|
||||
- Check downloads, media, Docker containers, service health, and other connected integrations.
|
||||
- Create formatted notes and Custom Widgets.
|
||||
- Search the web when OpenRouter web search is enabled.
|
||||
- Show tool activity, reasoning summaries, usage, time, and cost when the provider reports them.
|
||||
|
||||
Homarr includes presets for OpenRouter, OpenAI, Anthropic Claude, Google Gemini, xAI, Groq, Mistral AI,
|
||||
DeepSeek, Together AI, Ollama, and LM Studio. Provider icons make the presets easy to identify. A custom
|
||||
preset connects any other service that implements the OpenAI Chat Completions API.
|
||||
Use `@` to add an app, board, integration, widget, or tool as context. Use `/` for common prompts. You can also paste or
|
||||
attach images and text files when the selected model supports them.
|
||||
|
||||
The provider list also shows **Homarr — Coming soon** as a disabled option.
|
||||
## Configure the assistant
|
||||
|
||||
The management page presents setup as three compact sections: provider and endpoint, credentials, then
|
||||
model and availability. A status summary at the top shows whether the connection is ready, whether a key
|
||||
is protected, and which model is selected. Redundant active-state badges are omitted when the setup is ready.
|
||||
Open **Management → Assistant**.
|
||||
|
||||
1. Choose a provider preset or **Custom endpoint**, then confirm the API base URL. It usually ends in `/v1`.
|
||||
2. Add an API key if the service requires bearer authentication. Additional authentication or routing
|
||||
headers are available in the collapsed advanced section.
|
||||
3. Save the provider connection.
|
||||
4. Refresh the model list and select a discovered model. Homarr displays the friendly model name but
|
||||
stores and sends the provider's exact model ID. Context-window sizes use compact labels such as `1M`.
|
||||
5. Enable the assistant and save the configuration.
|
||||

|
||||
|
||||
The model discovery path defaults to `/models` and is appended to the API base URL. Leave it blank when
|
||||
the service does not expose model discovery. A manual model ID can still be used when discovery is
|
||||
disabled, unavailable, or returns no models.
|
||||
1. Select a provider.
|
||||
2. Check the API base URL and add an API key if required.
|
||||
3. Select **Save provider connection**.
|
||||
4. Refresh the model list and choose a model.
|
||||
5. Enable the assistant, then select **Save assistant**.
|
||||
|
||||
API keys and additional header values are encrypted in the Homarr database. They are decrypted only on
|
||||
the server for discovery and model requests, and they are never returned to the browser. Changing the
|
||||
provider or base URL clears existing credentials. This prevents credentials for one service from being sent
|
||||
to another endpoint.
|
||||
Homarr includes presets for OpenRouter, OpenAI, Claude, Gemini, xAI, Groq, Mistral, DeepSeek, Together AI, Ollama, and
|
||||
LM Studio. Use **Custom endpoint** for another OpenAI-compatible Chat Completions service.
|
||||
|
||||
When a key is already stored, the page shows its protected state instead of an ambiguous empty password
|
||||
field. Removing the API key requires explicit confirmation. Save that removal before entering and saving a
|
||||
different key.
|
||||
The [Homarr provider](../workshop/homarr-provider) does not require an administrator API key. Each user signs in to the
|
||||
Community Workshop and receives a separate daily allowance. A shared Workshop daily quota also applies, so requests
|
||||
can be rejected after that shared quota is exhausted.
|
||||
|
||||
:::info Container networking
|
||||
:::info Local model servers
|
||||
|
||||
For a containerized Homarr installation, `localhost` refers to the Homarr container. Use a resolvable
|
||||
container name, internal DNS name, or host gateway address to reach Ollama, LM Studio, or another local
|
||||
model server.
|
||||
Inside a container, `localhost` means the Homarr container. Use the model server's container name, local DNS name, or
|
||||
host gateway address instead.
|
||||
|
||||
:::
|
||||
|
||||
The configured endpoint must support streaming Chat Completions and structured tool calls for the full
|
||||
assistant experience. Model discovery alone does not guarantee tool support, except when OpenRouter
|
||||
explicitly advertises the model's `tools` capability.
|
||||
## Approvals and data
|
||||
|
||||
OpenRouter web search is an opt-in option in the model section. It registers OpenRouter's model-callable
|
||||
[`openrouter:web_search` server tool](https://openrouter.ai/docs/guides/features/server-tools/web-search)
|
||||
alongside Homarr's tools, so OpenRouter performs each search without
|
||||
exposing the stored credential to the browser. The model decides when live research is needed and can use the
|
||||
grounded results and source links in responses or dashboard notes. Homarr limits each assistant request to
|
||||
three searches with five results per search. OpenRouter charges web-search usage separately from model tokens.
|
||||
Completed responses show a compact web-search activity card when OpenRouter reports search usage. OpenRouter
|
||||
does not expose these server-side searches as ordinary client-executed tool calls, so Homarr shows the reported
|
||||
search count and the safe title and URL from each returned `url_citation` annotation instead. Citation excerpts
|
||||
stay in the provider response and are not persisted in the activity card.
|
||||
Read-only tools can run automatically. Changes show an approval card with the exact action before Homarr runs it.
|
||||
Normal Homarr permissions still apply.
|
||||
|
||||
The option is also available for custom endpoints that forward OpenRouter's server-tools request format. This
|
||||
supports OpenRouter proxies, including the future Homarr Router provider. Leave it disabled for ordinary
|
||||
OpenAI-compatible endpoints because the `openrouter:*` tool type is not part of the standard OpenAI API.
|
||||
API keys and custom headers are encrypted in the Homarr database and are never returned to the browser. Prompts,
|
||||
attachments, tool definitions, and tool results are sent to the configured model provider. Check that provider's data
|
||||
policy before enabling the assistant.
|
||||
|
||||
The Claude preset uses Anthropic's OpenAI SDK compatibility layer. Anthropic describes that layer as
|
||||
suited to testing and comparison rather than as its full native API; unsupported OpenAI fields may be
|
||||
ignored. Streaming responses and tool calls are supported, which are the capabilities Homarr Assistant
|
||||
uses. Choose OpenRouter or another provider when you prefer a provider-neutral production endpoint.
|
||||
Conversations are stored per user. Users can edit or retry messages, copy responses, export a conversation as Markdown,
|
||||
and delete conversations.
|
||||
|
||||
## Conversations and tools
|
||||
## If setup does not work
|
||||
|
||||
Conversations and messages are stored per user in the Homarr database. The assistant stays out of the
|
||||
page layout until it is opened. Users can start, revisit, and delete conversations from the
|
||||
conversation menu in the floating panel.
|
||||
- **No models found:** check the base URL, model discovery path, and API key. You can enter a model ID manually.
|
||||
- **The assistant is unavailable:** enable it and save the configuration.
|
||||
- **A model stops during a tool call:** use a model that supports streaming and structured tool calls.
|
||||
- **A local endpoint cannot be reached:** check container networking and the endpoint's listen address.
|
||||
|
||||
Messages support copy, edit-and-resend, response regeneration and branch navigation, Markdown export,
|
||||
and helpful/not-helpful feedback. The conversation menu also provides a compact export action beside delete for
|
||||
each conversation. Its debugging-oriented Markdown includes every stored user, assistant, reasoning, tool, error,
|
||||
branch-parent, and server-metadata record while redacting secret-looking fields and embedded attachment data.
|
||||
The per-response export uses the same tool-aware format instead of assistant-ui's text-only copy representation.
|
||||
Selecting text in a message exposes a quote action that carries the selected passage into the next prompt.
|
||||
Files returned by a compatible model can be downloaded directly from the message.
|
||||
Assistant responses render GitHub-flavored Markdown, including headings, links, lists, task lists, tables,
|
||||
quotes, inline code, and fenced code blocks. Line breaks are preserved, and Homarr repairs common streamed
|
||||
responses where a provider returns escaped line breaks or places a bold-label list on one line.
|
||||
Completed fenced code blocks use language-aware syntax highlighting; a streaming block stays plain until its
|
||||
content is stable. Safe same-origin links are prefetched and use client-side navigation, so opening a returned
|
||||
preview, editor, board, or other Homarr item does not unload the assistant. External links keep their normal
|
||||
external-browser behavior.
|
||||
Fenced `mermaid` blocks render as diagrams in the current color scheme using Mermaid's strict security mode;
|
||||
invalid diagrams fall back to their source instead of breaking the response.
|
||||
When Homarr's icon search returns an image URL, visual result tables render that actual icon in a compact
|
||||
image column instead of substituting an emoji. Assistant-provided Markdown images accept safe relative,
|
||||
HTTP, and HTTPS sources and load without forwarding the Homarr page as the referrer. Image message parts
|
||||
render through assistant-ui's image primitive in a bounded responsive preview with an accessible loading state,
|
||||
a filename caption when available, and an in-place retry when the source fails.
|
||||
Images up to 1 MB and text, Markdown, CSV, HTML, XML, or JSON documents up to 350 KB can be attached by
|
||||
using the paperclip, dropping files onto the composer, or pasting an image with `Cmd/Ctrl + V`. Attachment
|
||||
previews stay local to the composer where they were added and show upload state, file type, and a removable image
|
||||
thumbnail or document tile. A request can contain up to five attachments.
|
||||
Image understanding depends on the selected model; document text is extracted locally by Homarr and sent
|
||||
as part of the prompt.
|
||||
|
||||
Type `@` in the composer to add permission-checked context. The nested menu contains apps, integrations,
|
||||
boards, widgets, and tools available to the current user. Homarr validates every referenced ID again on
|
||||
the server before adding it to the model context. Selected references appear as compact, typed pills in the
|
||||
composer and sent message, while preserving assistant-ui's underlying directive syntax for the model. When the
|
||||
assistant cites one of those references in its own response, Homarr renders the same typed pill and uses the
|
||||
stored app or integration icon when one is available. Type `/`
|
||||
for common prompt shortcuts. Both menus support keyboard navigation and keep the active result visible while
|
||||
moving through long lists. The selector below the composer combines searchable, provider-grouped models with
|
||||
their description, context size and image-input capability, plus the thinking-level control. Model selection is
|
||||
stored on the conversation after a completed request. Thinking mode can be left on **Auto**, disabled, or set
|
||||
from minimal through high; explicit levels are sent using the AI SDK's provider-neutral reasoning setting and
|
||||
still depend on support from the chosen model and endpoint. Reasoning text is shown live when the provider streams
|
||||
it through assistant-ui's grouped-parts and chain-of-thought primitives. The compact **Thinking** trace groups
|
||||
adjacent reasoning with ordinary tool calls, shows each tool's status, duration and safe human-readable target,
|
||||
and expands raw input, output and result previews only on request. Approval and question tools remain full-width
|
||||
outside the trace. The disclosure renders Markdown, can be opened or closed without interrupting the response,
|
||||
and keeps the user's collapsed state when another message starts. A compact dot matrix distinguishes an initial
|
||||
thinking pass from response streaming and settles into waiting, success, or error states in the minimized activity
|
||||
bar. The state always keeps its text label, and the animation is disabled when the operating system requests
|
||||
reduced motion.
|
||||
|
||||
A circular context ring beside the model selector gives a quick glance at cumulative conversation usage without
|
||||
expanding the composer toolbar. Its accessible label includes cumulative tokens and cost when available, while
|
||||
its popover keeps the latest complete context reading as a new response starts and lists every request
|
||||
turn with its model, provider, input, output, cached-input and reasoning tokens, context use, duration, speed,
|
||||
agent steps, routing details, and cost when reported. Before usage is available, the control remains a compact
|
||||
ring instead of showing a redundant missing-data label. Closing and reopening the popover reads persisted
|
||||
conversation metadata instead of resetting the display to zero. A larger ring inside the popover breaks the latest
|
||||
reported context into used, remaining and total capacity plus input, cached-input, output and reasoning tokens.
|
||||
|
||||
Each assistant response places a small provider logo in the same stable action toolbar as copy, export,
|
||||
feedback, and regenerate. Hovering the logo shows the provider, exact model, timing, throughput, and cost without
|
||||
adding another metadata row to the transcript. Branch navigation occupies its own adjacent group, so switching
|
||||
between response versions never pushes the response actions off-screen.
|
||||
|
||||
OpenRouter responses request inline usage and routing metadata. Opening detailed turn data also loads the
|
||||
server-side generation record for its model steps and briefly polls while OpenRouter finishes processing it.
|
||||
The details separate Homarr's end-to-end timing from OpenRouter's provider latency, generation time, and provider
|
||||
throughput, and can include normalized and native token counts, moderation latency, cache discount, fallback count,
|
||||
service tier, data region, BYOK status, routed provider, generation identifier, exact cost, and upstream inference
|
||||
cost. Provider credentials remain server-side; the browser can request only generation identifiers already stored
|
||||
on a conversation owned by the signed-in user and signed by the Homarr server for that user and conversation.
|
||||
Successful generation lookups are cached briefly so polling and multi-step requests do not repeatedly query
|
||||
OpenRouter for the same record.
|
||||
When a provider omits cost but model discovery includes prompt and completion pricing, Homarr labels the
|
||||
calculated value as estimated. Missing provider data is shown as not reported rather than inferred.
|
||||
|
||||
The assistant can use the same internal tool definitions as the [Homarr MCP server](./mcp).
|
||||
Each tool call runs as the signed-in user, so normal Homarr permission checks still apply. Read-only
|
||||
procedures can run during the agent loop. Mutating procedures require an explicit approval in the
|
||||
conversation before they execute. The approval appears as a large **Approve and run** or **Deny** action;
|
||||
the assistant does not require a second typed confirmation first. Tool cards expose their status, timing,
|
||||
validated input, result, and error state without flooding the conversation with raw JSON by default.
|
||||
Common collection and widget results are summarized inline as compact responsive cards, while the full
|
||||
structured result remains available in the tool card details. Icon searches show the returned image variants,
|
||||
file formats, and repositories instead of the size of the complete icon catalog. Their trace uses a readable
|
||||
activity such as **Looking for “homarr” icons** instead of exposing the internal MCP procedure name.
|
||||
|
||||
Large tool-driven requests can use up to 32 agent steps and a larger response budget within one request, so
|
||||
multi-item dashboard changes normally finish without requiring a follow-up “Continue” message. OpenRouter calls
|
||||
Homarr tools sequentially to prevent routed models from interleaving or truncating parallel JSON arguments, while
|
||||
every mutation still pauses for its normal approval. A safely recoverable truncated icon-search object is repaired
|
||||
before validation; incomplete search strings and all mutation inputs are never guessed.
|
||||
Homarr also normalizes internal tool results to the model's JSON-compatible message format before continuing an
|
||||
agent loop. Rich server values such as dates cannot invalidate the next model step, and an interrupted tool call
|
||||
without a result is omitted when the conversation resumes instead of making the entire prompt fail.
|
||||
|
||||
The assistant can create a formatted dashboard note by adding a **Notebook** widget. Ask it for a note with
|
||||
headings, lists, tasks, links, emphasis, tables, or images; it generates compatible rich-text content, shows
|
||||
the native widget configuration form and standard mutation approval, then places the widget in the next
|
||||
available dashboard position. The generated content is retained even though the Notebook content field is
|
||||
normally hidden from widget settings.
|
||||
|
||||
Automatic placement checks every responsive layout and treats both widgets and complete container footprints
|
||||
as occupied. New assistant-created items use the first free position in the main canvas instead of overlapping
|
||||
existing content or being placed in a gutter.
|
||||
|
||||
Every assistant-created dashboard widget now passes through Homarr's native widget editor before it is added.
|
||||
The form fills widget defaults, validates widget-specific options, and only offers compatible integrations that
|
||||
the current user can use. Required integrations cannot be left empty, single-integration widgets cannot receive
|
||||
multiple integrations, and the reviewed values are passed unchanged into the final approval-gated mutation.
|
||||
|
||||
Custom-widget tools let an administrator list, inspect, create, and update definitions. The custom-widget
|
||||
form's **Design with AI** action sends a focused request into the current conversation, where the assistant
|
||||
can propose the matching mutation and use the same approval controls. Existing stored secret values are
|
||||
reported only as configured/not configured and are preserved on update unless replacements are explicitly
|
||||
provided. For Custom Widget requests, Homarr lazily adds the complete installed skill, all bundled schema,
|
||||
runtime and security references, and the current JSON Schema to the trusted authoring context. This avoids an
|
||||
extra provider round trip for the large `customWidget_getSkill` and `customWidget_schema` results. If the model
|
||||
discovers the Custom Widget workflow mid-response, Homarr promotes the same resources before its next step and
|
||||
removes the now-redundant resource calls from provider history. Multiline JSX uses `templateLines` to avoid fragile
|
||||
streamed JSON escaping. Custom Widget authoring tools are mandatory: the assistant validates the complete candidate,
|
||||
creates a preview from that exact version, executes every returned preview query, checks the actual HTTP status and
|
||||
response shape, and repeats the entire validation and preview sequence after any material JSX, request, source, option,
|
||||
or binding change. It only reports a widget as validated, working, created, or placed when the corresponding tool result
|
||||
proves that checkpoint. Large widgets are saved from the exact final tested preview session, so the approved create step
|
||||
does not stream the complete JSX a second time. When the request already names a board, that board is carried into the
|
||||
create result and placement continues without asking the user to select it again. Preview and saved-definition results
|
||||
include safe Homarr paths that open in the assistant without a page reload. When placement is still a choice, the
|
||||
assistant uses the structured question UI instead of ending with a prose question.
|
||||
|
||||
The authoring phase limits broad component-document retrieval and then moves into validation with the smallest useful
|
||||
reference set. Supplied samples and successful preview responses are treated as binding contracts: the assistant is
|
||||
instructed to render the requested fields, guard optional nested data, preserve every returned item, expose freshness
|
||||
when timestamps are available, and provide a refresh path for recoverable empty or error states.
|
||||
|
||||
The shield control in the conversation header can enable automatic approvals for the current conversation.
|
||||
While enabled, Homarr accepts every affirmative approval step as it arrives: protected mutations are approved,
|
||||
presented app details and board changes are used, and structured questions with an explicitly categorized
|
||||
**Confirm** answer choose that answer. Neutral choices such as selecting a board still wait for input because
|
||||
they do not have a safe affirmative default. Automatic approvals turn off when you switch conversations, and
|
||||
the shared approval tracker prevents the same call from being handled twice when both a board widget and the
|
||||
floating assistant are visible.
|
||||
|
||||
When the assistant genuinely needs missing information or a choice, it can pause with a structured
|
||||
question. Each proposed answer is visibly categorized as **Confirm**, **Decline**, or **Option**, so the effect
|
||||
of automatic approval is unambiguous. Users can select one of the proposed answers or choose **Other** and
|
||||
enter their own response. While a question is unanswered, its controls stay in a dedicated area immediately
|
||||
above the composer instead of requiring the user to find the tool call in the transcript. The question temporarily
|
||||
uses the available conversation body, with its own scrolling when necessary, rather than squeezing a long option
|
||||
list into a short dock beside part of the transcript. The answered choice then remains in the conversation history.
|
||||
Submitting the answer resumes the same agent run automatically. The minimized activity bar shows the
|
||||
question or approval that needs attention and opens directly back to the request. While an answer, app
|
||||
form, or mutation approval is pending, the composer preserves its draft but does not replace the pending
|
||||
tool call with a new message. If the provider stops before an interactive tool supplies complete arguments,
|
||||
Homarr replaces the loading state with an explicit error and confirms that no change was made.
|
||||
|
||||
App creation uses Homarr's native app form inside the conversation. The form reuses the local icon picker,
|
||||
including automatic matching and searches through the `icon.findIcons` procedure, before the final
|
||||
`app.create` approval is requested. App fields are filled only after the complete tool input has arrived, so
|
||||
streaming partial arguments cannot replace the proposed values with empty fields. The model is instructed not
|
||||
to invent external icon CDN URLs.
|
||||
|
||||
Board appearance changes use a similar review flow. The assistant first reads the board's current settings,
|
||||
including its complete custom stylesheet, and then opens an editable form with Custom CSS, Appearance,
|
||||
Background, and General tabs. The form highlights how many fields were proposed, preserves settings the
|
||||
assistant did not change, and returns only values that differ from the current board. Custom CSS can be
|
||||
copied or restored before continuing, and Homarr warns when it contains an imported stylesheet or remote
|
||||
resource URL. Continuing from the form opens the normal mutation approval before anything is saved.
|
||||
|
||||
Ask for changes such as “make the cards denser on my Home board,” “add a mobile CSS rule without removing
|
||||
my existing styles,” or “change the board colors and disable app status checks.” The current user must have
|
||||
modify permission on the selected board. The empty conversation includes an **Improve a dashboard** starter,
|
||||
and `/style` prepares the same guided workflow from the composer.
|
||||
|
||||
Homarr also exposes a small set of browser-only actions to the assistant. These actions can navigate to
|
||||
safe internal routes, refresh the current view, and open Homarr interfaces such as the command menu and media request search.
|
||||
They never receive provider credentials and cannot navigate to arbitrary external URLs.
|
||||
|
||||
After an approved mutation completes, Homarr automatically refetches active page and dashboard data so
|
||||
board items, settings, custom CSS, apps, and other visible results update without a full browser reload.
|
||||
The refresh action in the conversation header performs the same safe refetch manually. It keeps the
|
||||
conversation open while refreshing both client queries and server-rendered route data.
|
||||
|
||||
:::warning
|
||||
|
||||
Assistant prompts and selected tool results are sent to the configured model endpoint. Review that
|
||||
service's privacy and retention terms before enabling this feature for an instance.
|
||||
|
||||
:::
|
||||
Homarr exposes its MCP-enabled procedures to external clients through the [MCP server](./mcp). MCP omits
|
||||
`customWidget_*` tools for non-admin API keys, while the built-in assistant adds browser actions and optional
|
||||
OpenRouter web search. Custom Widget authors can find the full workflow in
|
||||
[Agent authoring](./custom-widgets/agent-authoring).
|
||||
@@ -22,6 +22,8 @@ Navigate to `Management` → `Tools` → `Backup`. You must have **admin** permi
|
||||
|
||||
## Exporting a Backup
|
||||
|
||||

|
||||
|
||||
Click the **Export** button in the export card. You'll be prompted to type `I understand` to confirm. Once confirmed, a ZIP file downloads containing:
|
||||
|
||||
| File | Description |
|
||||
@@ -33,6 +35,8 @@ The backup contains the complete database. It includes Custom Widgets and all ot
|
||||
|
||||
## Restoring a Backup
|
||||
|
||||

|
||||
|
||||
### 1. Upload
|
||||
|
||||
Drag and drop or select a `.zip` backup file. The import card validates that the file contains a valid `db.sqlite` and metadata.
|
||||
|
||||
@@ -125,20 +125,33 @@ To exercise the real Homarr Assistant lifecycle instead of accepting a returned
|
||||
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 default generation and harsh review to OpenRouter's `~deepseek/deepseek-v4-flash-latest` route. They save
|
||||
tool traces, validated manifests, raw reviews, and detailed category scores under
|
||||
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 models or
|
||||
per-scenario limit with `OPENROUTER_GENERATOR_MODEL`, `OPENROUTER_JUDGE_MODEL`, and
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
---
|
||||
tags:
|
||||
- Workshop
|
||||
- Custom Widgets
|
||||
- Custom CSS
|
||||
---
|
||||
|
||||
# Community Workshop
|
||||
|
||||
:::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!
|
||||
|
||||
:::
|
||||
|
||||
The [Community Workshop](pathname:///workshop) is where Homarr users share and discover
|
||||
**custom CSS themes** and **custom widgets**. You can browse submissions, upvote the ones
|
||||
you like, report abuse, and install entries directly into your own Homarr instance.
|
||||
|
||||
The central `homarr.dev` Workshop is a public community. Browsing it on the website is public. Browsing, installing,
|
||||
publishing, or importing Workshop content **inside a Homarr instance is restricted to Homarr administrators**.
|
||||
|
||||
:::warning
|
||||
|
||||
The Workshop contains community-made content. Moderation is not a security boundary, and PocketBase does not interpret
|
||||
or validate submission content. Always review widget capabilities, network origins, actions, and CSS before installing.
|
||||
Homarr's installation-time validator is definitive and can reject content that the public service accepted earlier.
|
||||
Back up your instance before installing untrusted content.
|
||||
|
||||
:::
|
||||
|
||||
## Browsing and voting
|
||||
|
||||
- Filter by **Widgets** or **CSS**, show or hide outdated entries, and sort by popularity, creation date, recent updates, or discussion activity.
|
||||
- Sign in with **GitHub** to upvote/downvote, submit your own creations, or report a submission.
|
||||
- Each submission shows its title, description, linked GitHub author, revision, optional screenshots, comments, and full content. Widget pages also summarize API sources, actions, options, network access, and required credentials before installation.
|
||||
- A new submission receives one real upvote owned by its author. Votes remain unique per user and submission.
|
||||
- Open report counts are public on submission cards so visitors can make an informed installation decision. The submission author and Workshop moderators can read each report category and explanation; reporter identities remain moderator-only.
|
||||
- Signed-in moderators see a **Moderation** button in the Workshop header. The moderation queue is sorted by open report count and lets moderators inspect every report, dismiss it, or delete the submission. Reports do not automatically remove a submission.
|
||||
- The manual **Outdated** badge warns visitors that a submission may need an update.
|
||||
|
||||
## Comments
|
||||
|
||||
Comments are publicly readable and appear in chronological conversation bubbles with the author's avatar and GitHub profile. Signed-in users can post; authors can edit or delete their own comments, and Workshop moderators can delete any comment. Failed posts keep the text you typed. When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, the submission author receives one email for a comment from another user. Self-comments, edits, and deletions do not send mail.
|
||||
|
||||
## Submitting
|
||||
|
||||
1. Sign in with GitHub.
|
||||
2. Click **Share yours** and choose the type:
|
||||
- **Custom widget** — paste the JSON exported from **Manage → Custom Widgets → Export**.
|
||||
It must match the `homarr-custom-widget-v2` schema (validated before submission).
|
||||
- **Custom CSS** — paste your stylesheet (max 16384 characters).
|
||||
3. Review the widget JSON or CSS in the syntax-aware editor, then add a title, description, optional screenshots, and changelog.
|
||||
4. Submit the entry.
|
||||
|
||||
Custom widget JSON is normalized to readable multi-line formatting when it is published, and the Workshop detail page
|
||||
also formats older compact submissions before displaying them.
|
||||
|
||||
Community members can report submissions as outdated or no longer working, malicious, spam, copyright-infringing,
|
||||
inappropriate, or another concern. An outdated report does not automatically mark or remove the submission.
|
||||
|
||||
Submissions are published immediately. Reported entries are reviewed and may be removed.
|
||||
Authors can edit a submission later to publish a new revision. The edit form can keep or remove existing screenshots and add new PNG, JPG, or WebP images without changing the Workshop URL.
|
||||
|
||||
When SMTP and `WORKSHOP_PUBLIC_ORIGIN` are configured, every current Workshop moderator receives an email when a report is submitted. If a moderator deletes the reported submission, its author receives a separate removal email. Author self-deletion does not send a moderation email.
|
||||
|
||||
:::info Separate roles
|
||||
|
||||
A Workshop moderator is a user with PocketBase's internal `isAdmin` field. This role moderates the central community
|
||||
and is unrelated to a Homarr instance administrator.
|
||||
|
||||
:::
|
||||
|
||||
## Submission guidelines
|
||||
|
||||
Submissions must be useful to Homarr users and safe to install. Do not submit content that is
|
||||
malicious, deceptive, illegal, hateful, sexually explicit, gore, gambling-focused, spam, or content
|
||||
that primarily promotes unrelated products or services.
|
||||
|
||||
## Installing into Homarr
|
||||
|
||||
You can install workshop entries without leaving Homarr:
|
||||
|
||||
- **Custom widgets (Homarr administrators only)** — go to **Manage → Custom Widgets**, or use **Import from Workshop** in the
|
||||
board widget picker. Choose **Install widget**, review the complete source and capabilities, then confirm API sources and any local secrets in the second modal step.
|
||||
- **Custom CSS from Workshop (Homarr administrators only)** — open a board's **Settings → Custom CSS**, click **Import from Workshop**, choose a theme, review its full CSS in the second same-size modal step, and click **Import CSS** once. The board setting is still saved separately.
|
||||
|
||||
Alternatively, open a submission in the Workshop and use **Download widget JSON** or **Copy CSS**. The submission page shows the matching manual installation steps before the source code.
|
||||
|
||||
Custom Widget imports detect required bearer, basic, header, or query credentials. You can enter them during import or explicitly defer them; secret values are never stored in Workshop content. Missing credentials remain visible on the local Custom Widget afterward.
|
||||
|
||||
## Workshop deployment
|
||||
|
||||
Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile, Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions.
|
||||
|
||||
Submission links include Open Graph and X/Twitter metadata for the submission title, description, and type. The first uploaded screenshot is used as the social preview image; submissions without screenshots use the Homarr logo.
|
||||
|
||||
Run `pnpm docker:workshop` for PocketBase on port 8090, then run
|
||||
`WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs` for Docusaurus on port 3003. `pnpm docker:docs` builds the
|
||||
combined production layout. Normal start and stop commands retain data; `down --volumes` is an intentional,
|
||||
irreversible development reset.
|
||||
|
||||
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins unrestricted
|
||||
unless every self-hosted Homarr caller is explicitly listed. Configure both `GITHUB_CLIENT_ID` and
|
||||
`GITHUB_CLIENT_SECRET`; supplying only one is a startup error. Set `WORKSHOP_PUBLIC_ORIGIN` in production so generated
|
||||
links and email use the public host. Workshop stores only the OAuth provider username in `users.name`, and
|
||||
users cannot rename it. Map **OAuth2 username** to `name`; leave OAuth2 id, full name, and avatar mappings
|
||||
empty. Profile and avatar URLs are derived from `https://github.com/<username>` and
|
||||
`https://github.com/<username>.png`, so PocketBase does not store duplicate profile metadata or avatar files. Configure
|
||||
SMTP for comment, report, and removal notices.
|
||||
|
||||
The public URL contract is:
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
| ------------------------ | ------------------------------------------------- | ----------------------------- |
|
||||
| `HOMARR_WEBSITE_URL` | Documentation/site base used by Homarr links | `https://homarr.dev` |
|
||||
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone docs | `HOMARR_WEBSITE_URL` |
|
||||
| `WORKSHOP_WEB_URL` | Public Workshop page linked from Homarr | `HOMARR_WEBSITE_URL/workshop` |
|
||||
| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production |
|
||||
| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community |
|
||||
|
||||
The deprecated docs-only `WORKSHOP_URL` alias remains for one release; `WORKSHOP_API_URL` takes precedence. The
|
||||
combined Workshop image writes these public URLs into its browser runtime configuration whenever the container starts;
|
||||
the Docker build arguments remain fallbacks. Restart the container after changing these values, without rebuilding it.
|
||||
|
||||
### Staging and demo deployment
|
||||
|
||||
Use `apps/workshop/docker-compose.staging.yml` with the published `ghcr.io/homarr-labs/workshop:v2` image for a v2
|
||||
preview. Copy `apps/workshop/.env.staging.example` to `.env.staging`; its default public contract is:
|
||||
|
||||
```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=*
|
||||
```
|
||||
|
||||
Start or update it from the repository root:
|
||||
|
||||
```sh
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml pull
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml up --detach
|
||||
curl --fail http://127.0.0.1:8090/api/health
|
||||
```
|
||||
|
||||
The service binds to `127.0.0.1:8090`. Terminate TLS at a reverse proxy and forward the **entire hostname**, including
|
||||
`/api`, `/_/`, and `/workshop-runtime-config.js`; forwarding only `/workshop` breaks PocketBase and runtime URL loading.
|
||||
This combined image serves both the documentation site and PocketBase, so use a dedicated Workshop hostname if the
|
||||
chosen hostname already serves Homarr itself. In that case, replace the hostname in all four URL values and keep
|
||||
`/workshop` only on `WORKSHOP_WEB_URL`.
|
||||
|
||||
The Compose volume preserves PocketBase data across pulls and container recreation. The mutable `v2` tag is suitable
|
||||
for previews; use the `sha-<commit>` tag for promotion. For the public central Workshop, `PB_ALLOWED_ORIGINS=*` allows
|
||||
self-hosted Homarr origins to use the API and OAuth. A private demo may use a comma-separated origin allowlist instead.
|
||||
GitHub OAuth requires both credentials and the callback
|
||||
`https://v2.preview.homarr.dev/api/oauth2-redirect`. Restart 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.
|
||||
|
||||
## Client architecture
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"label": "Workshop",
|
||||
"collapsed": true,
|
||||
"position": 4,
|
||||
"link": {
|
||||
"type": "doc",
|
||||
"id": "workshop/index"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
---
|
||||
title: Workshop development
|
||||
sidebar_position: 4
|
||||
tags:
|
||||
- Workshop
|
||||
- Developer
|
||||
- Docusaurus
|
||||
- PocketBase
|
||||
---
|
||||
|
||||
# Workshop development
|
||||
|
||||
Install the repository dependencies and make sure Docker is available before starting the Workshop services. See the
|
||||
[main development guide](../advanced/development/getting-started) for the repository prerequisites.
|
||||
|
||||
## Run PocketBase and documentation
|
||||
|
||||
Start PocketBase in one terminal:
|
||||
|
||||
```sh
|
||||
cp apps/workshop/.env.example apps/workshop/.env
|
||||
docker compose --env-file apps/workshop/.env -f apps/workshop/docker-compose.yml up --build workshop
|
||||
```
|
||||
|
||||
Then start Docusaurus in another terminal:
|
||||
|
||||
```sh
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs
|
||||
```
|
||||
|
||||
PocketBase runs on port 8090 and Docusaurus on port 3003. Stopping the services preserves the PocketBase volume.
|
||||
|
||||
Open `http://127.0.0.1:8090/_/` and create the first PocketBase superuser. The Workshop UI is available at
|
||||
`http://127.0.0.1:3003/workshop`.
|
||||
|
||||
Point Homarr at the local services with:
|
||||
|
||||
```dotenv
|
||||
HOMARR_WEBSITE_URL=http://127.0.0.1:3003
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090
|
||||
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
|
||||
```
|
||||
|
||||
Restart `pnpm dev:docs` after changing the Docusaurus URL variables. If Homarr is also running, restart its `pnpm dev`
|
||||
process after changing the Workshop URLs. GitHub OAuth is optional locally. See
|
||||
[`apps/workshop/README.md`](https://github.com/homarr-labs/homarr/blob/HEAD/apps/workshop/README.md) for OAuth,
|
||||
Workshop moderator promotion, logs, tests, backup, restore, stopping, and intentional volume reset.
|
||||
|
||||
## URL testing
|
||||
|
||||
Docusaurus reads `HOMARR_WEBSITE_URL` and `WORKSHOP_API_URL` when it starts or builds. Homarr also reads
|
||||
`WORKSHOP_WEB_URL` when its server starts, so these values do not rely on `NEXT_PUBLIC_*` build variables. Test a
|
||||
separate API origin before release:
|
||||
|
||||
```sh
|
||||
HOMARR_WEBSITE_URL=http://127.0.0.1:3003 \
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090 \
|
||||
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop \
|
||||
pnpm dev:docs
|
||||
```
|
||||
|
||||
The [Workshop operator guide](./operator) covers production environment variables, staging, and service validation.
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
title: Homarr provider
|
||||
sidebar_position: 2
|
||||
tags:
|
||||
- Assistant
|
||||
- AI
|
||||
- Privacy
|
||||
- Community
|
||||
---
|
||||
|
||||
import { OpenCollectiveLink } from "@site/src/components/assistant-provider/open-collective-link";
|
||||
|
||||
# Homarr provider
|
||||
|
||||
The Homarr provider gives Workshop users access to the **Homarr model** without an administrator API key. The Homarr
|
||||
team selects a capable model based on quality, reliability, and cost, and can change the upstream model without
|
||||
changing the user-facing provider. Each user signs in with GitHub and receives a separate daily allowance.
|
||||
|
||||
## Set it up
|
||||
|
||||
1. Open **Management → Assistant**.
|
||||
2. Select **Homarr** as the provider.
|
||||
3. Save the provider connection.
|
||||
4. Select the available model, enable the assistant, and save.
|
||||
5. Open the assistant and sign in to the Workshop when asked.
|
||||
|
||||
## Daily allowance
|
||||
|
||||
- Each user starts with **50 request units per UTC day**.
|
||||
- Each model request uses one unit. Agent tool loops can make multiple model requests.
|
||||
- Once Workshop forwards a request, it uses one unit even if the upstream later fails. This prevents failed requests
|
||||
from bypassing the shared cost and abuse limits.
|
||||
- The allowance resets at 00:00 UTC.
|
||||
|
||||
The battery beside the model selector shows the remaining allowance and reset time. The Workshop server enforces the
|
||||
limit before it sends a request to OpenRouter.
|
||||
|
||||
## Data and cost
|
||||
|
||||
The Workshop keeps the OpenRouter API key on its server. It does not send that key to Homarr or the browser.
|
||||
|
||||
Prompts, attachments, tool definitions, and tool results are sent through OpenRouter to generate a response. The
|
||||
Workshop proxy does not retain that content, responses, or per-request telemetry. It stores only the Workshop user,
|
||||
UTC day, and counters required to enforce per-user and global daily allowances. Requests require OpenRouter routing
|
||||
with zero data retention and data collection disabled. Homarr does not use this content to train language models.
|
||||
|
||||
We would rather become millionaires in a more ethical manner.
|
||||
|
||||
The Homarr community pays for these requests. Donations help cover the cost.
|
||||
|
||||
<OpenCollectiveLink />
|
||||
|
||||
Workshop operators can change the upstream model, per-user allowance, global allowance, and security limits in the
|
||||
[Workshop operator guide](./operator#homarr-provider-proxy).
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: Community Workshop
|
||||
sidebar_label: Overview
|
||||
tags:
|
||||
- Workshop
|
||||
- Custom Widgets
|
||||
- Custom CSS
|
||||
---
|
||||
|
||||
# Community Workshop
|
||||
|
||||
The [Community Workshop](pathname:///workshop) is where Homarr users share Custom Widgets and dashboard CSS.
|
||||
|
||||
:::warning Experimental
|
||||
|
||||
Workshop content is made by the community. Review it before installation and back up your Homarr instance.
|
||||
|
||||
:::
|
||||
|
||||
## Find and install content
|
||||
|
||||
Browse the Workshop by type, date, popularity, or recent activity. Each submission includes its source, screenshots,
|
||||
author, revision, comments, and reports.
|
||||
|
||||
Administrators can install content without leaving Homarr:
|
||||
|
||||
- **Custom Widgets:** open **Management → Custom Widgets**, then select **Import from Workshop**.
|
||||
- **Custom CSS:** open a board's **Settings → Custom CSS**, then select **Import from Workshop**.
|
||||
|
||||
Homarr shows the complete source and required permissions before installation. Widget credentials are entered in your
|
||||
Homarr instance and are never included in a Workshop submission.
|
||||
|
||||
## Share your work
|
||||
|
||||
1. Sign in to the Workshop with GitHub.
|
||||
2. Select **Share yours**.
|
||||
3. Choose **Custom Widget** or **Custom CSS**.
|
||||
4. Add the source, a short description, screenshots, and a changelog.
|
||||
5. Review and publish the submission.
|
||||
|
||||
Export Custom Widget JSON from **Management → Custom Widgets → Export**. You can edit a submission later without
|
||||
changing its Workshop URL.
|
||||
|
||||
## Voting, comments, and reports
|
||||
|
||||
Signed-in users can vote, comment, and report submissions. Authors can edit their own comments and submissions.
|
||||
Workshop moderators can review reports, remove comments, and delete unsafe content. Reports do not remove a submission
|
||||
automatically.
|
||||
|
||||
A Workshop moderator manages the public community. This is separate from the administrator role on a Homarr instance.
|
||||
|
||||
Deployment and server configuration are in the [Workshop operator guide](./operator). Local setup is in
|
||||
[Workshop development](./development).
|
||||
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: Workshop operator guide
|
||||
sidebar_label: Operator guide
|
||||
sidebar_position: 3
|
||||
tags:
|
||||
- Workshop
|
||||
- Deployment
|
||||
- Environment variables
|
||||
- PocketBase
|
||||
---
|
||||
|
||||
# Workshop operator guide
|
||||
|
||||
Workshop is powered by [PocketBase](https://pocketbase.io). The production image compiles the current Docusaurus site
|
||||
and serves it together with the API from one origin. The deployment lives in `apps/workshop/` with a Dockerfile,
|
||||
Compose configuration, version-controlled migrations, hooks, a smoke test, and ACT-local workflow instructions.
|
||||
|
||||
Submission links include Open Graph and X/Twitter metadata for the submission title, description, and type. The first
|
||||
uploaded screenshot is used as the social preview image; submissions without screenshots use the Homarr logo.
|
||||
|
||||
Run `pnpm docker:workshop` for PocketBase on port 8090, then run
|
||||
`WORKSHOP_API_URL=http://127.0.0.1:8090 pnpm dev:docs` for Docusaurus on port 3003. `pnpm docker:docs` builds the
|
||||
combined production layout. Normal start and stop commands retain data; `down --volumes` is an intentional,
|
||||
irreversible development reset. See [Workshop development](./development) for the complete local setup.
|
||||
|
||||
## Authentication and notifications
|
||||
|
||||
GitHub needs only the central callback `https://<workshop-host>/api/oauth2-redirect`. Keep PocketBase origins
|
||||
unrestricted unless every self-hosted Homarr caller is explicitly listed. Configure both `GITHUB_CLIENT_ID` and
|
||||
`GITHUB_CLIENT_SECRET`; supplying only one is a startup error. Set `WORKSHOP_PUBLIC_ORIGIN` in production so generated
|
||||
links and email use the public host. Workshop stores only the OAuth provider username in `users.name`, and users cannot
|
||||
rename it. Map **OAuth2 username** to `name`; leave OAuth2 id, full name, and avatar mappings empty. Profile and avatar
|
||||
URLs are derived from `https://github.com/<username>` and `https://github.com/<username>.png`, so PocketBase does not
|
||||
store duplicate profile metadata or avatar files. Configure SMTP for comment, report, and removal notices.
|
||||
|
||||
## Environment variables
|
||||
|
||||
The public URL contract and Workshop-only server configuration are:
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
| -------------------------------------- | ------------------------------------------------- | ------------------------------ |
|
||||
| `HOMARR_WEBSITE_URL` | Documentation/site base used by Homarr links | `https://homarr.dev` |
|
||||
| `WORKSHOP_API_URL` | PocketBase API used by Homarr and standalone docs | `HOMARR_WEBSITE_URL` |
|
||||
| `WORKSHOP_WEB_URL` | Public Workshop page linked from Homarr | `HOMARR_WEBSITE_URL/workshop` |
|
||||
| `WORKSHOP_PUBLIC_ORIGIN` | PocketBase-generated links and email | Required in production |
|
||||
| `PB_ALLOWED_ORIGINS` | PocketBase browser origins | `*` for the central community |
|
||||
| `OPENROUTER_API_KEY` | Server-only key for the Homarr provider proxy | Provider disabled when empty |
|
||||
| `HOMARR_AI_DAILY_REQUEST_LIMIT` | Per-user daily provider allowance | `50` |
|
||||
| `HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT` | Shared daily ceiling across all users | `10000` |
|
||||
| `HOMARR_AI_OPENROUTER_BASE_URL` | OpenRouter-compatible proxy upstream | `https://openrouter.ai/api/v1` |
|
||||
| `HOMARR_AI_OPENROUTER_MODEL` | Server-selected upstream model | Selected by the Homarr team |
|
||||
|
||||
All public URL variables reject non-HTTP protocols, embedded credentials, query strings, and fragments, and normalize
|
||||
trailing slashes when Homarr or Docusaurus starts. The deprecated docs-only `WORKSHOP_URL` alias remains for one
|
||||
release; `WORKSHOP_API_URL` takes precedence. The combined Workshop image writes the public URLs into its browser
|
||||
runtime configuration whenever the container starts; the Docker build arguments remain fallbacks. Restart the
|
||||
container after changing these values, without rebuilding it.
|
||||
|
||||
### Homarr provider proxy
|
||||
|
||||
The [Homarr provider](./homarr-provider) proxy is part of the Workshop image. `OPENROUTER_API_KEY` enables the proxy,
|
||||
`HOMARR_AI_OPENROUTER_BASE_URL` selects its OpenRouter-compatible upstream, `HOMARR_AI_OPENROUTER_MODEL` selects the
|
||||
private upstream model behind the stable `homarr/model` alias, and the two allowance variables provide per-user and
|
||||
shared request ceilings. Lowering either allowance takes effect for existing users. The upstream URL cannot contain
|
||||
embedded credentials, a query, or a fragment and must use HTTPS. Tests can explicitly opt into an insecure local mock
|
||||
with `HOMARR_AI_ALLOW_INSECURE_UPSTREAM=true`; do not use that setting with a real API key.
|
||||
|
||||
The proxy overwrites client routing and generation limits. It requires OpenRouter zero-data-retention routing, denies
|
||||
data-collecting providers, limits responses to 32,768 completion tokens, and does not retain prompts, responses, or
|
||||
per-request telemetry. It accepts only uploaded image data, conservatively bounds text to the advertised context, and
|
||||
allows at most two concurrent requests per user and 100 across the service. Keep an OpenRouter account-level credit
|
||||
limit as the final monetary backstop because request quotas are not dollar accounting.
|
||||
|
||||
Run `pnpm test:workshop` to test authentication, streaming, UTC reset, atomic per-user and global quota enforcement,
|
||||
failed-request accounting, identity attribution, server-selected model routing, function tools, input and output
|
||||
controls, privacy routing, spoofing resistance, absence of request logs, and migration rollback.
|
||||
|
||||
An operator can explicitly smoke-test a deployed Workshop against its real configured upstream with a dedicated,
|
||||
low-quota test user:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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
|
||||
preview. Copy `apps/workshop/.env.staging.example` to `.env.staging`; its default public contract is:
|
||||
|
||||
```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=*
|
||||
```
|
||||
|
||||
Start or update it from the repository root:
|
||||
|
||||
```sh
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml pull
|
||||
docker compose --env-file apps/workshop/.env.staging \
|
||||
-f apps/workshop/docker-compose.staging.yml up --detach
|
||||
curl --fail http://127.0.0.1:8090/api/health
|
||||
```
|
||||
|
||||
The service binds to `127.0.0.1:8090`. Terminate TLS at a reverse proxy and forward the **entire hostname**, including
|
||||
`/api`, `/_/`, and `/workshop-runtime-config.js`; forwarding only `/workshop` breaks PocketBase and runtime URL loading.
|
||||
This combined image serves both the documentation site and PocketBase, so use a dedicated Workshop hostname if the
|
||||
chosen hostname already serves Homarr itself. In that case, replace the hostname in all four URL values and keep
|
||||
`/workshop` only on `WORKSHOP_WEB_URL`.
|
||||
|
||||
The Compose volume preserves PocketBase data across pulls and container recreation. The mutable `v2` tag is suitable
|
||||
for previews; use the `sha-<commit>` tag for promotion. For the public central Workshop, `PB_ALLOWED_ORIGINS=*` allows
|
||||
self-hosted Homarr origins to use the API and OAuth. A private demo may use a comma-separated origin allowlist instead.
|
||||
GitHub OAuth requires both credentials and the callback `https://v2.preview.homarr.dev/api/oauth2-redirect`. Restart
|
||||
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.
|
||||
|
||||
## Client architecture
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -362,7 +362,7 @@ html[data-workshop-detail-loading] #workshop-detail-route-fallback {
|
||||
box-sizing: border-box;
|
||||
color: #4b5563;
|
||||
display: flex;
|
||||
font: 500 0.875rem/1.5 Inter, system-ui, sans-serif;
|
||||
font: 500 0.875rem/1.5 "Work Sans", system-ui, sans-serif;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
"postcss": "^8.5.16",
|
||||
"posthog-docusaurus": "^2.0.5",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"random-word-slugs": "^0.1.7",
|
||||
"react": "catalog:",
|
||||
"react-day-picker": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
.link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--ifm-color-emphasis-300);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--ifm-background-surface-color);
|
||||
color: var(--ifm-font-color-base);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
border-color 150ms ease;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
border-color: var(--ifm-color-emphasis-500);
|
||||
background: var(--ifm-color-emphasis-100);
|
||||
color: var(--ifm-font-color-base);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:focus-visible {
|
||||
outline: 2px solid var(--ifm-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.link {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import Translate from "@docusaurus/Translate";
|
||||
|
||||
import styles from "./open-collective-link.module.css";
|
||||
|
||||
export const OpenCollectiveLink = () => (
|
||||
<a className={styles.link} href="https://opencollective.com/homarr">
|
||||
<img className={styles.logo} src="/img/opencollective.svg" alt="" aria-hidden />
|
||||
<Translate id="workshop.provider.donate">Donate to Homarr on OpenCollective</Translate>
|
||||
</a>
|
||||
);
|
||||
@@ -1,147 +1,84 @@
|
||||
import React, { useRef } from "react";
|
||||
import styles from "../../../../pages/index.module.css";
|
||||
import { supportedIntegrations } from "../../../../constants/supported-integrations";
|
||||
|
||||
import LeaderLine, { SocketType } from "leader-line-new";
|
||||
const nodePositions = [
|
||||
styles.dataflowNodeTopLeft,
|
||||
styles.dataflowNodeMiddleLeft,
|
||||
styles.dataflowNodeBottomLeft,
|
||||
styles.dataflowNodeTopRight,
|
||||
styles.dataflowNodeMiddleRight,
|
||||
styles.dataflowNodeBottomRight,
|
||||
];
|
||||
const cyclingIntegrations = supportedIntegrations.filter((integration) => integration.name !== "Homarr");
|
||||
|
||||
export const DataflowVisualizationComponent = () => {
|
||||
const homarrRef = useRef<HTMLImageElement>(null);
|
||||
const sonarrRef = useRef<HTMLImageElement>(null);
|
||||
const lidarrRef = useRef<HTMLImageElement>(null);
|
||||
const radarrRef = useRef<HTMLImageElement>(null);
|
||||
const jellyfinRef = useRef<HTMLImageElement>(null);
|
||||
const plexRef = useRef<HTMLImageElement>(null);
|
||||
const sabnzbdRef = useRef<HTMLImageElement>(null);
|
||||
const paths = [
|
||||
"M145 82 C330 82 330 235 480 260",
|
||||
"M145 260 C300 260 360 260 480 260",
|
||||
"M145 438 C330 438 330 285 480 260",
|
||||
"M480 260 C630 235 630 82 815 82",
|
||||
"M480 260 C600 260 660 260 815 260",
|
||||
"M480 260 C630 285 630 438 815 438",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={"bg-black/[.10] py-20"}>
|
||||
<h2 className={"text-center lg:text-5xl text-3xl font-extrabold mb-24"}>
|
||||
export const DataflowVisualizationComponent = () => (
|
||||
<section className={styles.dataflowSection}>
|
||||
<div className={styles.dataflowHeading}>
|
||||
<h2>
|
||||
No YAML configurations.
|
||||
<br />
|
||||
Easy and quick to manage integrations.
|
||||
</h2>
|
||||
<p>Homarr brings status, controls, and updates together in one dashboard.</p>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-128 h-80 mx-auto animated-dataflow mx-5">
|
||||
<img
|
||||
ref={homarrRef}
|
||||
className={"absolute left-1/2 top-1/2 -translate-y-1/2 -translate-x-1/2 aspect-square object-contain"}
|
||||
src={"/img/logo.png"}
|
||||
alt={"Homarr Logo"}
|
||||
width={100}
|
||||
height={50}
|
||||
/>
|
||||
<div
|
||||
className={styles.dataflowDiagram}
|
||||
role="img"
|
||||
aria-label={`Information flowing both ways between Homarr and rotating examples from ${cyclingIntegrations.length} supported integrations`}
|
||||
>
|
||||
<svg className={styles.dataflowLines} viewBox="0 0 960 520" preserveAspectRatio="none" aria-hidden="true">
|
||||
{paths.map((path) => (
|
||||
<g key={path}>
|
||||
<path className={styles.dataflowLine} d={path} pathLength="1" />
|
||||
<path className={styles.dataflowSignal} d={path} pathLength="1" />
|
||||
<path className={styles.dataflowSignalReverse} d={path} pathLength="1" />
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
<img
|
||||
ref={radarrRef}
|
||||
className={"absolute left-0 top-0"}
|
||||
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/radarr.png?raw=true"}
|
||||
alt={"Radarr"}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
<img
|
||||
ref={sonarrRef}
|
||||
className={"absolute left-0 top-1/2 -translate-y-1/2"}
|
||||
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/sonarr.png?raw=true"}
|
||||
alt={"Sonarr"}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
<img
|
||||
ref={lidarrRef}
|
||||
className={"absolute left-0 bottom-0"}
|
||||
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/lidarr.png?raw=true"}
|
||||
alt={"Lidarr"}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
{nodePositions.map((position, nodeIndex) => {
|
||||
const cycleStepInSeconds = 1.2 + nodeIndex * 0.13;
|
||||
const phaseOffsetInSeconds = nodeIndex * 2.7;
|
||||
|
||||
<img
|
||||
ref={jellyfinRef}
|
||||
className={"absolute right-0 top-1/2 -translate-y-1/2"}
|
||||
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/jellyfin.png?raw=true"}
|
||||
alt={"Jellyfin"}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
return (
|
||||
<div className={`${styles.dataflowNode} ${position}`} key={position}>
|
||||
<div className={styles.dataflowCyclingIcons} aria-hidden="true">
|
||||
{cyclingIntegrations.map((_, integrationIndex) => {
|
||||
const integration =
|
||||
cyclingIntegrations[(integrationIndex + nodeIndex * 4) % cyclingIntegrations.length];
|
||||
|
||||
<img
|
||||
ref={plexRef}
|
||||
className={"absolute right-0 bottom-0"}
|
||||
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/plex.png?raw=true"}
|
||||
alt={"Plex"}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
return (
|
||||
<img
|
||||
src={integration.iconUrl}
|
||||
alt=""
|
||||
width={54}
|
||||
height={54}
|
||||
key={integration.name}
|
||||
style={{
|
||||
animationDelay: `${-(integrationIndex * cycleStepInSeconds + phaseOffsetInSeconds)}s`,
|
||||
animationDuration: `${cyclingIntegrations.length * cycleStepInSeconds}s`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<img
|
||||
ref={sabnzbdRef}
|
||||
className={"absolute right-0 top-0"}
|
||||
src={"https://github.com/walkxcode/dashboard-icons/blob/main/png/sabnzbd.png?raw=true"}
|
||||
alt={"Sabnzbd"}
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
|
||||
<LineTree start={sonarrRef} end={homarrRef} startSocket={"right"} endSocket={"left"} />
|
||||
<LineTree start={radarrRef} end={homarrRef} startSocket={"right"} endSocket={"left"} />
|
||||
<LineTree start={lidarrRef} end={homarrRef} startSocket={"right"} endSocket={"left"} />
|
||||
<LineTree start={plexRef} end={homarrRef} startSocket={"left"} endSocket={"right"} x={100} />
|
||||
<LineTree start={jellyfinRef} end={homarrRef} startSocket={"left"} endSocket={"right"} x={100} />
|
||||
<LineTree start={sabnzbdRef} end={homarrRef} startSocket={"left"} endSocket={"right"} x={100} />
|
||||
<div className={`${styles.dataflowNode} ${styles.dataflowHub}`}>
|
||||
<img src="/img/logo.png" alt="" width={86} height={86} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const LineTree = ({
|
||||
start,
|
||||
end,
|
||||
startSocket,
|
||||
endSocket,
|
||||
x = 0,
|
||||
}: {
|
||||
start: any;
|
||||
end: any;
|
||||
startSocket: SocketType;
|
||||
endSocket: SocketType;
|
||||
x?: number;
|
||||
}) => {
|
||||
const line = useRef<LeaderLine | null>(null);
|
||||
let leaderLine: LeaderLine;
|
||||
|
||||
React.useEffect(() => {
|
||||
const drawLine = () => {
|
||||
leaderLine = new LeaderLine(start.current, LeaderLine.pointAnchor(end.current, { x: `${x}%` }), {
|
||||
startSocket: startSocket,
|
||||
endSocket: endSocket,
|
||||
color: "var(--ifm-color-primary)",
|
||||
size: 3,
|
||||
dash: {
|
||||
animation: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
const timer = setInterval(() => {
|
||||
if (start.current) {
|
||||
clearInterval(timer);
|
||||
drawLine();
|
||||
}
|
||||
}, 5);
|
||||
return () => {
|
||||
timer && clearInterval(timer);
|
||||
leaderLine.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
// scroll and resize listeners could be assigned here
|
||||
setTimeout(() => {
|
||||
// skip current even loop and wait
|
||||
// the end of parent's render call
|
||||
if (line.current && end?.current) {
|
||||
(line.current as any).position();
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
</section>
|
||||
);
|
||||
@@ -1,22 +1,100 @@
|
||||
import { translate } from "@docusaurus/Translate";
|
||||
import { IconBrandDocker, IconCpu, IconDeviceDesktopAnalytics } from "@tabler/icons-react";
|
||||
|
||||
import styles from "../../../../pages/index.module.css";
|
||||
import { AppWidget } from "./widgets/app-widget";
|
||||
import { ClockWidget } from "./widgets/clock-widget";
|
||||
import { DownloadsWidget } from "./widgets/downloads-widget";
|
||||
import { WeatherWidget } from "./widgets/weather-widget";
|
||||
import { StockWidget } from "./widgets/stock-widget";
|
||||
import { EntityStateWidget } from "./widgets/entity-state-widget";
|
||||
import { StockWidget } from "./widgets/stock-widget";
|
||||
import { WeatherWidget } from "./widgets/weather-widget";
|
||||
import { WidgetCard } from "./widgets/card";
|
||||
|
||||
export const HeroCards = () => {
|
||||
return (
|
||||
<div className="argos-ignore hero-cards flex flex-wrap max-w-[504px] gap-y-4 gap-x-3 text-gray-700 dark:text-gray-300">
|
||||
<div className={`argos-ignore hero-cards ${styles.heroCards}`}>
|
||||
<StockWidget />
|
||||
<AppWidget />
|
||||
<AppWidget className="hidden 3xl:block" />
|
||||
<WeatherWidget />
|
||||
<DownloadsWidget />
|
||||
<EntityStateWidget />
|
||||
<AppWidget />
|
||||
|
||||
<AppWidget name="Plex" />
|
||||
<AppWidget name="Jellyfin" />
|
||||
<AppWidget name="Homeassistant" label="Home Assistant" />
|
||||
<ClockWidget />
|
||||
<SystemResourcesWidget />
|
||||
<WeatherWidget />
|
||||
<EntityStateWidget />
|
||||
<AppWidget name="PiHole" label="Pi-hole" />
|
||||
<AppWidget name="qBittorrent" />
|
||||
<DownloadsWidget />
|
||||
<DockerWidget />
|
||||
<AppWidget name="Sonarr" />
|
||||
<AppWidget name="Radarr" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const heroLabels = {
|
||||
systemResources: translate({ id: "homepage.preview.systemResources", message: "System resources" }),
|
||||
online: translate({ id: "homepage.preview.online", message: "Online" }),
|
||||
cpu: translate({ id: "homepage.preview.cpu", message: "CPU" }),
|
||||
memory: translate({ id: "homepage.preview.memory", message: "Memory" }),
|
||||
disk: translate({ id: "homepage.preview.disk", message: "Disk" }),
|
||||
docker: translate({ id: "homepage.preview.docker", message: "Docker" }),
|
||||
dockerSummary: translate({ id: "homepage.preview.dockerSummary", message: "2 running · 1 failed" }),
|
||||
healthy: translate({ id: "homepage.preview.healthy", message: "Healthy" }),
|
||||
running: translate({ id: "homepage.preview.running", message: "Running" }),
|
||||
exited: translate({ id: "homepage.preview.exited", message: "Exited" }),
|
||||
};
|
||||
|
||||
const SystemResourcesWidget = () => (
|
||||
<WidgetCard width={2} className={styles.resourcesWidget}>
|
||||
<div className={styles.widgetHeading}>
|
||||
<span className={styles.widgetTitle}>
|
||||
<IconDeviceDesktopAnalytics size={17} aria-hidden="true" />
|
||||
{heroLabels.systemResources}
|
||||
</span>
|
||||
<span className={styles.onlineStatus}>{heroLabels.online}</span>
|
||||
</div>
|
||||
<ResourceMeter icon={<IconCpu size={15} aria-hidden="true" />} label={heroLabels.cpu} value={34} />
|
||||
<ResourceMeter label={heroLabels.memory} value={62} />
|
||||
<ResourceMeter label={heroLabels.disk} value={98} />
|
||||
</WidgetCard>
|
||||
);
|
||||
|
||||
const ResourceMeter = ({ icon, label, value }: { icon?: React.ReactNode; label: string; value: number }) => (
|
||||
<div className={`${styles.resourceMeter} ${value >= 90 ? styles.resourceMeterCritical : ""}`}>
|
||||
<div className={styles.resourceLabel}>
|
||||
<span>
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
<span>{value}%</span>
|
||||
</div>
|
||||
<progress className={styles.resourceTrack} aria-label={`${label} usage`} max={100} value={value} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const DockerWidget = () => (
|
||||
<WidgetCard width={2} className={styles.dockerWidget}>
|
||||
<div className={styles.widgetHeading}>
|
||||
<span className={styles.widgetTitle}>
|
||||
<IconBrandDocker size={18} aria-hidden="true" />
|
||||
{heroLabels.docker}
|
||||
</span>
|
||||
<span className={styles.dockerCount}>{heroLabels.dockerSummary}</span>
|
||||
</div>
|
||||
<div className={styles.containerList}>
|
||||
{[
|
||||
{ name: "immich", status: heroLabels.healthy, failed: false },
|
||||
{ name: "jellyfin", status: heroLabels.running, failed: false },
|
||||
{ name: "paperless", status: heroLabels.exited, failed: true },
|
||||
].map(({ name, status, failed }) => (
|
||||
<div className={`${styles.containerRow} ${failed ? styles.containerRowFailed : ""}`} key={name}>
|
||||
<span className={styles.containerName}>{name}</span>
|
||||
<span className={styles.containerStatus}>
|
||||
<span className={`${styles.statusDot} ${failed ? styles.statusDotFailed : ""}`} aria-hidden="true" />
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</WidgetCard>
|
||||
);
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from "@docusaurus/Link";
|
||||
import { translate } from "@docusaurus/Translate";
|
||||
import { IconDownload, IconExternalLink } from "@tabler/icons-react";
|
||||
import clsx from "clsx";
|
||||
import styles from "../../../../pages/index.module.css";
|
||||
@@ -7,9 +8,9 @@ import { HeroCards } from "./hero-cards";
|
||||
export default function HomeHero() {
|
||||
return (
|
||||
<header className={clsx("hero hero--primary", styles.heroBanner, styles.hero)}>
|
||||
<div className="flex">
|
||||
<div className="ps-0 lg:ps-52">
|
||||
<h1 className="font-extrabold lg:text-7xl text-3xl">
|
||||
<div className={styles.heroContent}>
|
||||
<div className={styles.heroCopy}>
|
||||
<h1 className={clsx("font-extrabold lg:text-7xl text-3xl", styles.heroTitle)}>
|
||||
A simple, yet
|
||||
<br />
|
||||
powerful dashboard
|
||||
@@ -22,7 +23,7 @@ export default function HomeHero() {
|
||||
information.
|
||||
</p>
|
||||
|
||||
<div className={"flex flex-nowrap gap-2"}>
|
||||
<div className={styles.heroActions}>
|
||||
<Link
|
||||
data-attr="Install button"
|
||||
className={"button button--secondary button--lg rounded-3xl dark:border-zinc-600 dark:bg-zinc-800"}
|
||||
@@ -57,9 +58,12 @@ export default function HomeHero() {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-grow-1 w-full lg:block hidden hover-animation">
|
||||
<section
|
||||
className={clsx(styles.heroPreview, "hover-animation")}
|
||||
aria-label={translate({ id: "homepage.preview.label", message: "Example Homarr dashboard" })}
|
||||
>
|
||||
<HeroCards />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { supportedIntegrations } from "@site/src/constants/supported-integrations";
|
||||
import { CommonWidgetProps, WidgetCard } from "./card";
|
||||
import { getRndInteger } from "@site/src/tools/math";
|
||||
import clsx from "clsx";
|
||||
|
||||
export const AppWidget = ({ className }: CommonWidgetProps) => {
|
||||
const randomApp = supportedIntegrations[getRndInteger(0, supportedIntegrations.length - 1)];
|
||||
interface AppWidgetProps extends CommonWidgetProps {
|
||||
name: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const AppWidget = ({ name, label, className }: AppWidgetProps) => {
|
||||
const app = supportedIntegrations.find((integration) => integration.name === name);
|
||||
if (!app) return null;
|
||||
|
||||
return (
|
||||
<WidgetCard width={1} className={clsx("text-center", className)}>
|
||||
<span className={"text-sm font-bold"}>{randomApp.name}</span>
|
||||
<img
|
||||
src={randomApp.iconUrl}
|
||||
className="aspect-square scale-[0.6] transition-transform"
|
||||
alt={`${randomApp.name} Icon`}
|
||||
/>
|
||||
<div className="absolute bottom-3 right-3 rounded-full bg-green-500 w-2 h-2"></div>
|
||||
<WidgetCard width={1} className={clsx("app-card text-center", className)}>
|
||||
<span className={"text-sm font-bold"}>{label ?? app.name}</span>
|
||||
<img src={app.iconUrl} className="aspect-square scale-[0.6]" alt="" />
|
||||
<div className="absolute bottom-3 right-3 rounded-full bg-green-500 w-2 h-2" aria-hidden="true" />
|
||||
</WidgetCard>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
import { ReactNode } from "react";
|
||||
import styles from "../../../../../pages/index.module.css";
|
||||
|
||||
export interface CommonWidgetProps {
|
||||
className?: string;
|
||||
@@ -16,15 +17,13 @@ export const WidgetCard = ({ width, children, className, onClick }: WidgetCardPr
|
||||
return (
|
||||
<Component
|
||||
className={clsx(
|
||||
"rounded-xl flex flex-col justify-center items-center p-2 relative border-none bg-[var(--ifm-navbar-background-color)]",
|
||||
styles.widgetCard,
|
||||
"flex flex-col justify-center items-center p-2 relative border-none bg-[var(--ifm-navbar-background-color)]",
|
||||
className,
|
||||
onClick ? "cursor-pointer" : undefined,
|
||||
)}
|
||||
style={{
|
||||
width: (128 * width + (width === 2 ? 16 : 0)) * 0.9,
|
||||
height: 128,
|
||||
background: "rgb(from var(--ifm-navbar-background-color) r g b / 80%)",
|
||||
}}
|
||||
style={{ width: (128 * width + (width === 2 ? 16 : 0)) * 0.9, height: 128 }}
|
||||
type={onClick ? "button" : undefined}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
import { getRndInteger } from "@site/src/tools/math";
|
||||
import { translate } from "@docusaurus/Translate";
|
||||
import { useEffect, useState } from "react";
|
||||
import { CommonWidgetProps, WidgetCard } from "./card";
|
||||
import clsx from "clsx";
|
||||
import { generateSlug } from "random-word-slugs";
|
||||
import styles from "../../../../../pages/index.module.css";
|
||||
|
||||
const downloadQueue = ["family-photos.zip", "linux-isos.mkv", "server-backup.zip", "holiday-video.mp4"];
|
||||
const downloadLabels = {
|
||||
file: translate({ id: "homepage.preview.file", message: "File" }),
|
||||
progress: translate({ id: "homepage.preview.progress", message: "Progress" }),
|
||||
};
|
||||
|
||||
export const DownloadsWidget = ({ className }: CommonWidgetProps) => {
|
||||
const [downloads, setDownloads] = useState<Download[]>([]);
|
||||
const [downloads, setDownloads] = useState<Download[]>([
|
||||
{ filename: "photos-backup.zip", progress: 72 },
|
||||
{ filename: "media-library.mkv", progress: 38 },
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = setInterval(() => {
|
||||
setDownloads((prev: Download[]) => {
|
||||
const filteredPrev = prev.filter((download) => download.progress !== 100);
|
||||
|
||||
filteredPrev.forEach((download, index) => {
|
||||
download.progress = Math.min(100, download.progress + getRndInteger(0, index < 2 ? 16 : 8));
|
||||
});
|
||||
const progressedDownloads = filteredPrev.map((download, index) => ({
|
||||
...download,
|
||||
progress: Math.min(100, download.progress + (index < 2 ? 8 : 4)),
|
||||
}));
|
||||
|
||||
if (filteredPrev.length < 3 && filteredPrev.length === prev.length) {
|
||||
filteredPrev.push(generateRandomDownload());
|
||||
if (progressedDownloads.length < 3 && progressedDownloads.length === prev.length) {
|
||||
const availableDownloads = downloadQueue.filter(
|
||||
(filename) => !progressedDownloads.some((download) => download.filename === filename),
|
||||
);
|
||||
const filename = availableDownloads[0];
|
||||
if (filename) progressedDownloads.push({ filename, progress: 0 });
|
||||
}
|
||||
|
||||
return [...filteredPrev];
|
||||
return progressedDownloads;
|
||||
});
|
||||
}, 500);
|
||||
|
||||
@@ -34,19 +48,31 @@ export const DownloadsWidget = ({ className }: CommonWidgetProps) => {
|
||||
<table className="mb-0 w-full h-full text-xs">
|
||||
<thead className="inline-block w-full">
|
||||
<tr className="inline-block w-full">
|
||||
<th className="border-none px-2 py-1 text-start inline-block w-1/2">File</th>
|
||||
<th className="border-none px-2 py-1 text-start inline-block w-1/2">Progress</th>
|
||||
<th className="border-none px-2 py-1 text-start inline-block w-1/2">{downloadLabels.file}</th>
|
||||
<th className="border-none px-2 py-1 text-start inline-block w-1/2">{downloadLabels.progress}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{downloads.map((file: Download, index: number) => {
|
||||
{downloads.map((file: Download) => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<tr key={file.filename}>
|
||||
<td className="border-none text-nowrap p-2">{file.filename}</td>
|
||||
<td className="border-none p-2 w-full">
|
||||
<div className="overflow-hidden rounded-xl h-2 w-full bg-zinc-600">
|
||||
<div className="bg-green-600 h-full animated-width" style={{ width: `${file.progress}%` }}></div>
|
||||
</div>
|
||||
<td
|
||||
className="border-none p-2 w-full"
|
||||
aria-label={translate(
|
||||
{ id: "homepage.preview.downloadComplete", message: "{progress}% complete" },
|
||||
{ progress: file.progress },
|
||||
)}
|
||||
>
|
||||
<progress
|
||||
className={styles.downloadProgress}
|
||||
value={file.progress}
|
||||
max={100}
|
||||
aria-label={translate(
|
||||
{ id: "homepage.preview.downloadProgress", message: "{filename} download progress" },
|
||||
{ filename: file.filename },
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -61,15 +87,3 @@ interface Download {
|
||||
filename: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
const formats = ["mkv", "mp4"];
|
||||
|
||||
const generateRandomDownload = (): Download => {
|
||||
const randomMovie = generateSlug(2);
|
||||
const randomFormat = formats[getRndInteger(0, formats.length - 1)];
|
||||
|
||||
return {
|
||||
filename: `${randomMovie}.${randomFormat}`,
|
||||
progress: 0,
|
||||
};
|
||||
};
|
||||
@@ -27,6 +27,7 @@ export const StockWidget = ({ className }: CommonWidgetProps) => {
|
||||
HOMR
|
||||
</div>
|
||||
<Line
|
||||
ariaLabel="Simulated HOMR stock price trend"
|
||||
height={128}
|
||||
width={244}
|
||||
colors={[upwardTrend ? "green" : "red"]}
|
||||
|
||||
@@ -758,7 +758,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to="/docs/management/workshop/"
|
||||
to="/docs/workshop/"
|
||||
className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Installation guide <IconExternalLink size={13} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap");
|
||||
@import "https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap";
|
||||
@import "tailwindcss/theme" layer(theme);
|
||||
@import "tailwindcss/utilities" layer(utilities);
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
--breakpoint-3xl: 1697px;
|
||||
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: "Inter", sans-serif;
|
||||
--font-sans: "Work Sans", sans-serif;
|
||||
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
@@ -137,7 +137,7 @@
|
||||
--sidebar-border: oklch(0.9276 0.0058 264.5313);
|
||||
--sidebar-ring: oklch(0.56 0.205 25.6509);
|
||||
--destructive-foreground: oklch(0.9851 0 0);
|
||||
--font-sans: "Inter", sans-serif;
|
||||
--font-sans: "Work Sans", sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
--shadow-color: #000000;
|
||||
@@ -491,6 +491,10 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hover-animation {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.submit-step-enter {
|
||||
animation: none;
|
||||
}
|
||||
@@ -541,7 +545,7 @@ html[data-theme="dark"] div#__docusaurus div[role="banner"] button svg {
|
||||
--sidebar-accent-foreground: oklch(0.6646 0.2222 25.6509);
|
||||
--sidebar-border: oklch(0.2739 0.0055 286.0326);
|
||||
--sidebar-ring: oklch(0.72 0.16 25.6509);
|
||||
--font-sans: "Inter", sans-serif;
|
||||
--font-sans: "Work Sans", sans-serif;
|
||||
--font-serif: Georgia, serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
--shadow-color: #000000;
|
||||
|
||||
@@ -28,13 +28,457 @@ section {
|
||||
}
|
||||
|
||||
.heroBanner {
|
||||
padding: 4rem;
|
||||
padding: clamp(3rem, 6vw, 6rem) clamp(1.25rem, 4vw, 4rem);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 996px) {
|
||||
.heroBanner {
|
||||
padding: 2rem;
|
||||
.heroContent {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(25rem, 1fr) minmax(44rem, 48rem);
|
||||
align-items: center;
|
||||
gap: clamp(1rem, 3vw, 3rem);
|
||||
max-width: 108rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
max-width: 52rem;
|
||||
}
|
||||
|
||||
.heroTitle {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.heroActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.heroPreview {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.heroCards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem 0.75rem;
|
||||
width: min(100%, 48rem);
|
||||
margin-inline: auto;
|
||||
color: #343a40;
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .heroCards {
|
||||
color: #dee2e6;
|
||||
}
|
||||
|
||||
.widgetCard {
|
||||
flex: none;
|
||||
min-width: 0;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid color-mix(in srgb, currentcolor 12%, transparent) !important;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
background: color-mix(in srgb, var(--ifm-navbar-background-color) 76%, transparent) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
box-shadow: 0 12px 28px rgb(31 41 55 / 14%);
|
||||
}
|
||||
|
||||
.resourcesWidget,
|
||||
.dockerWidget {
|
||||
align-items: stretch !important;
|
||||
justify-content: flex-start !important;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
|
||||
.widgetHeading,
|
||||
.resourceLabel,
|
||||
.containerRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.widgetTitle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
min-width: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.onlineStatus,
|
||||
.dockerCount {
|
||||
flex: none;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.onlineStatus {
|
||||
color: #087f5b;
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .onlineStatus {
|
||||
color: #69dbb1;
|
||||
}
|
||||
|
||||
.resourceMeter {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.resourceMeterCritical .resourceLabel {
|
||||
color: #a51111;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.resourceLabel {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.resourceLabel > span:first-child {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.resourceTrack {
|
||||
display: block;
|
||||
appearance: none;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 0.375rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, currentcolor 16%, transparent);
|
||||
}
|
||||
|
||||
.resourceTrack::-webkit-progress-bar {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.resourceTrack::-webkit-progress-value,
|
||||
.resourceTrack::-moz-progress-bar {
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--ifm-color-primary);
|
||||
}
|
||||
|
||||
.resourceMeterCritical .resourceTrack::-webkit-progress-value,
|
||||
.resourceMeterCritical .resourceTrack::-moz-progress-bar {
|
||||
background: #e03131;
|
||||
}
|
||||
|
||||
.downloadProgress {
|
||||
display: block;
|
||||
appearance: none;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 0.5rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, currentcolor 18%, transparent);
|
||||
}
|
||||
|
||||
.downloadProgress::-webkit-progress-bar {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.downloadProgress::-webkit-progress-value,
|
||||
.downloadProgress::-moz-progress-bar {
|
||||
border-radius: inherit;
|
||||
background: #2f9e44;
|
||||
}
|
||||
|
||||
.containerList {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.containerRow {
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.containerName {
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.containerStatus {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: color-mix(in srgb, currentcolor 82%, transparent);
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 0.375rem;
|
||||
height: 0.375rem;
|
||||
border-radius: 999px;
|
||||
background: #12b886;
|
||||
}
|
||||
|
||||
.containerRowFailed {
|
||||
color: #a51111;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dataflowSection {
|
||||
margin-bottom: 0;
|
||||
padding: clamp(4rem, 8vw, 7rem) clamp(1rem, 4vw, 3rem);
|
||||
overflow: hidden;
|
||||
background: rgb(0 0 0 / 10%);
|
||||
}
|
||||
|
||||
.dataflowHeading {
|
||||
max-width: 58rem;
|
||||
margin: 0 auto clamp(2rem, 5vw, 4rem);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dataflowHeading h2 {
|
||||
margin-bottom: 1rem;
|
||||
font-size: clamp(2rem, 5vw, 4rem);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.03em;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.dataflowHeading p {
|
||||
margin: 0 auto;
|
||||
max-width: 42rem;
|
||||
color: color-mix(in srgb, var(--ifm-font-color-base) 76%, transparent);
|
||||
font-size: clamp(1rem, 2vw, 1.25rem);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.dataflowDiagram {
|
||||
position: relative;
|
||||
width: min(100%, 70rem);
|
||||
aspect-ratio: 960 / 520;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.dataflowLines {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.dataflowLine,
|
||||
.dataflowSignal,
|
||||
.dataflowSignalReverse {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.dataflowLine {
|
||||
stroke: color-mix(in srgb, var(--ifm-color-primary) 30%, transparent);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.dataflowSignal,
|
||||
.dataflowSignalReverse {
|
||||
stroke: var(--ifm-color-primary);
|
||||
stroke-width: 7;
|
||||
stroke-dasharray: 0.035 0.965;
|
||||
animation: dataflow-signal 2.8s linear infinite;
|
||||
}
|
||||
|
||||
.dataflowSignalReverse {
|
||||
animation-direction: reverse;
|
||||
animation-delay: -1.4s;
|
||||
}
|
||||
|
||||
.dataflowNode {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
box-sizing: border-box;
|
||||
width: clamp(4.5rem, 10vw, 7.5rem);
|
||||
aspect-ratio: 1;
|
||||
padding: clamp(0.6rem, 1.5vw, 1rem);
|
||||
border-radius: 0.875rem;
|
||||
color: var(--ifm-font-color-base);
|
||||
background: var(--ifm-background-surface-color);
|
||||
box-shadow: 0 6px 12px rgb(31 41 55 / 14%);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.dataflowNode img {
|
||||
width: clamp(2rem, 4.5vw, 3.4rem);
|
||||
height: clamp(2rem, 4.5vw, 3.4rem);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.dataflowCyclingIcons {
|
||||
position: relative;
|
||||
width: clamp(2rem, 4.5vw, 3.4rem);
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.dataflowCyclingIcons img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
animation: dataflow-icon-cycle linear infinite;
|
||||
}
|
||||
|
||||
.dataflowNodeTopLeft,
|
||||
.dataflowNodeMiddleLeft,
|
||||
.dataflowNodeBottomLeft {
|
||||
left: 15%;
|
||||
}
|
||||
|
||||
.dataflowNodeTopRight,
|
||||
.dataflowNodeMiddleRight,
|
||||
.dataflowNodeBottomRight {
|
||||
left: 85%;
|
||||
}
|
||||
|
||||
.dataflowNodeTopLeft,
|
||||
.dataflowNodeTopRight {
|
||||
top: 16%;
|
||||
}
|
||||
|
||||
.dataflowNodeMiddleLeft,
|
||||
.dataflowNodeMiddleRight,
|
||||
.dataflowHub {
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.dataflowNodeBottomLeft,
|
||||
.dataflowNodeBottomRight {
|
||||
top: 84%;
|
||||
}
|
||||
|
||||
.dataflowHub {
|
||||
left: 50%;
|
||||
width: clamp(6rem, 13vw, 9rem);
|
||||
}
|
||||
|
||||
.dataflowHub img {
|
||||
width: clamp(3rem, 7vw, 5.4rem);
|
||||
height: clamp(3rem, 7vw, 5.4rem);
|
||||
}
|
||||
|
||||
@keyframes dataflow-signal {
|
||||
to {
|
||||
stroke-dashoffset: -1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dataflow-icon-cycle {
|
||||
0%,
|
||||
3.7% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
4.2%,
|
||||
99% {
|
||||
opacity: 0;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
}
|
||||
|
||||
:global([data-theme="dark"]) .resourceMeterCritical .resourceLabel,
|
||||
:global([data-theme="dark"]) .containerRowFailed {
|
||||
color: #ffe3e3;
|
||||
}
|
||||
|
||||
.statusDotFailed {
|
||||
background: #fa5252;
|
||||
animation: failed-container-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes failed-container-pulse {
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
transform: scale(1.65);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1280px) {
|
||||
.heroBanner {
|
||||
padding: 3rem 1.5rem;
|
||||
}
|
||||
|
||||
.heroContent {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.heroCopy {
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.heroCards {
|
||||
width: min(100%, 48rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 996px) {
|
||||
.heroBanner {
|
||||
padding: 2.5rem 1rem;
|
||||
}
|
||||
|
||||
.heroPreview {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.heroActions > :global(*) {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 640px) {
|
||||
.dataflowSection {
|
||||
padding-block: 4rem;
|
||||
}
|
||||
|
||||
.dataflowHeading {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.dataflowDiagram {
|
||||
aspect-ratio: 4 / 5;
|
||||
}
|
||||
|
||||
.dataflowNode {
|
||||
width: 4.5rem;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.statusDotFailed,
|
||||
.dataflowSignal,
|
||||
.dataflowSignalReverse,
|
||||
.dataflowCyclingIcons img {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.dataflowSignal,
|
||||
.dataflowSignalReverse {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dataflowCyclingIcons img:first-child {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import Layout from "@theme/Layout";
|
||||
import HomepageFeatures from "../components/pages/home/features/features";
|
||||
import HomepageUserReviews from "../components/pages/home/review-list/review-list";
|
||||
import HomeHero from "../components/pages/home/hero/hero";
|
||||
import { DataflowVisualizationComponent } from "../components/pages/home/dataflow/dataflow";
|
||||
|
||||
import styles from "./index.module.css";
|
||||
import BrowserOnly from "@docusaurus/BrowserOnly";
|
||||
import { SectionContainer } from "@site/src/components/pages/home/container/section-container";
|
||||
import { DragAndDropShowcase } from "@site/src/components/pages/home/drag-and-drop/drag-and-drop-showcase";
|
||||
import { AvailableIntegrations } from "@site/src/components/pages/home/integrations/available-integrations";
|
||||
@@ -32,13 +32,7 @@ export default function Home() {
|
||||
<SectionContainer>
|
||||
<HomepageFeatures />
|
||||
</SectionContainer>
|
||||
<BrowserOnly fallback={<span>loading...</span>}>
|
||||
{() => {
|
||||
const DataflowVisualizationComponent =
|
||||
require("../components/pages/home/dataflow/dataflow").DataflowVisualizationComponent;
|
||||
return <DataflowVisualizationComponent />;
|
||||
}}
|
||||
</BrowserOnly>
|
||||
<DataflowVisualizationComponent />
|
||||
<DragAndDropShowcase />
|
||||
<AvailableIntegrations />
|
||||
<SectionContainer>
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export function getRndInteger(min: number, max: number) {
|
||||
return Math.round(Math.random() * (max - min)) + min;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 141 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
+7
@@ -0,0 +1,7 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>OpenCollective</title>
|
||||
<path
|
||||
fill="#7FADF2"
|
||||
d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12c2.54 0 4.894-.79 6.834-2.135l-3.107-3.109a7.715 7.715 0 1 1 0-13.512l3.107-3.109A11.943 11.943 0 0 0 12 0zm9.865 5.166-3.109 3.107A7.67 7.67 0 0 1 19.715 12a7.682 7.682 0 0 1-.959 3.727l3.109 3.107A11.943 11.943 0 0 0 24 12c0-2.54-.79-4.894-2.135-6.834z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 447 B |
@@ -97,7 +97,6 @@
|
||||
"isomorphic-dompurify": "catalog:",
|
||||
"jotai": "catalog:",
|
||||
"jszip": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"mantine-datatable": "catalog:",
|
||||
"mantine-react-table": "catalog:",
|
||||
"mermaid": "catalog:",
|
||||
|
||||
+114
-105
@@ -26,6 +26,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconBrandGithub,
|
||||
IconCheck,
|
||||
IconCircleCheck,
|
||||
IconDatabaseSearch,
|
||||
@@ -51,14 +52,14 @@ import type { AssistantProvider, AssistantProviderCategory } from "@homarr/defin
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getWorkshopAssistantProviderUrl } from "~/components/workshop/workshop-client";
|
||||
|
||||
import classes from "./assistant-configuration.module.css";
|
||||
import { formatAssistantContextWindow, getAssistantConnectionState } from "./assistant-configuration-state";
|
||||
|
||||
type HeaderEntry = { id: number; name: string; value: string };
|
||||
type CredentialFlow = "idle" | "remove";
|
||||
type AssistantProviderOption = AssistantProvider | "homarr";
|
||||
|
||||
const ProviderIcon = ({ providerId, size = 20 }: { providerId: AssistantProviderOption; size?: number }) => {
|
||||
const ProviderIcon = ({ providerId, size = 20 }: { providerId: AssistantProvider; size?: number }) => {
|
||||
if (providerId === "homarr") {
|
||||
return <Image src="/logo/logo.png" alt="" aria-hidden w={size} h={size} fit="contain" />;
|
||||
}
|
||||
@@ -154,8 +155,13 @@ export const AssistantConfiguration = () => {
|
||||
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setProvider(configuration?.provider ?? "openrouter");
|
||||
setBaseUrl(configuration?.baseUrl ?? assistantProviderPresets.openrouter.baseUrl);
|
||||
const configuredProvider = configuration?.provider ?? "openrouter";
|
||||
setProvider(configuredProvider);
|
||||
setBaseUrl(
|
||||
configuredProvider === "homarr"
|
||||
? getWorkshopAssistantProviderUrl()
|
||||
: (configuration?.baseUrl ?? assistantProviderPresets.openrouter.baseUrl),
|
||||
);
|
||||
setModelDiscoveryPath(configuration?.modelDiscoveryPath ?? "");
|
||||
setModelId(configuration?.modelId ?? "");
|
||||
setEnabled(configuration?.enabled ?? false);
|
||||
@@ -203,20 +209,7 @@ export const AssistantConfiguration = () => {
|
||||
label: t(`provider.options.${providerId}.label`),
|
||||
}));
|
||||
|
||||
return {
|
||||
group: t(`provider.groups.${category}`),
|
||||
items:
|
||||
category === "hosted"
|
||||
? [
|
||||
{
|
||||
value: "homarr",
|
||||
label: t("provider.options.homarr.label"),
|
||||
disabled: true,
|
||||
},
|
||||
...items,
|
||||
]
|
||||
: items,
|
||||
};
|
||||
return { group: t(`provider.groups.${category}`), items };
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
@@ -232,6 +225,7 @@ export const AssistantConfiguration = () => {
|
||||
}, [modelId, models]);
|
||||
|
||||
const preset = assistantProviderPresets[provider];
|
||||
const isHomarrProvider = provider === "homarr";
|
||||
const headerValuesValid = headers.every((header) => header.name.trim().length > 0 && header.value.length > 0);
|
||||
const { hasStoredApiKey, connectionPending, connectionReady } = getAssistantConnectionState({
|
||||
connectionConfigured: configuration?.connectionConfigured === true,
|
||||
@@ -304,7 +298,7 @@ export const AssistantConfiguration = () => {
|
||||
const nextPreset = assistantProviderPresets[nextProvider];
|
||||
clearDraftConnectionState();
|
||||
setProvider(nextProvider);
|
||||
setBaseUrl(nextPreset.baseUrl);
|
||||
setBaseUrl(nextProvider === "homarr" ? getWorkshopAssistantProviderUrl() : nextPreset.baseUrl);
|
||||
setModelDiscoveryPath(nextPreset.modelDiscoveryPath ?? "");
|
||||
};
|
||||
|
||||
@@ -334,16 +328,19 @@ export const AssistantConfiguration = () => {
|
||||
};
|
||||
|
||||
const saveConnection = (clearApiKey = false) => {
|
||||
const customHeaders =
|
||||
headers.length > 0 ? Object.fromEntries(headers.map((header) => [header.name.trim(), header.value])) : undefined;
|
||||
const customHeaders = isHomarrProvider
|
||||
? undefined
|
||||
: headers.length > 0
|
||||
? Object.fromEntries(headers.map((header) => [header.name.trim(), header.value]))
|
||||
: undefined;
|
||||
updateConnection.mutate({
|
||||
provider,
|
||||
baseUrl,
|
||||
modelDiscoveryPath: modelDiscoveryPath.trim() || null,
|
||||
apiKey: clearApiKey ? undefined : apiKey.trim() || undefined,
|
||||
clearApiKey,
|
||||
apiKey: clearApiKey || isHomarrProvider ? undefined : apiKey.trim() || undefined,
|
||||
clearApiKey: clearApiKey || isHomarrProvider,
|
||||
customHeaders,
|
||||
clearCustomHeaders: clearHeaders,
|
||||
clearCustomHeaders: clearHeaders || isHomarrProvider,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -434,17 +431,12 @@ export const AssistantConfiguration = () => {
|
||||
leftSection={<ProviderIcon providerId={provider} />}
|
||||
renderOption={({ option }) => (
|
||||
<Group gap="sm" wrap="nowrap" align="center" w="100%">
|
||||
<ProviderIcon providerId={option.value as AssistantProviderOption} />
|
||||
<ProviderIcon providerId={option.value as AssistantProvider} />
|
||||
<Box flex={1} miw={0}>
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||
<Text size="sm" truncate>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.value === "homarr" && (
|
||||
<Badge size="xs" variant="light" color="gray" flex="0 0 auto">
|
||||
{t("provider.comingSoon")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
@@ -455,6 +447,7 @@ export const AssistantConfiguration = () => {
|
||||
description={t("baseUrl.description")}
|
||||
leftSection={<IconWorld size={16} />}
|
||||
value={baseUrl}
|
||||
disabled={isHomarrProvider}
|
||||
onChange={(event) => changeBaseUrl(event.currentTarget.value)}
|
||||
placeholder="https://provider.example/v1"
|
||||
/>
|
||||
@@ -462,6 +455,7 @@ export const AssistantConfiguration = () => {
|
||||
label={t("model.discoveryPath")}
|
||||
description={t("model.discoveryPathDescription")}
|
||||
value={modelDiscoveryPath}
|
||||
disabled={isHomarrProvider}
|
||||
onChange={(event) => changeModelDiscoveryPath(event.currentTarget.value)}
|
||||
placeholder="/models"
|
||||
/>
|
||||
@@ -473,7 +467,11 @@ export const AssistantConfiguration = () => {
|
||||
title={t("credentials.title")}
|
||||
description={t("credentials.description")}
|
||||
status={
|
||||
hasStoredApiKey ? undefined : (
|
||||
isHomarrProvider ? (
|
||||
<Badge variant="light" color="blue">
|
||||
{t("credentials.workshop")}
|
||||
</Badge>
|
||||
) : hasStoredApiKey ? undefined : (
|
||||
<Badge variant="light" color={preset.requiresApiKey ? "yellow" : "gray"}>
|
||||
{preset.requiresApiKey ? t("credentials.required") : t("credentials.optional")}
|
||||
</Badge>
|
||||
@@ -494,7 +492,11 @@ export const AssistantConfiguration = () => {
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{hasStoredApiKey && credentialFlow === "idle" ? (
|
||||
{isHomarrProvider ? (
|
||||
<Alert color="blue" icon={<IconBrandGithub size={18} />} title={t("provider.homarrAuth.title")}>
|
||||
{t("provider.homarrAuth.description")}
|
||||
</Alert>
|
||||
) : hasStoredApiKey && credentialFlow === "idle" ? (
|
||||
<Group className={classes.credentialSummary} justify="space-between" align="center">
|
||||
<Group className={classes.credentialSummaryContent} gap="sm" wrap="nowrap">
|
||||
<ThemeIcon color="teal" variant="light" radius="xl">
|
||||
@@ -545,80 +547,87 @@ export const AssistantConfiguration = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Accordion variant="contained" radius="md">
|
||||
<Accordion.Item value="headers">
|
||||
<Accordion.Control className={classes.advancedControl} icon={<IconShieldLock size={18} />}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("headers.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("headers.description")}
|
||||
</Text>
|
||||
</Box>
|
||||
{configuration?.customHeadersConfigured && (
|
||||
<Badge variant="light" color={clearHeaders ? "yellow" : "green"}>
|
||||
{clearHeaders ? t("headers.pendingRemoval") : t("headers.savedBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{configuration?.customHeadersConfigured ? t("headers.configured") : t("headers.empty")}
|
||||
</Text>
|
||||
<Button variant="light" size="compact-sm" leftSection={<IconPlus size={14} />} onClick={addHeader}>
|
||||
{t("headers.add")}
|
||||
</Button>
|
||||
{!isHomarrProvider && (
|
||||
<Accordion variant="contained" radius="md">
|
||||
<Accordion.Item value="headers">
|
||||
<Accordion.Control className={classes.advancedControl} icon={<IconShieldLock size={18} />}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{t("headers.title")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("headers.description")}
|
||||
</Text>
|
||||
</Box>
|
||||
{configuration?.customHeadersConfigured && (
|
||||
<Badge variant="light" color={clearHeaders ? "yellow" : "green"}>
|
||||
{clearHeaders ? t("headers.pendingRemoval") : t("headers.savedBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{headers.map((header) => (
|
||||
<Group key={header.id} align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
flex={1}
|
||||
miw="12rem"
|
||||
label={t("headers.name")}
|
||||
value={header.name}
|
||||
onChange={(event) => updateHeader(header.id, "name", event.currentTarget.value)}
|
||||
placeholder="X-Provider-Header"
|
||||
/>
|
||||
<PasswordInput
|
||||
flex={2}
|
||||
miw="12rem"
|
||||
label={t("headers.value")}
|
||||
value={header.value}
|
||||
onChange={(event) => updateHeader(header.id, "value", event.currentTarget.value)}
|
||||
placeholder={t("headers.valuePlaceholder")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Tooltip label={t("headers.remove")}>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("headers.remove")}
|
||||
onClick={() => setHeaders((current) => current.filter((item) => item.id !== header.id))}
|
||||
>
|
||||
<IconTrash size={17} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{configuration?.customHeadersConfigured ? t("headers.configured") : t("headers.empty")}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={addHeader}
|
||||
>
|
||||
{t("headers.add")}
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
{configuration?.customHeadersConfigured && headers.length === 0 && (
|
||||
<Button
|
||||
variant="light"
|
||||
color={clearHeaders ? "gray" : "red"}
|
||||
onClick={() => setClearHeaders(!clearHeaders)}
|
||||
>
|
||||
{clearHeaders ? t("headers.keep") : t("headers.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
{headers.map((header) => (
|
||||
<Group key={header.id} align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
flex={1}
|
||||
miw="12rem"
|
||||
label={t("headers.name")}
|
||||
value={header.name}
|
||||
onChange={(event) => updateHeader(header.id, "name", event.currentTarget.value)}
|
||||
placeholder="X-Provider-Header"
|
||||
/>
|
||||
<PasswordInput
|
||||
flex={2}
|
||||
miw="12rem"
|
||||
label={t("headers.value")}
|
||||
value={header.value}
|
||||
onChange={(event) => updateHeader(header.id, "value", event.currentTarget.value)}
|
||||
placeholder={t("headers.valuePlaceholder")}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Tooltip label={t("headers.remove")}>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label={t("headers.remove")}
|
||||
onClick={() => setHeaders((current) => current.filter((item) => item.id !== header.id))}
|
||||
>
|
||||
<IconTrash size={17} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
))}
|
||||
{configuration?.customHeadersConfigured && headers.length === 0 && (
|
||||
<Button
|
||||
variant="light"
|
||||
color={clearHeaders ? "gray" : "red"}
|
||||
onClick={() => setClearHeaders(!clearHeaders)}
|
||||
>
|
||||
{clearHeaders ? t("headers.keep") : t("headers.clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
)}
|
||||
</ConfigurationSection>
|
||||
|
||||
<ConfigurationSection
|
||||
|
||||
@@ -5,7 +5,7 @@ import { assistantExecutionPolicy } from "./assistant-execution-policy";
|
||||
describe("assistantExecutionPolicy", () => {
|
||||
test("allows a sizeable batch of tool calls to finish in one request", () => {
|
||||
expect(assistantExecutionPolicy.maxSteps).toBeGreaterThanOrEqual(32);
|
||||
expect(assistantExecutionPolicy.maxOutputTokens).toBeGreaterThanOrEqual(65_536);
|
||||
expect(assistantExecutionPolicy.maxOutputTokens).toBe(32_768);
|
||||
expect(assistantExecutionPolicy.totalTimeoutMs).toBeGreaterThan(assistantExecutionPolicy.stepTimeoutMs);
|
||||
expect(assistantExecutionPolicy.toolTimeoutMs).toBeGreaterThanOrEqual(60_000);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const assistantExecutionPolicy = {
|
||||
maxSteps: 32,
|
||||
maxOutputTokens: 65_536,
|
||||
maxOutputTokens: 32_768,
|
||||
totalTimeoutMs: 240_000,
|
||||
stepTimeoutMs: 60_000,
|
||||
toolTimeoutMs: 60_000,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { toProviderOptionsKey } from "./assistant-provider-options";
|
||||
import { assistantHomarrProviderTokenHeader } from "@homarr/definitions";
|
||||
|
||||
import {
|
||||
getHomarrProviderBaseUrl,
|
||||
resolveHomarrProviderToken,
|
||||
toProviderOptionsKey,
|
||||
} from "./assistant-provider-options";
|
||||
|
||||
describe("toProviderOptionsKey", () => {
|
||||
test("converts the hyphenated provider name to the camelCase AI SDK provider option key", () => {
|
||||
@@ -16,3 +22,55 @@ describe("toProviderOptionsKey", () => {
|
||||
expect(toProviderOptionsKey("homarr")).toBe("homarr");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHomarrProviderToken", () => {
|
||||
test("accepts a Workshop token only for the matching Homarr provider endpoint", () => {
|
||||
const headers = new Headers({ [assistantHomarrProviderTokenHeader]: "workshop-token" });
|
||||
expect(
|
||||
resolveHomarrProviderToken({
|
||||
provider: "homarr",
|
||||
configuredBaseUrl: "https://homarr.dev/api/ai/v1/",
|
||||
workshopApiUrl: "https://homarr.dev",
|
||||
headers,
|
||||
}),
|
||||
).toBe("workshop-token");
|
||||
expect(getHomarrProviderBaseUrl("https://homarr.dev/")).toBe("https://homarr.dev/api/ai/v1");
|
||||
});
|
||||
|
||||
test.each([
|
||||
[undefined, null],
|
||||
["", null],
|
||||
[" ", null],
|
||||
["a".repeat(4096), "a".repeat(4096)],
|
||||
["a".repeat(4097), null],
|
||||
])("validates the Workshop token header %s", (token, expected) => {
|
||||
const headers = new Headers(token === undefined ? undefined : { [assistantHomarrProviderTokenHeader]: token });
|
||||
expect(
|
||||
resolveHomarrProviderToken({
|
||||
provider: "homarr",
|
||||
configuredBaseUrl: "https://homarr.dev/api/ai/v1",
|
||||
workshopApiUrl: "https://homarr.dev",
|
||||
headers,
|
||||
}),
|
||||
).toBe(expected);
|
||||
});
|
||||
|
||||
test("never forwards a Workshop token to another endpoint", () => {
|
||||
expect(() =>
|
||||
resolveHomarrProviderToken({
|
||||
provider: "homarr",
|
||||
configuredBaseUrl: "https://attacker.example/v1",
|
||||
workshopApiUrl: "https://homarr.dev",
|
||||
headers: new Headers({ [assistantHomarrProviderTokenHeader]: "workshop-token" }),
|
||||
}),
|
||||
).toThrow("does not match");
|
||||
expect(
|
||||
resolveHomarrProviderToken({
|
||||
provider: "openrouter",
|
||||
configuredBaseUrl: "https://openrouter.ai/api/v1",
|
||||
workshopApiUrl: "https://homarr.dev",
|
||||
headers: new Headers({ [assistantHomarrProviderTokenHeader]: "workshop-token" }),
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { assistantHomarrProviderTokenHeader } from "@homarr/definitions";
|
||||
|
||||
/**
|
||||
* The AI SDK resolves provider options by the provider name registered on the
|
||||
* language model. The @ai-sdk/openai-compatible provider converts the raw
|
||||
@@ -15,3 +17,24 @@ export const toProviderOptionsKey = (name: string) =>
|
||||
return segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||
})
|
||||
.join("");
|
||||
|
||||
export const getHomarrProviderBaseUrl = (workshopApiUrl: string) => `${workshopApiUrl.replace(/\/+$/u, "")}/api/ai/v1`;
|
||||
|
||||
export const resolveHomarrProviderToken = ({
|
||||
provider,
|
||||
configuredBaseUrl,
|
||||
workshopApiUrl,
|
||||
headers,
|
||||
}: {
|
||||
provider: string;
|
||||
configuredBaseUrl: string;
|
||||
workshopApiUrl: string;
|
||||
headers: Headers;
|
||||
}) => {
|
||||
if (provider !== "homarr") return undefined;
|
||||
if (configuredBaseUrl.replace(/\/+$/u, "") !== getHomarrProviderBaseUrl(workshopApiUrl)) {
|
||||
throw new Error("The Homarr provider endpoint does not match the configured Workshop server.");
|
||||
}
|
||||
const token = headers.get(assistantHomarrProviderTokenHeader)?.trim();
|
||||
return token && token.length <= 4096 ? token : null;
|
||||
};
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
withOpenRouterToolRequestOptions,
|
||||
withOpenRouterWebSearch,
|
||||
} from "./assistant-openrouter";
|
||||
import { toProviderOptionsKey } from "./assistant-provider-options";
|
||||
import { resolveHomarrProviderToken, toProviderOptionsKey } from "./assistant-provider-options";
|
||||
import { assistantExecutionPolicy } from "./assistant-execution-policy";
|
||||
import { getAssistantStreamErrorMessage } from "./assistant-stream-error";
|
||||
import { getSafeAssistantToolError } from "./assistant-tool-error";
|
||||
@@ -72,6 +72,7 @@ export const maxDuration = 300;
|
||||
const assistantRequestMaxBytes = 12_000_000;
|
||||
|
||||
const logger = createLogger({ module: "assistant" });
|
||||
const getAssistantLogErrorType = (error: unknown) => (error instanceof Error ? error.name : typeof error);
|
||||
const getToolApprovalSecret = () =>
|
||||
Buffer.from(
|
||||
hkdfSync("sha256", Buffer.from(env.SECRET_ENCRYPTION_KEY, "hex"), "", "assistant-tool-approval", 32),
|
||||
@@ -424,6 +425,28 @@ export async function POST(request: Request) {
|
||||
return Response.json({ error: "Homarr Assistant is not configured." }, { status: 503 });
|
||||
}
|
||||
|
||||
let homarrProviderToken: string | null | undefined;
|
||||
try {
|
||||
homarrProviderToken = resolveHomarrProviderToken({
|
||||
provider: configuration.provider,
|
||||
configuredBaseUrl: configuration.baseUrl,
|
||||
workshopApiUrl: appEnv.WORKSHOP_API_URL ?? appEnv.HOMARR_WEBSITE_URL,
|
||||
headers: request.headers,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn("Rejected an unsafe Homarr provider endpoint", {
|
||||
provider: configuration.provider,
|
||||
errorType: getAssistantLogErrorType(error),
|
||||
});
|
||||
return Response.json({ error: "The Homarr provider endpoint is not configured safely." }, { status: 503 });
|
||||
}
|
||||
if (homarrProviderToken === null) {
|
||||
return Response.json(
|
||||
{ error: "Sign in to the Homarr Community Workshop to use the Homarr provider." },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const context = createTRPCContext({ headers: request.headers, session });
|
||||
const requestStartedAt = Date.now();
|
||||
const requestId = crypto.randomUUID();
|
||||
@@ -440,7 +463,7 @@ export async function POST(request: Request) {
|
||||
requestId,
|
||||
provider: configuration.provider,
|
||||
modelId: requestedModelId,
|
||||
error: modelLookup.error,
|
||||
errorType: getAssistantLogErrorType(modelLookup.error),
|
||||
});
|
||||
}
|
||||
const selectedModel = modelLookup.model;
|
||||
@@ -514,7 +537,7 @@ export async function POST(request: Request) {
|
||||
} catch (error) {
|
||||
logger.error("Assistant tool call failed", {
|
||||
toolName: mcpTool.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorType: getAssistantLogErrorType(error),
|
||||
});
|
||||
return { error: getSafeAssistantToolError(error, { toolName: mcpTool.name }) };
|
||||
}
|
||||
@@ -559,13 +582,18 @@ export async function POST(request: Request) {
|
||||
...(configuration.provider === "openrouter" || openRouterServerToolsEnabled
|
||||
? {
|
||||
"HTTP-Referer": "https://homarr.dev",
|
||||
"X-Title": "Homarr Assistant",
|
||||
"X-OpenRouter-Title": "Homarr AI Assistant",
|
||||
"X-OpenRouter-Metadata": "enabled",
|
||||
}
|
||||
: {}),
|
||||
...customHeaders,
|
||||
};
|
||||
const providerApiKey = configuration.encryptedApiKey ? decryptSecret(configuration.encryptedApiKey) : undefined;
|
||||
const providerApiKey =
|
||||
configuration.provider === "homarr"
|
||||
? homarrProviderToken
|
||||
: configuration.encryptedApiKey
|
||||
? decryptSecret(configuration.encryptedApiKey)
|
||||
: undefined;
|
||||
const providerName = `homarr-${configuration.provider}`;
|
||||
const provider = createOpenAICompatible({
|
||||
name: providerName,
|
||||
@@ -720,7 +748,7 @@ export async function POST(request: Request) {
|
||||
requestId,
|
||||
provider: configuration.provider,
|
||||
modelId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorType: getAssistantLogErrorType(error),
|
||||
});
|
||||
},
|
||||
onFinish: async () => {
|
||||
@@ -732,7 +760,7 @@ export async function POST(request: Request) {
|
||||
} catch (error) {
|
||||
logger.error("Failed to update assistant conversation metadata", {
|
||||
threadId: thread.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorType: getAssistantLogErrorType(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -839,7 +867,7 @@ export async function POST(request: Request) {
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Assistant response could not start", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
errorType: getAssistantLogErrorType(error),
|
||||
});
|
||||
return Response.json(
|
||||
{
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { AssistantProvider } from "@homarr/definitions";
|
||||
import type { WorkshopAssistantUsage, WorkshopUser } from "@homarr/workshop/schema";
|
||||
|
||||
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
|
||||
|
||||
/**
|
||||
@@ -27,14 +30,22 @@ export interface AssistantContextValue {
|
||||
export const AssistantContext = createContext<AssistantContextValue | null>(null);
|
||||
|
||||
export interface AssistantPreferencesContextValue {
|
||||
provider: AssistantProvider | null;
|
||||
defaultModelId: string | null;
|
||||
modelId: string | null;
|
||||
models: AssistantRuntimeModelOption[];
|
||||
reasoning: AssistantReasoningMode;
|
||||
isLoading: boolean;
|
||||
providerUser: WorkshopUser | null;
|
||||
quota: WorkshopAssistantUsage | null;
|
||||
quotaLoading: boolean;
|
||||
quotaError: string | null;
|
||||
setModelId: (modelId: string) => void;
|
||||
setReasoning: (reasoning: AssistantReasoningMode) => void;
|
||||
getRequestBody: () => { modelId?: string; reasoning: AssistantReasoningMode };
|
||||
getRequestHeaders: () => Record<string, string>;
|
||||
refreshQuota: () => Promise<void>;
|
||||
signInToProvider: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const AssistantPreferencesContext = createContext<AssistantPreferencesContextValue | null>(null);
|
||||
|
||||
@@ -1210,6 +1210,43 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.providerQuotaTrigger {
|
||||
display: inline-flex;
|
||||
height: 1.75rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding-inline: 0.38rem;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.providerQuotaTrigger:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.providerQuotaTrigger:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.providerQuotaTrigger[data-level="ok"] {
|
||||
color: light-dark(var(--mantine-color-green-8), var(--mantine-color-green-4));
|
||||
}
|
||||
|
||||
.providerQuotaTrigger[data-level="warning"] {
|
||||
color: light-dark(var(--mantine-color-yellow-8), var(--mantine-color-yellow-4));
|
||||
}
|
||||
|
||||
.providerQuotaTrigger[data-level="bad"] {
|
||||
color: light-dark(var(--mantine-color-orange-8), var(--mantine-color-orange-4));
|
||||
}
|
||||
|
||||
.providerQuotaTrigger[data-level="dead"] {
|
||||
color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-4));
|
||||
}
|
||||
|
||||
.conversationContextPopover {
|
||||
max-height: min(34rem, calc(100dvh - 1rem));
|
||||
overflow: hidden;
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
Image,
|
||||
Loader,
|
||||
Popover,
|
||||
Progress,
|
||||
RingProgress,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
@@ -79,6 +80,10 @@ import {
|
||||
IconArrowUp,
|
||||
IconArrowsMaximize,
|
||||
IconAt,
|
||||
IconBattery1,
|
||||
IconBattery3,
|
||||
IconBatteryFilled,
|
||||
IconBatteryOff,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconChevronLeft,
|
||||
@@ -115,10 +120,11 @@ import remarkGfm from "remark-gfm";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { clientApi, fetchApi } from "@homarr/api/client";
|
||||
import { useTimeAgo } from "@homarr/common";
|
||||
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
|
||||
import type { AssistantProvider } from "@homarr/definitions";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./assistant-panel.module.css";
|
||||
import { getAssistantActivityState } from "./assistant-activity-state";
|
||||
@@ -129,6 +135,7 @@ import {
|
||||
getAssistantConversationExportFilename,
|
||||
} from "./assistant-conversation-export";
|
||||
import { AssistantDotMatrix } from "./assistant-dot-matrix";
|
||||
import { useAssistantPreferences } from "./assistant-context";
|
||||
import { AssistantImage } from "./assistant-image";
|
||||
import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from "./assistant-directives";
|
||||
import { remarkAssistantDirectives, resolveAssistantDirectiveEntity } from "./assistant-markdown-directives";
|
||||
@@ -149,6 +156,7 @@ import { useAssistantReasoningState } from "./assistant-reasoning-state";
|
||||
import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll";
|
||||
import { getToolResultPresentation } from "./assistant-tool-result";
|
||||
import { getAssistantIconSearchQuery } from "./assistant-tool-label";
|
||||
import { getAssistantProviderQuotaLevel, isAssistantProviderUnavailable } from "./assistant-provider-quota";
|
||||
import { getAssistantToolTraceTarget } from "./assistant-tool-trace";
|
||||
import { getSafeAssistantHttpUrl } from "./assistant-url";
|
||||
|
||||
@@ -2537,10 +2545,154 @@ const RuntimeControls = ({
|
||||
);
|
||||
};
|
||||
|
||||
const providerQuotaIcons = {
|
||||
ok: IconBatteryFilled,
|
||||
warning: IconBattery3,
|
||||
bad: IconBattery1,
|
||||
dead: IconBatteryOff,
|
||||
};
|
||||
|
||||
const providerQuotaColors = {
|
||||
ok: "green",
|
||||
warning: "yellow",
|
||||
bad: "orange",
|
||||
dead: "red",
|
||||
} as const;
|
||||
|
||||
const HomarrProviderQuota = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const locale = useCurrentIntlLocale();
|
||||
const preferences = useAssistantPreferences();
|
||||
const quota = preferences.quota;
|
||||
const resetAt = useMemo(() => new Date(quota?.resetsAt ?? Date.now()), [quota?.resetsAt]);
|
||||
const resetRelative = useTimeAgo(resetAt, 30_000);
|
||||
if (preferences.provider !== "homarr") return null;
|
||||
|
||||
const level = preferences.quota ? getAssistantProviderQuotaLevel(preferences.quota) : "dead";
|
||||
const Icon = providerQuotaIcons[level];
|
||||
const color = providerQuotaColors[level];
|
||||
const percentage = preferences.quota
|
||||
? Math.min(100, Math.max(0, (preferences.quota.remaining / Math.max(preferences.quota.limit, 1)) * 100))
|
||||
: 0;
|
||||
const label = preferences.providerUser
|
||||
? quota
|
||||
? t("providerQuota.remainingLabel", {
|
||||
remaining: quota.remaining,
|
||||
limit: quota.limit,
|
||||
})
|
||||
: t("providerQuota.loading")
|
||||
: t("providerQuota.signInRequired");
|
||||
const resetTime = quota ? (
|
||||
<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;
|
||||
|
||||
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>
|
||||
|
||||
{!preferences.providerUser ? (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
) : preferences.quota ? (
|
||||
<>
|
||||
<Box>
|
||||
<Group justify="space-between" gap="xs" mb={5}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("providerQuota.dailyAllowance")}
|
||||
</Text>
|
||||
<Text size="xs" fw={650}>
|
||||
{preferences.quota.remaining} / {preferences.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>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-sm"
|
||||
loading={preferences.quotaLoading}
|
||||
onClick={() => void preferences.refreshQuota()}
|
||||
>
|
||||
{t("providerQuota.retry")}
|
||||
</Button>
|
||||
)}
|
||||
{preferences.quotaError && (
|
||||
<Text size="xs" c="red">
|
||||
{preferences.quotaError}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
const Composer = (props: ComposerProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const preferences = useAssistantPreferences();
|
||||
const running = useAuiState((state) => state.thread.isRunning);
|
||||
const hasPendingAction = props.pendingAction !== undefined;
|
||||
const providerUnavailable = isAssistantProviderUnavailable({
|
||||
provider: preferences.provider,
|
||||
signedIn: preferences.providerUser !== null,
|
||||
remaining: preferences.quota?.remaining,
|
||||
});
|
||||
const sendBlocked = hasPendingAction || providerUnavailable;
|
||||
const composerInputRef = useRef<HTMLDivElement>(null);
|
||||
const composerLabel = t("composerPlaceholder");
|
||||
|
||||
@@ -2561,7 +2713,7 @@ const Composer = (props: ComposerProps) => {
|
||||
className={classes.composer}
|
||||
data-pending-action={hasPendingAction || undefined}
|
||||
onSubmit={(event) => {
|
||||
if (hasPendingAction) event.preventDefault();
|
||||
if (sendBlocked) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ComposerTriggers />
|
||||
@@ -2607,8 +2759,14 @@ const Composer = (props: ComposerProps) => {
|
||||
color="red"
|
||||
variant="filled"
|
||||
size="lg"
|
||||
aria-label={hasPendingAction ? t("pendingAction.sendBlocked") : t("send")}
|
||||
disabled={hasPendingAction}
|
||||
aria-label={
|
||||
hasPendingAction
|
||||
? t("pendingAction.sendBlocked")
|
||||
: providerUnavailable
|
||||
? t("providerQuota.unavailableDescription")
|
||||
: t("send")
|
||||
}
|
||||
disabled={sendBlocked}
|
||||
>
|
||||
<IconArrowUp size={18} />
|
||||
</ActionIcon>
|
||||
@@ -2618,6 +2776,7 @@ const Composer = (props: ComposerProps) => {
|
||||
<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 className={classes.composerHints} gap="xs" wrap="nowrap">
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
getAssistantProviderQuotaLevel,
|
||||
getAssistantProviderQuotaRefreshDelay,
|
||||
isAssistantProviderUnavailable,
|
||||
} from "./assistant-provider-quota";
|
||||
|
||||
describe("getAssistantProviderQuotaLevel", () => {
|
||||
test.each([
|
||||
[{ limit: 50, remaining: 50 }, "ok"],
|
||||
[{ limit: 50, remaining: 26 }, "ok"],
|
||||
[{ limit: 50, remaining: 25 }, "warning"],
|
||||
[{ limit: 50, remaining: 10 }, "bad"],
|
||||
[{ limit: 50, remaining: 0 }, "dead"],
|
||||
[{ limit: 0, remaining: 0 }, "dead"],
|
||||
] as const)("maps %o to %s", (usage, expected) => {
|
||||
expect(getAssistantProviderQuotaLevel(usage)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAssistantProviderUnavailable", () => {
|
||||
test("blocks only unavailable Homarr provider sessions", () => {
|
||||
expect(isAssistantProviderUnavailable({ provider: "homarr", signedIn: false, remaining: undefined })).toBe(true);
|
||||
expect(isAssistantProviderUnavailable({ provider: "homarr", signedIn: true, remaining: 0 })).toBe(true);
|
||||
expect(isAssistantProviderUnavailable({ provider: "homarr", signedIn: true, remaining: undefined })).toBe(false);
|
||||
expect(isAssistantProviderUnavailable({ provider: "openrouter", signedIn: false, remaining: 0 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAssistantProviderQuotaRefreshDelay", () => {
|
||||
test("refreshes just after the UTC reset, or immediately when it has passed", () => {
|
||||
const reset = "2026-08-12T00:00:00.000Z";
|
||||
expect(getAssistantProviderQuotaRefreshDelay(reset, Date.parse("2026-08-11T23:59:00.000Z"))).toBe(61_000);
|
||||
expect(getAssistantProviderQuotaRefreshDelay(reset, Date.parse("2026-08-12T00:00:01.000Z"))).toBe(1_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { WorkshopAssistantUsage } from "@homarr/workshop/schema";
|
||||
import type { AssistantProvider } from "@homarr/definitions";
|
||||
|
||||
export type AssistantProviderQuotaLevel = "ok" | "warning" | "bad" | "dead";
|
||||
|
||||
export const getAssistantProviderQuotaRefreshDelay = (resetsAt: string, now = Date.now()) =>
|
||||
Math.max(0, Date.parse(resetsAt) - now) + 1_000;
|
||||
|
||||
export const getAssistantProviderQuotaLevel = (
|
||||
usage: Pick<WorkshopAssistantUsage, "limit" | "remaining">,
|
||||
): AssistantProviderQuotaLevel => {
|
||||
if (usage.remaining === 0 || usage.limit === 0) return "dead";
|
||||
const remainingRatio = usage.remaining / usage.limit;
|
||||
if (remainingRatio <= 0.2) return "bad";
|
||||
if (remainingRatio <= 0.5) return "warning";
|
||||
return "ok";
|
||||
};
|
||||
|
||||
export const isAssistantProviderUnavailable = ({
|
||||
provider,
|
||||
signedIn,
|
||||
remaining,
|
||||
}: {
|
||||
provider: AssistantProvider | null;
|
||||
signedIn: boolean;
|
||||
remaining: number | undefined;
|
||||
}) => provider === "homarr" && (!signedIn || remaining === 0);
|
||||
@@ -19,15 +19,17 @@ import { createAssistantStream } from "assistant-stream";
|
||||
|
||||
import { clientApi, fetchApi } from "@homarr/api/client";
|
||||
import { createId } from "@homarr/common";
|
||||
import { hotkeys } from "@homarr/definitions";
|
||||
import { assistantHomarrProviderTokenHeader, hotkeys } from "@homarr/definitions";
|
||||
import { showErrorNotification, showWarningNotification } from "@homarr/notifications";
|
||||
import { closeSpotlight } from "@homarr/spotlight/store";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResults } from "@homarr/spotlight";
|
||||
import { AssistantWidgetRendererProvider } from "@homarr/widgets";
|
||||
import { createWorkshopClient } from "~/components/workshop/workshop-client";
|
||||
|
||||
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
|
||||
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
|
||||
import { getAssistantProviderQuotaRefreshDelay, isAssistantProviderUnavailable } from "./assistant-provider-quota";
|
||||
import { AssistantAutoApprovalProvider } from "./assistant-auto-approval";
|
||||
import { getRunningAssistantPartType } from "./assistant-activity-state";
|
||||
import { prepareAssistantRequestBody } from "./assistant-attachment-payload";
|
||||
@@ -124,11 +126,18 @@ const createAssistantAttachmentAdapter = (allowImages: boolean): AttachmentAdapt
|
||||
};
|
||||
|
||||
const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const { data, isLoading } = clientApi.assistant.getRuntimeOptions.useQuery(undefined, {
|
||||
staleTime: 10 * 60_000,
|
||||
});
|
||||
const [modelId, setModelIdState] = useState<string | null>(null);
|
||||
const [reasoning, setReasoningState] = useState<AssistantReasoningMode>("auto");
|
||||
const workshopClient = useMemo(createWorkshopClient, []);
|
||||
const [providerUser, setProviderUser] = useState(workshopClient.currentUser);
|
||||
const [quota, setQuota] = useState<Awaited<ReturnType<typeof workshopClient.getAssistantUsage>> | null>(null);
|
||||
const [quotaLoading, setQuotaLoading] = useState(false);
|
||||
const [quotaError, setQuotaError] = useState<string | null>(null);
|
||||
const quotaRequestRef = useRef<AbortController | null>(null);
|
||||
const previousDefaultModelIdRef = useRef<string | null | undefined>(undefined);
|
||||
const preferencesRef = useRef<{ modelId: string | null; reasoning: AssistantReasoningMode }>({
|
||||
modelId: null,
|
||||
@@ -163,6 +172,52 @@ const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
|
||||
reasoning: current.reasoning,
|
||||
};
|
||||
}, []);
|
||||
const getRequestHeaders = useCallback((): Record<string, string> => {
|
||||
if (data?.provider !== "homarr") return {};
|
||||
const token = workshopClient.authToken;
|
||||
return token ? { [assistantHomarrProviderTokenHeader]: token } : {};
|
||||
}, [data?.provider, workshopClient]);
|
||||
const refreshQuota = useCallback(async () => {
|
||||
quotaRequestRef.current?.abort();
|
||||
if (data?.provider !== "homarr" || !workshopClient.authToken) {
|
||||
quotaRequestRef.current = null;
|
||||
setQuota(null);
|
||||
setQuotaError(null);
|
||||
setQuotaLoading(false);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
quotaRequestRef.current = controller;
|
||||
setQuotaLoading(true);
|
||||
setQuotaError(null);
|
||||
try {
|
||||
const nextQuota = await workshopClient.getAssistantUsage(controller.signal);
|
||||
if (quotaRequestRef.current === controller && !controller.signal.aborted) setQuota(nextQuota);
|
||||
} catch {
|
||||
if (quotaRequestRef.current === controller && !controller.signal.aborted) {
|
||||
setQuotaError(t("providerQuota.loadError"));
|
||||
}
|
||||
} finally {
|
||||
if (quotaRequestRef.current === controller) {
|
||||
quotaRequestRef.current = null;
|
||||
setQuotaLoading(false);
|
||||
}
|
||||
}
|
||||
}, [data?.provider, t, workshopClient]);
|
||||
const signInToProvider = useCallback(async () => {
|
||||
setQuotaLoading(true);
|
||||
setQuotaError(null);
|
||||
try {
|
||||
const user = await workshopClient.signInWithGitHub();
|
||||
setProviderUser(user);
|
||||
await refreshQuota();
|
||||
} catch (error) {
|
||||
setQuotaError(t("providerQuota.signInError"));
|
||||
throw error;
|
||||
} finally {
|
||||
setQuotaLoading(false);
|
||||
}
|
||||
}, [refreshQuota, t, workshopClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
@@ -178,18 +233,76 @@ const AssistantPreferencesProvider = ({ children }: PropsWithChildren) => {
|
||||
setModelIdState(nextModelId);
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.provider !== "homarr") {
|
||||
setProviderUser(null);
|
||||
setQuota(null);
|
||||
setQuotaError(null);
|
||||
return;
|
||||
}
|
||||
const unsubscribe = workshopClient.subscribeToAuth(setProviderUser);
|
||||
void workshopClient
|
||||
.refreshAuth()
|
||||
.then(setProviderUser)
|
||||
.catch(() => undefined);
|
||||
return unsubscribe;
|
||||
}, [data?.provider, workshopClient]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
quotaRequestRef.current?.abort();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.provider !== "homarr" || !providerUser) return;
|
||||
void refreshQuota();
|
||||
}, [data?.provider, providerUser, refreshQuota]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.provider !== "homarr" || !providerUser || !quota) return;
|
||||
const timeout = window.setTimeout(() => void refreshQuota(), getAssistantProviderQuotaRefreshDelay(quota.resetsAt));
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [data?.provider, providerUser, quota, refreshQuota]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
provider: data?.provider ?? null,
|
||||
defaultModelId: data?.defaultModelId ?? null,
|
||||
modelId,
|
||||
models,
|
||||
reasoning,
|
||||
isLoading,
|
||||
providerUser,
|
||||
quota,
|
||||
quotaLoading,
|
||||
quotaError,
|
||||
setModelId,
|
||||
setReasoning,
|
||||
getRequestBody,
|
||||
getRequestHeaders,
|
||||
refreshQuota,
|
||||
signInToProvider,
|
||||
}),
|
||||
[data?.defaultModelId, getRequestBody, isLoading, modelId, models, reasoning, setModelId, setReasoning],
|
||||
[
|
||||
data?.defaultModelId,
|
||||
data?.provider,
|
||||
getRequestBody,
|
||||
getRequestHeaders,
|
||||
isLoading,
|
||||
modelId,
|
||||
models,
|
||||
providerUser,
|
||||
quota,
|
||||
quotaError,
|
||||
quotaLoading,
|
||||
reasoning,
|
||||
refreshQuota,
|
||||
setModelId,
|
||||
setReasoning,
|
||||
signInToProvider,
|
||||
],
|
||||
);
|
||||
|
||||
return <AssistantPreferencesContext.Provider value={value}>{children}</AssistantPreferencesContext.Provider>;
|
||||
@@ -329,14 +442,19 @@ const AssistantThreadRuntime = () => {
|
||||
const localThreadId = useAuiState((state) => state.threadListItem.id);
|
||||
const threadId = useAuiState((state) => state.threadListItem.remoteId);
|
||||
const preferences = useAssistantPreferences();
|
||||
const { getRequestBody, getRequestHeaders, refreshQuota } = preferences;
|
||||
const transport = useMemo(
|
||||
() =>
|
||||
new AssistantChatTransport({
|
||||
api: "/api/assistant/chat",
|
||||
body: preferences.getRequestBody,
|
||||
fetch: (input, init) => globalThis.fetch(input, { ...init, body: prepareAssistantRequestBody(init?.body) }),
|
||||
body: getRequestBody,
|
||||
fetch: (input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
for (const [name, value] of Object.entries(getRequestHeaders())) headers.set(name, value);
|
||||
return globalThis.fetch(input, { ...init, headers, body: prepareAssistantRequestBody(init?.body) });
|
||||
},
|
||||
}),
|
||||
[preferences.getRequestBody],
|
||||
[getRequestBody, getRequestHeaders],
|
||||
);
|
||||
const history = useMemo(() => createHistoryAdapter(threadId), [threadId]);
|
||||
const selectedModel = preferences.models.find((model) => model.id === preferences.modelId);
|
||||
@@ -360,20 +478,21 @@ const AssistantThreadRuntime = () => {
|
||||
}),
|
||||
[t, threadId],
|
||||
);
|
||||
const onError = useCallback(
|
||||
() =>
|
||||
showErrorNotification({
|
||||
title: t("responseError.title"),
|
||||
message: t("responseError.description"),
|
||||
autoClose: 10_000,
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
const onError = useCallback(() => {
|
||||
void refreshQuota();
|
||||
showErrorNotification({
|
||||
title: t("responseError.title"),
|
||||
message: t("responseError.description"),
|
||||
autoClose: 10_000,
|
||||
});
|
||||
}, [refreshQuota, t]);
|
||||
const onFinish = useCallback(() => void refreshQuota(), [refreshQuota]);
|
||||
|
||||
const chat = useChat<AssistantUIMessage>({
|
||||
id: localThreadId,
|
||||
transport,
|
||||
onError,
|
||||
onFinish,
|
||||
sendAutomaticallyWhen: shouldAutomaticallyContinueAssistant,
|
||||
});
|
||||
|
||||
@@ -560,6 +679,11 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
);
|
||||
const pendingAction = getPendingAssistantAction(latestAssistantMessage);
|
||||
const assistantIsRunning = isRunning || queuedPrompt !== null;
|
||||
const providerUnavailable = isAssistantProviderUnavailable({
|
||||
provider: preferences.provider,
|
||||
signedIn: preferences.providerUser !== null,
|
||||
remaining: preferences.quota?.remaining,
|
||||
});
|
||||
const notificationKey = getNotificationKey(latestAssistantMessage);
|
||||
const notificationStateRef = useRef(initialAssistantNotificationState);
|
||||
const mutationRefreshStateRef = useRef<{ conversationId: string | null; toolCallIds: Set<string> }>({
|
||||
@@ -590,6 +714,15 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
(prompt: string) => {
|
||||
const text = prompt.trim();
|
||||
if (text.length === 0) return false;
|
||||
if (providerUnavailable) {
|
||||
showWarningNotification({
|
||||
title: t("providerQuota.title"),
|
||||
message: preferences.providerUser
|
||||
? t("providerQuota.exhaustedDescription")
|
||||
: t("providerQuota.signInDescription"),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (assistantIsRunning || latestStatus?.type === "requires-action") {
|
||||
showWarningNotification({
|
||||
title: t("busy.title"),
|
||||
@@ -604,7 +737,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
}
|
||||
return sendPromptThroughRuntime(aui, text);
|
||||
},
|
||||
[assistantIsRunning, aui, isLoading, latestStatus?.type, t],
|
||||
[assistantIsRunning, aui, isLoading, latestStatus?.type, preferences.providerUser, providerUnavailable, t],
|
||||
);
|
||||
const selectModel = useCallback(
|
||||
(modelId: string) => {
|
||||
@@ -622,10 +755,14 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isRunning || queuedPrompt === null) return;
|
||||
if (providerUnavailable) {
|
||||
setQueuedPrompt(null);
|
||||
return;
|
||||
}
|
||||
const prompt = queuedPrompt;
|
||||
setQueuedPrompt(null);
|
||||
sendPromptThroughRuntime(aui, prompt);
|
||||
}, [aui, isLoading, isRunning, queuedPrompt]);
|
||||
}, [aui, isLoading, isRunning, providerUnavailable, queuedPrompt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
@@ -667,12 +804,16 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
useHotkeys([[hotkeys.openAssistant, open, { preventDefault: true }]]);
|
||||
|
||||
const spotlightItem = useMemo(() => {
|
||||
const canSend = !assistantIsRunning && latestStatus?.type !== "requires-action";
|
||||
const canSend = !providerUnavailable && !assistantIsRunning && latestStatus?.type !== "requires-action";
|
||||
return {
|
||||
id: "homarr-assistant",
|
||||
name: t("spotlight"),
|
||||
icon: "/logo/logo.png",
|
||||
description: canSend ? t("spotlightDescription") : t("busy.description"),
|
||||
description: providerUnavailable
|
||||
? t("providerQuota.unavailableDescription")
|
||||
: canSend
|
||||
? t("spotlightDescription")
|
||||
: t("busy.description"),
|
||||
unavailable: !canSend,
|
||||
alwaysVisible: true,
|
||||
interaction: (query: string) =>
|
||||
@@ -683,7 +824,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
canSend,
|
||||
}),
|
||||
};
|
||||
}, [assistantIsRunning, latestStatus?.type, sendPrompt, t]);
|
||||
}, [assistantIsRunning, latestStatus?.type, providerUnavailable, sendPrompt, t]);
|
||||
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
|
||||
|
||||
const value = useMemo(
|
||||
|
||||
@@ -3,13 +3,23 @@ import { WORKSHOP_API_URL, WORKSHOP_WEB_URL } from "@homarr/workshop/schema";
|
||||
|
||||
let browserClient: WorkshopBackend | undefined;
|
||||
|
||||
export function getWorkshopApiUrl() {
|
||||
const runtimeUrl =
|
||||
typeof document === "undefined"
|
||||
? undefined
|
||||
: document.querySelector<HTMLMetaElement>('meta[name="homarr-workshop-api-url"]')?.content;
|
||||
return (runtimeUrl || WORKSHOP_API_URL).replace(/\/+$/u, "");
|
||||
}
|
||||
|
||||
export function getWorkshopAssistantProviderUrl() {
|
||||
return `${getWorkshopApiUrl()}/api/ai/v1`;
|
||||
}
|
||||
|
||||
export function createWorkshopClient() {
|
||||
if (typeof document === "undefined") return new WorkshopBackend(WORKSHOP_API_URL);
|
||||
if (typeof document === "undefined") return new WorkshopBackend(getWorkshopApiUrl());
|
||||
if (browserClient) return browserClient;
|
||||
|
||||
const runtimeUrl = document.querySelector<HTMLMetaElement>('meta[name="homarr-workshop-api-url"]')?.content;
|
||||
|
||||
browserClient = new WorkshopBackend(runtimeUrl || WORKSHOP_API_URL);
|
||||
browserClient = new WorkshopBackend(getWorkshopApiUrl());
|
||||
return browserClient;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getWorkshopWebUrl } from "./workshop-client";
|
||||
import { getWorkshopAssistantProviderUrl, getWorkshopWebUrl } from "./workshop-client";
|
||||
|
||||
describe("Workshop widget install flow", () => {
|
||||
afterEach(() => {
|
||||
@@ -17,6 +17,12 @@ describe("Workshop widget install flow", () => {
|
||||
expect(getWorkshopWebUrl("widget/id")).toBe("https://preview.example/workshop/widget%2Fid");
|
||||
});
|
||||
|
||||
it("derives the Homarr provider endpoint from the runtime Workshop API", () => {
|
||||
document.head.innerHTML = '<meta name="homarr-workshop-api-url" content="https://api.preview.example/">';
|
||||
|
||||
expect(getWorkshopAssistantProviderUrl()).toBe("https://api.preview.example/api/ai/v1");
|
||||
});
|
||||
|
||||
it("uses one registered modal stack for browsing, details, reports, and confirmation", () => {
|
||||
const installButton = readFileSync(
|
||||
`${process.cwd()}/apps/nextjs/src/components/workshop/workshop-install-button.tsx`,
|
||||
|
||||
@@ -7,6 +7,14 @@ HOMARR_WEBSITE_URL=http://127.0.0.1:3003
|
||||
WORKSHOP_API_URL=http://127.0.0.1:8090
|
||||
WORKSHOP_WEB_URL=http://127.0.0.1:3003/workshop
|
||||
|
||||
# Optional Homarr provider. The upstream key stays only in the Workshop server.
|
||||
# OPENROUTER_API_KEY=
|
||||
HOMARR_AI_DAILY_REQUEST_LIMIT=50
|
||||
HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT=10000
|
||||
# HOMARR_AI_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
# HOMARR_AI_OPENROUTER_MODEL=
|
||||
# HOMARR_AI_ALLOW_INSECURE_UPSTREAM=false
|
||||
|
||||
# Optional local GitHub OAuth application.
|
||||
# GITHUB_CLIENT_ID=
|
||||
# GITHUB_CLIENT_SECRET=
|
||||
@@ -7,6 +7,13 @@ WORKSHOP_WEB_URL=https://v2.preview.homarr.dev/workshop
|
||||
WORKSHOP_PUBLIC_ORIGIN=https://v2.preview.homarr.dev
|
||||
PB_ALLOWED_ORIGINS=*
|
||||
|
||||
# Set this secret in the deployment environment to enable the Homarr provider.
|
||||
# OPENROUTER_API_KEY=
|
||||
HOMARR_AI_DAILY_REQUEST_LIMIT=50
|
||||
HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT=10000
|
||||
# HOMARR_AI_OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
# HOMARR_AI_OPENROUTER_MODEL=
|
||||
|
||||
# Optional GitHub OAuth application for the preview hostname.
|
||||
# GITHUB_CLIENT_ID=
|
||||
# GITHUB_CLIENT_SECRET=
|
||||
@@ -2,6 +2,17 @@
|
||||
|
||||
ARG POCKETBASE_IMAGE=ghcr.io/muchobien/pocketbase:0.39.9@sha256:c0667fe6c5197ff226ff642120ab1ad870c3f2f27300940a61e5a7fa082b6816
|
||||
|
||||
FROM golang:1.25.7-alpine AS pocketbase-builder
|
||||
WORKDIR /src
|
||||
COPY apps/workshop/go.mod apps/workshop/go.sum ./
|
||||
RUN --mount=type=cache,id=workshop-go-mod,target=/go/pkg/mod go mod download
|
||||
COPY apps/workshop/*.go ./
|
||||
RUN --mount=type=cache,id=workshop-go-mod,target=/go/pkg/mod \
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/pocketbase .
|
||||
|
||||
FROM pocketbase-builder AS pocketbase-test
|
||||
RUN --mount=type=cache,id=workshop-go-mod,target=/go/pkg/mod go test ./...
|
||||
|
||||
FROM node:24.18.0-alpine AS dependencies
|
||||
WORKDIR /app
|
||||
ENV COREPACK_HOME=/corepack
|
||||
@@ -26,6 +37,7 @@ RUN CI=true SKIP_ENV_VALIDATION=true pnpm turbo build --filter=@homarr/docs
|
||||
|
||||
FROM ${POCKETBASE_IMAGE} AS pocketbase-runtime
|
||||
ENV PORT=8090
|
||||
COPY --from=pocketbase-builder /out/pocketbase /usr/local/bin/pocketbase
|
||||
RUN addgroup -S pocketbase && adduser -S -G pocketbase pocketbase
|
||||
COPY apps/workshop/entrypoint.sh /usr/local/bin/workshop-entrypoint
|
||||
RUN chmod 755 /usr/local/bin/workshop-entrypoint && mkdir -p /pb_data /pb_public && chown pocketbase:pocketbase /pb_data /pb_public
|
||||
|
||||
@@ -123,6 +123,18 @@ pnpm --filter @homarr/docs typecheck
|
||||
|
||||
The integration suite uses a separate Compose project and deletes only its disposable volume.
|
||||
|
||||
To smoke-test a deployed Workshop against its real configured upstream, use a dedicated low-quota test user:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
@@ -13,6 +13,11 @@ services:
|
||||
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:?Set WORKSHOP_WEB_URL in the Workshop environment file}
|
||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||
HOMARR_AI_DAILY_REQUEST_LIMIT: ${HOMARR_AI_DAILY_REQUEST_LIMIT:-50}
|
||||
HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT: ${HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT:-10000}
|
||||
HOMARR_AI_OPENROUTER_BASE_URL: ${HOMARR_AI_OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}
|
||||
HOMARR_AI_OPENROUTER_MODEL: ${HOMARR_AI_OPENROUTER_MODEL:-~deepseek/deepseek-v4-flash-latest}
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||
ports:
|
||||
- "127.0.0.1:${WORKSHOP_EXPOSE_PORT:-8090}:8090"
|
||||
volumes:
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
name: homarr-workshop
|
||||
|
||||
services:
|
||||
assistant-upstream:
|
||||
profiles:
|
||||
- test
|
||||
image: node:24.18.0-alpine
|
||||
command: ["node", "/mock/openrouter-mock.mjs"]
|
||||
environment:
|
||||
PORT: 18091
|
||||
EXPECTED_MODEL: mock/team-selected-model
|
||||
volumes:
|
||||
- ./tests/openrouter-mock.mjs:/mock/openrouter-mock.mjs:ro
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
workshop:
|
||||
build:
|
||||
context: ../..
|
||||
@@ -15,6 +28,12 @@ services:
|
||||
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop}
|
||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||
HOMARR_AI_DAILY_REQUEST_LIMIT: ${HOMARR_AI_DAILY_REQUEST_LIMIT:-50}
|
||||
HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT: ${HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT:-10000}
|
||||
HOMARR_AI_OPENROUTER_BASE_URL: ${HOMARR_AI_OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}
|
||||
HOMARR_AI_OPENROUTER_MODEL: ${HOMARR_AI_OPENROUTER_MODEL:-~deepseek/deepseek-v4-flash-latest}
|
||||
HOMARR_AI_ALLOW_INSECURE_UPSTREAM: ${HOMARR_AI_ALLOW_INSECURE_UPSTREAM:-false}
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:-http://localhost:3003}
|
||||
ports:
|
||||
- "127.0.0.1:${PB_EXPOSE_PORT:-8090}:${PB_EXPOSE_PORT:-8090}"
|
||||
@@ -44,6 +63,12 @@ services:
|
||||
WORKSHOP_WEB_URL: ${WORKSHOP_WEB_URL:-http://localhost:3003/workshop}
|
||||
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||
HOMARR_AI_DAILY_REQUEST_LIMIT: ${HOMARR_AI_DAILY_REQUEST_LIMIT:-50}
|
||||
HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT: ${HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT:-10000}
|
||||
HOMARR_AI_OPENROUTER_BASE_URL: ${HOMARR_AI_OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}
|
||||
HOMARR_AI_OPENROUTER_MODEL: ${HOMARR_AI_OPENROUTER_MODEL:-~deepseek/deepseek-v4-flash-latest}
|
||||
HOMARR_AI_ALLOW_INSECURE_UPSTREAM: ${HOMARR_AI_ALLOW_INSECURE_UPSTREAM:-false}
|
||||
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
|
||||
WORKSHOP_PUBLIC_ORIGIN: ${WORKSHOP_PUBLIC_ORIGIN:-http://localhost:3003}
|
||||
ports:
|
||||
- "127.0.0.1:${DOCS_EXPOSE_PORT:-3003}:${DOCS_EXPOSE_PORT:-3003}"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
module github.com/homarr-labs/homarr/apps/workshop
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
github.com/pocketbase/pocketbase v0.39.9
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/disintegration/imaging v1.6.2 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.5.2 // indirect
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 // indirect
|
||||
github.com/dop251/goja v0.0.0-20260722130236-0768e0998ac0 // indirect
|
||||
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fatih/color v1.19.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/ganigeorgiev/fexpr v0.6.0 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.23 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/cobra v1.10.2 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
golang.org/x/crypto v0.54.0 // indirect
|
||||
golang.org/x/image v0.44.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
modernc.org/libc v1.74.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.54.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,143 @@
|
||||
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
|
||||
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/dlclark/regexp2/v2 v2.5.2 h1:HAsucWRhsqcDzl6Ua9aR8JwYOTzrZyPrF0/FNxJVAI0=
|
||||
github.com/dlclark/regexp2/v2 v2.5.2/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217 h1:16iT9CBDOniJwFGPI41MbUDfEk74hFaKTqudrX8kenY=
|
||||
github.com/dop251/base64dec v0.0.0-20231022112746-c6c9f9a96217/go.mod h1:eIb+f24U+eWQCIsj9D/ah+MD9UP+wdxuqzsdLD+mhGM=
|
||||
github.com/dop251/goja v0.0.0-20260722130236-0768e0998ac0 h1:1JJPIzrFPTNEHCFkIDhKV2CHBklTA/7VHJp9sVB8Em0=
|
||||
github.com/dop251/goja v0.0.0-20260722130236-0768e0998ac0/go.mod h1:LiIEzozrcvNXorsG/3+ypGqdTUAqZryhzSsqi0oU/Qg=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14 h1:3U8dTgyNBhEQ/GVw0jZW5q+93Zw2gAZPRWhJ9TwV3rM=
|
||||
github.com/dop251/goja_nodejs v0.0.0-20260212111938-1f56ff5bcf14/go.mod h1:Tb7Xxye4LX7cT3i8YLvmPMGCV92IOi4CDZvm/V8ylc0=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
|
||||
github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
|
||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0 h1:du0WGc8xSKq/++e0cglxhS/mXVqsR7+c7jLEi5Vqduw=
|
||||
github.com/google/pprof v0.0.0-20260709232956-b9395ee17fa0/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
||||
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs=
|
||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
|
||||
github.com/pocketbase/pocketbase v0.39.9 h1:zmrvbWJwBlb+iXst02uD6zNqNpiyG86KtlOJjd+Bhx4=
|
||||
github.com/pocketbase/pocketbase v0.39.9/go.mod h1:6l4ZFFa8kNkxLSumxzVm+DtczfUz86Wqow0bQYiXEkQ=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
|
||||
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.54.0 h1:JCxR4qwkJvOaqAoYcgDoO25Nc+ROg6EJ2LfBVzdrgog=
|
||||
modernc.org/sqlite v1.54.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,682 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
const (
|
||||
homarrProviderModelID = "homarr/model"
|
||||
homarrProviderModelName = "Homarr model"
|
||||
defaultOpenRouterModelID = "~deepseek/deepseek-v4-flash-latest"
|
||||
maxChatInputTokens = 256 * 1024
|
||||
maxChatOutputTokens = 32 * 1024
|
||||
defaultDailyLimit = 50
|
||||
defaultGlobalDailyLimit = 10_000
|
||||
// The Homarr composer permits five 1 MB images, which expand to about 6.7 MB as
|
||||
// base64. Keep the authenticated transport bounded without confusing bytes with
|
||||
// model tokens; validate textual and image content separately below.
|
||||
maxChatRequestBytes = 12_000_000
|
||||
// One UTF-8 byte cannot expand into more than one tokenizer fallback token.
|
||||
// This conservative bound keeps every accepted text payload within 256K tokens
|
||||
// without coupling the public alias to the private upstream tokenizer.
|
||||
maxChatTextBytes = maxChatInputTokens
|
||||
maxChatImageDataBytes = 1_400_000
|
||||
maxChatImages = 5
|
||||
maxChatMessages = 512
|
||||
maxChatResponseBytes = 8 << 20
|
||||
maxWebSearchResults = 5
|
||||
maxWebSearchUses = 3
|
||||
maxUserInFlight = 2
|
||||
maxGlobalInFlight = 100
|
||||
openRouterDefaultURL = "https://openrouter.ai/api/v1"
|
||||
openRouterReferer = "https://homarr.dev"
|
||||
openRouterTitle = "Homarr AI Assistant"
|
||||
providerRequestTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
var (
|
||||
errProviderResponseTooLarge = errors.New("provider response is too large")
|
||||
errInvalidProviderResponse = errors.New("invalid provider response")
|
||||
errInvalidTools = errors.New("invalid request tools")
|
||||
errUnsupportedTool = errors.New("unsupported request tool")
|
||||
errTooManyImages = errors.New("too many images or image too large")
|
||||
errRemoteImage = errors.New("remote image not allowed")
|
||||
errInputTooLarge = errors.New("request exceeds model input limit")
|
||||
)
|
||||
|
||||
var clientControlledRoutingFields = []string{
|
||||
"models", "provider", "route", "plugins", "transforms", "extra_body", "extra_headers",
|
||||
}
|
||||
|
||||
var clientControlledCostFields = []string{
|
||||
"audio", "modalities", "logprobs", "top_logprobs", "prediction", "service_tier",
|
||||
}
|
||||
|
||||
type homarrProvider struct {
|
||||
apiKey string
|
||||
baseURL string
|
||||
modelID string
|
||||
dailyLimit int
|
||||
globalDailyLimit int
|
||||
httpClient *http.Client
|
||||
now func() time.Time
|
||||
inFlightMutex sync.Mutex
|
||||
inFlightByUser map[string]int
|
||||
globalInFlight int
|
||||
}
|
||||
|
||||
type quotaSnapshot struct {
|
||||
Limit int `json:"limit"`
|
||||
Used int `json:"used"`
|
||||
Remaining int `json:"remaining"`
|
||||
ResetsAt time.Time `json:"resetsAt"`
|
||||
}
|
||||
|
||||
type providerRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []providerMessage `json:"messages"`
|
||||
Stream bool `json:"stream"`
|
||||
}
|
||||
|
||||
type providerMessage struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type quotaExceededError struct {
|
||||
Snapshot quotaSnapshot
|
||||
}
|
||||
|
||||
func (err *quotaExceededError) Error() string { return "daily Homarr provider allowance exhausted" }
|
||||
|
||||
func registerHomarrProvider(app *pocketbase.PocketBase) {
|
||||
provider, err := newHomarrProviderFromEnv()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
app.OnServe().BindFunc(func(event *core.ServeEvent) error {
|
||||
group := event.Router.Group("/api/ai")
|
||||
group.GET("/v1/models", provider.models)
|
||||
group.GET("/usage", provider.usage).
|
||||
Bind(apis.RequireAuth("users")).
|
||||
Unbind(apis.DefaultActivityLoggerMiddlewareId)
|
||||
group.POST("/v1/chat/completions", provider.chat).
|
||||
Bind(apis.RequireAuth("users"), apis.BodyLimit(maxChatRequestBytes)).
|
||||
Unbind(apis.DefaultActivityLoggerMiddlewareId)
|
||||
return event.Next()
|
||||
})
|
||||
}
|
||||
|
||||
func newHomarrProviderFromEnv() (*homarrProvider, error) {
|
||||
baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("HOMARR_AI_OPENROUTER_BASE_URL")), "/")
|
||||
if baseURL == "" {
|
||||
baseURL = openRouterDefaultURL
|
||||
}
|
||||
parsedURL, err := url.Parse(baseURL)
|
||||
if err != nil || parsedURL.Host == "" || (parsedURL.Scheme != "https" && parsedURL.Scheme != "http") ||
|
||||
parsedURL.User != nil || parsedURL.RawQuery != "" || parsedURL.Fragment != "" {
|
||||
return nil, errors.New("HOMARR_AI_OPENROUTER_BASE_URL must be an absolute HTTP(S) URL without credentials, a query, or a fragment")
|
||||
}
|
||||
if parsedURL.Scheme != "https" && strings.TrimSpace(os.Getenv("HOMARR_AI_ALLOW_INSECURE_UPSTREAM")) != "true" {
|
||||
return nil, errors.New("HOMARR_AI_OPENROUTER_BASE_URL must use HTTPS unless HOMARR_AI_ALLOW_INSECURE_UPSTREAM=true")
|
||||
}
|
||||
|
||||
modelID := strings.TrimSpace(os.Getenv("HOMARR_AI_OPENROUTER_MODEL"))
|
||||
if modelID == "" {
|
||||
modelID = defaultOpenRouterModelID
|
||||
}
|
||||
if len(modelID) > 256 || strings.IndexFunc(modelID, func(value rune) bool {
|
||||
return unicode.IsSpace(value) || unicode.IsControl(value)
|
||||
}) >= 0 {
|
||||
return nil, errors.New("HOMARR_AI_OPENROUTER_MODEL must be a non-empty model identifier of at most 256 characters")
|
||||
}
|
||||
|
||||
dailyLimit := defaultDailyLimit
|
||||
if rawLimit := strings.TrimSpace(os.Getenv("HOMARR_AI_DAILY_REQUEST_LIMIT")); rawLimit != "" {
|
||||
value, parseErr := strconv.Atoi(rawLimit)
|
||||
if parseErr != nil || value < 1 || value > 100_000 {
|
||||
return nil, errors.New("HOMARR_AI_DAILY_REQUEST_LIMIT must be an integer between 1 and 100000")
|
||||
}
|
||||
dailyLimit = value
|
||||
}
|
||||
globalDailyLimit := defaultGlobalDailyLimit
|
||||
if rawLimit := strings.TrimSpace(os.Getenv("HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT")); rawLimit != "" {
|
||||
value, parseErr := strconv.Atoi(rawLimit)
|
||||
if parseErr != nil || value < 1 || value > 10_000_000 {
|
||||
return nil, errors.New("HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT must be an integer between 1 and 10000000")
|
||||
}
|
||||
globalDailyLimit = value
|
||||
}
|
||||
|
||||
return &homarrProvider{
|
||||
apiKey: strings.TrimSpace(os.Getenv("OPENROUTER_API_KEY")),
|
||||
baseURL: baseURL,
|
||||
modelID: modelID,
|
||||
dailyLimit: dailyLimit,
|
||||
globalDailyLimit: globalDailyLimit,
|
||||
httpClient: &http.Client{
|
||||
Timeout: providerRequestTimeout,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
},
|
||||
now: time.Now,
|
||||
inFlightByUser: make(map[string]int),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (provider *homarrProvider) models(event *core.RequestEvent) error {
|
||||
if provider.apiKey == "" {
|
||||
return event.JSON(http.StatusServiceUnavailable, openAIError("The Homarr provider is not configured."))
|
||||
}
|
||||
return event.JSON(http.StatusOK, map[string]any{
|
||||
"object": "list",
|
||||
"data": []map[string]any{{
|
||||
"id": homarrProviderModelID,
|
||||
"object": "model",
|
||||
"name": homarrProviderModelName,
|
||||
"description": "The tool-capable model selected by the Homarr team.",
|
||||
"owned_by": "homarr",
|
||||
"context_length": maxChatInputTokens,
|
||||
"supported_parameters": []string{
|
||||
"tools", "tool_choice", "reasoning", "include_reasoning", "structured_outputs",
|
||||
},
|
||||
"architecture": map[string]any{
|
||||
"input_modalities": []string{"text", "image"},
|
||||
"output_modalities": []string{"text"},
|
||||
},
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func (provider *homarrProvider) usage(event *core.RequestEvent) error {
|
||||
snapshot, err := provider.quota(event.App, event.Auth)
|
||||
if err != nil {
|
||||
return event.InternalServerError("The Homarr provider allowance could not be loaded.", nil)
|
||||
}
|
||||
return event.JSON(http.StatusOK, snapshot)
|
||||
}
|
||||
|
||||
func (provider *homarrProvider) chat(event *core.RequestEvent) error {
|
||||
if provider.apiKey == "" {
|
||||
return event.JSON(http.StatusServiceUnavailable, openAIError("The Homarr provider is not configured."))
|
||||
}
|
||||
if !provider.acquireRequest(event.Auth.Id) {
|
||||
return event.JSON(http.StatusTooManyRequests, openAIError("Too many Homarr provider requests are already running."))
|
||||
}
|
||||
defer provider.releaseRequest(event.Auth.Id)
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(event.Request.Body, maxChatRequestBytes+1))
|
||||
if err != nil || len(body) > maxChatRequestBytes {
|
||||
return event.JSON(http.StatusRequestEntityTooLarge, openAIError("The request is too large."))
|
||||
}
|
||||
|
||||
var requestBody providerRequest
|
||||
if err := json.Unmarshal(body, &requestBody); err != nil || len(requestBody.Messages) == 0 {
|
||||
return event.JSON(http.StatusBadRequest, openAIError("The request body is not valid OpenAI chat JSON."))
|
||||
}
|
||||
if len(requestBody.Messages) > maxChatMessages {
|
||||
return event.JSON(http.StatusBadRequest, openAIError("The request contains too many messages."))
|
||||
}
|
||||
if requestBody.Model != homarrProviderModelID {
|
||||
return event.JSON(http.StatusBadRequest, openAIError("The Homarr provider offers only the Homarr model."))
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return event.JSON(http.StatusBadRequest, openAIError("The request body is not valid OpenAI chat JSON."))
|
||||
}
|
||||
if err := validateProviderInput(payload); err != nil {
|
||||
message := "The request exceeds the Homarr model input limit."
|
||||
if errors.Is(err, errTooManyImages) {
|
||||
message = "The request contains too many images or an image that is too large."
|
||||
} else if errors.Is(err, errRemoteImage) {
|
||||
message = "Remote image URLs are not supported. Upload the image to Homarr instead."
|
||||
}
|
||||
return event.JSON(http.StatusRequestEntityTooLarge, openAIError(message))
|
||||
}
|
||||
if err := sanitizeProviderPayload(payload, provider.modelID); err != nil {
|
||||
message := "The request contains an unsupported tool."
|
||||
if errors.Is(err, errInvalidTools) {
|
||||
message = "The request tools are not valid."
|
||||
}
|
||||
return event.JSON(http.StatusBadRequest, openAIError(message))
|
||||
}
|
||||
upstreamBody, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return event.JSON(http.StatusBadRequest, openAIError("The request body could not be prepared."))
|
||||
}
|
||||
|
||||
upstreamRequest, err := http.NewRequestWithContext(
|
||||
event.Request.Context(),
|
||||
http.MethodPost,
|
||||
provider.baseURL+"/chat/completions",
|
||||
bytes.NewReader(upstreamBody),
|
||||
)
|
||||
if err != nil {
|
||||
return event.JSON(http.StatusInternalServerError, openAIError("The Homarr provider request could not be prepared."))
|
||||
}
|
||||
snapshot, err := provider.startRequest(event.App, event.Auth)
|
||||
if err != nil {
|
||||
var quotaErr *quotaExceededError
|
||||
if errors.As(err, "aErr) {
|
||||
event.Response.Header().Set("X-Homarr-Quota-Limit", strconv.Itoa(quotaErr.Snapshot.Limit))
|
||||
event.Response.Header().Set("X-Homarr-Quota-Remaining", "0")
|
||||
event.Response.Header().Set("X-Homarr-Quota-Reset", quotaErr.Snapshot.ResetsAt.Format(time.RFC3339))
|
||||
return event.JSON(http.StatusTooManyRequests, openAIError("Your daily Homarr provider allowance is exhausted."))
|
||||
}
|
||||
return event.JSON(http.StatusInternalServerError, openAIError("The Homarr provider request could not be started."))
|
||||
}
|
||||
upstreamRequest.Header.Set("Authorization", "Bearer "+provider.apiKey)
|
||||
upstreamRequest.Header.Set("Content-Type", "application/json")
|
||||
upstreamRequest.Header.Set("Accept", "text/event-stream, application/json")
|
||||
upstreamRequest.Header.Set("HTTP-Referer", openRouterReferer)
|
||||
upstreamRequest.Header.Set("X-OpenRouter-Title", openRouterTitle)
|
||||
upstreamRequest.Header.Set("X-OpenRouter-Metadata", "enabled")
|
||||
|
||||
upstreamResponse, err := provider.httpClient.Do(upstreamRequest)
|
||||
if err != nil {
|
||||
return event.JSON(http.StatusBadGateway, openAIError("The model endpoint could not be reached."))
|
||||
}
|
||||
defer func() { _ = upstreamResponse.Body.Close() }()
|
||||
|
||||
event.Response.Header().Set("Cache-Control", "no-store")
|
||||
event.Response.Header().Set("X-Accel-Buffering", "no")
|
||||
event.Response.Header().Set("X-Homarr-Quota-Limit", strconv.Itoa(snapshot.Limit))
|
||||
event.Response.Header().Set("X-Homarr-Quota-Remaining", strconv.Itoa(snapshot.Remaining))
|
||||
event.Response.Header().Set("X-Homarr-Quota-Reset", snapshot.ResetsAt.Format(time.RFC3339))
|
||||
if requestID := upstreamResponse.Header.Get("X-Request-Id"); requestID != "" {
|
||||
event.Response.Header().Set("X-Request-Id", requestID)
|
||||
}
|
||||
|
||||
contentType := upstreamResponse.Header.Get("Content-Type")
|
||||
if contentType == "" {
|
||||
contentType = "application/json"
|
||||
}
|
||||
if upstreamResponse.StatusCode < 200 || upstreamResponse.StatusCode >= 300 {
|
||||
return event.JSON(safeUpstreamStatus(upstreamResponse.StatusCode), openAIError("The model endpoint rejected the request."))
|
||||
}
|
||||
|
||||
if requestBody.Stream {
|
||||
event.Response.Header().Set("Content-Type", contentType)
|
||||
event.Response.WriteHeader(upstreamResponse.StatusCode)
|
||||
if _, streamErr := copyAliasedSSE(flushingWriter{event.Response}, upstreamResponse.Body, maxChatResponseBytes); streamErr != nil {
|
||||
_, _ = io.WriteString(flushingWriter{event.Response}, streamErrorFrame())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
responseBody, readErr := readBoundedBody(upstreamResponse.Body, maxChatResponseBytes)
|
||||
if readErr != nil {
|
||||
return event.JSON(http.StatusBadGateway, openAIError("The model endpoint returned an invalid response."))
|
||||
}
|
||||
responseBody, readErr = aliasCompletionBody(responseBody)
|
||||
if readErr != nil {
|
||||
return event.JSON(http.StatusBadGateway, openAIError("The model endpoint returned an invalid response."))
|
||||
}
|
||||
return event.Blob(upstreamResponse.StatusCode, contentType, responseBody)
|
||||
}
|
||||
|
||||
func streamErrorFrame() string {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"error": map[string]any{
|
||||
"message": "The model endpoint returned an invalid response.",
|
||||
"type": "homarr_provider_error",
|
||||
},
|
||||
})
|
||||
return "data: " + string(payload) + "\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
|
||||
func (provider *homarrProvider) acquireRequest(userID string) bool {
|
||||
provider.inFlightMutex.Lock()
|
||||
defer provider.inFlightMutex.Unlock()
|
||||
if provider.globalInFlight >= maxGlobalInFlight || provider.inFlightByUser[userID] >= maxUserInFlight {
|
||||
return false
|
||||
}
|
||||
provider.globalInFlight++
|
||||
provider.inFlightByUser[userID]++
|
||||
return true
|
||||
}
|
||||
|
||||
func (provider *homarrProvider) releaseRequest(userID string) {
|
||||
provider.inFlightMutex.Lock()
|
||||
defer provider.inFlightMutex.Unlock()
|
||||
provider.globalInFlight--
|
||||
provider.inFlightByUser[userID]--
|
||||
if provider.inFlightByUser[userID] == 0 {
|
||||
delete(provider.inFlightByUser, userID)
|
||||
}
|
||||
}
|
||||
|
||||
func safeUpstreamStatus(status int) int {
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
func sanitizeProviderPayload(payload map[string]any, upstreamModelID string) error {
|
||||
for _, field := range clientControlledRoutingFields {
|
||||
delete(payload, field)
|
||||
}
|
||||
for _, field := range clientControlledCostFields {
|
||||
delete(payload, field)
|
||||
}
|
||||
delete(payload, "max_completion_tokens")
|
||||
delete(payload, "max_tokens")
|
||||
delete(payload, "metadata")
|
||||
delete(payload, "user")
|
||||
if reasoning, ok := payload["reasoning"].(map[string]any); ok {
|
||||
delete(reasoning, "max_tokens")
|
||||
}
|
||||
|
||||
if rawTools, exists := payload["tools"]; exists {
|
||||
tools, ok := rawTools.([]any)
|
||||
if !ok {
|
||||
return errInvalidTools
|
||||
}
|
||||
sanitizedTools := make([]any, 0, len(tools))
|
||||
webSearchAdded := false
|
||||
for _, rawTool := range tools {
|
||||
tool, ok := rawTool.(map[string]any)
|
||||
if !ok {
|
||||
return errUnsupportedTool
|
||||
}
|
||||
switch tool["type"] {
|
||||
case "function":
|
||||
sanitizedTools = append(sanitizedTools, tool)
|
||||
case "openrouter:web_search":
|
||||
if !webSearchAdded {
|
||||
sanitizedTools = append(sanitizedTools, map[string]any{
|
||||
"type": "openrouter:web_search",
|
||||
"parameters": map[string]any{
|
||||
"max_results": maxWebSearchResults,
|
||||
"max_uses": maxWebSearchUses,
|
||||
},
|
||||
})
|
||||
webSearchAdded = true
|
||||
}
|
||||
default:
|
||||
return errUnsupportedTool
|
||||
}
|
||||
}
|
||||
payload["tools"] = sanitizedTools
|
||||
}
|
||||
|
||||
payload["model"] = upstreamModelID
|
||||
payload["max_completion_tokens"] = maxChatOutputTokens
|
||||
payload["n"] = 1
|
||||
payload["parallel_tool_calls"] = false
|
||||
payload["usage"] = map[string]any{"include": true}
|
||||
payload["provider"] = map[string]any{"zdr": true, "data_collection": "deny"}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateProviderInput(payload map[string]any) error {
|
||||
textBytes := 0
|
||||
imageCount := 0
|
||||
var walk func(any) error
|
||||
walk = func(value any) error {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if strings.HasPrefix(typed, "data:image/") {
|
||||
imageCount++
|
||||
if imageCount > maxChatImages || len(typed) > maxChatImageDataBytes {
|
||||
return errTooManyImages
|
||||
}
|
||||
return nil
|
||||
}
|
||||
textBytes += len(typed)
|
||||
if textBytes > maxChatTextBytes {
|
||||
return errInputTooLarge
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if err := walk(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case map[string]any:
|
||||
if typed["type"] == "image_url" {
|
||||
image, ok := typed["image_url"].(map[string]any)
|
||||
imageURL, urlOK := image["url"].(string)
|
||||
if !ok || !urlOK || !strings.HasPrefix(imageURL, "data:image/") {
|
||||
return errRemoteImage
|
||||
}
|
||||
imageCount++
|
||||
if imageCount > maxChatImages || len(imageURL) > maxChatImageDataBytes {
|
||||
return errTooManyImages
|
||||
}
|
||||
}
|
||||
for key, item := range typed {
|
||||
if typed["type"] == "image_url" && key == "image_url" {
|
||||
continue
|
||||
}
|
||||
textBytes += len(key)
|
||||
if textBytes > maxChatTextBytes {
|
||||
return errInputTooLarge
|
||||
}
|
||||
if err := walk(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return walk(payload)
|
||||
}
|
||||
|
||||
func aliasCompletionBody(body []byte) ([]byte, error) {
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, errInvalidProviderResponse
|
||||
}
|
||||
payload["model"] = homarrProviderModelID
|
||||
aliased, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, errInvalidProviderResponse
|
||||
}
|
||||
return aliased, nil
|
||||
}
|
||||
|
||||
func copyAliasedSSE(writer io.Writer, reader io.Reader, limit int64) (int64, error) {
|
||||
buffered := bufio.NewReader(io.LimitReader(reader, limit+1))
|
||||
var written int64
|
||||
var read int64
|
||||
for {
|
||||
line, err := buffered.ReadString('\n')
|
||||
read += int64(len(line))
|
||||
if read > limit {
|
||||
return written, errProviderResponseTooLarge
|
||||
}
|
||||
if strings.HasPrefix(line, "data: ") && strings.TrimSpace(strings.TrimPrefix(line, "data: ")) != "[DONE]" {
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(line, "data: "))
|
||||
aliased, aliasErr := aliasCompletionBody([]byte(payload))
|
||||
if aliasErr != nil {
|
||||
return written, aliasErr
|
||||
}
|
||||
line = "data: " + string(aliased) + "\n"
|
||||
}
|
||||
if written+int64(len(line)) > limit {
|
||||
return written, errProviderResponseTooLarge
|
||||
}
|
||||
count, writeErr := io.WriteString(writer, line)
|
||||
written += int64(count)
|
||||
if writeErr != nil {
|
||||
return written, writeErr
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
return written, nil
|
||||
}
|
||||
if err != nil {
|
||||
return written, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copyBounded(writer io.Writer, reader io.Reader, limit int64) (int64, error) {
|
||||
written, err := io.Copy(writer, io.LimitReader(reader, limit))
|
||||
if err != nil {
|
||||
return written, err
|
||||
}
|
||||
var extra [1]byte
|
||||
read, err := reader.Read(extra[:])
|
||||
if read > 0 {
|
||||
return written, errProviderResponseTooLarge
|
||||
}
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
return written, err
|
||||
}
|
||||
return written, nil
|
||||
}
|
||||
|
||||
func readBoundedBody(reader io.Reader, limit int64) ([]byte, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(reader, limit+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(body)) > limit {
|
||||
return nil, errProviderResponseTooLarge
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (provider *homarrProvider) quota(app core.App, user *core.Record) (quotaSnapshot, error) {
|
||||
now := provider.now().UTC()
|
||||
var snapshot quotaSnapshot
|
||||
err := app.RunInTransaction(func(txApp core.App) error {
|
||||
quota, current, quotaChanged, err := provider.loadQuota(txApp, user, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
globalQuota, globalRemaining, globalQuotaChanged, err := provider.loadGlobalQuota(txApp, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current.Remaining = min(current.Remaining, globalRemaining)
|
||||
snapshot = current
|
||||
if globalQuotaChanged {
|
||||
if err := txApp.Save(globalQuota); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if quotaChanged {
|
||||
return txApp.Save(quota)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return snapshot, err
|
||||
}
|
||||
|
||||
func (provider *homarrProvider) loadGlobalQuota(app core.App, now time.Time) (*core.Record, int, bool, error) {
|
||||
quota, err := app.FindFirstRecordByFilter("assistant_global_quota", "key = 'default'")
|
||||
if err != nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
changed := false
|
||||
if quota.GetString("day") != now.Format(time.DateOnly) {
|
||||
quota.Set("day", now.Format(time.DateOnly))
|
||||
quota.Set("used", 0)
|
||||
changed = true
|
||||
}
|
||||
return quota, max(0, provider.globalDailyLimit-quota.GetInt("used")), changed, nil
|
||||
}
|
||||
|
||||
func (provider *homarrProvider) loadQuota(
|
||||
app core.App,
|
||||
user *core.Record,
|
||||
now time.Time,
|
||||
) (*core.Record, quotaSnapshot, bool, error) {
|
||||
quota, err := app.FindFirstRecordByFilter("assistant_quotas", "user = {:user}", dbx.Params{"user": user.Id})
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, quotaSnapshot{}, false, err
|
||||
}
|
||||
changed := false
|
||||
if quota == nil {
|
||||
collection, err := app.FindCollectionByNameOrId("assistant_quotas")
|
||||
if err != nil {
|
||||
return nil, quotaSnapshot{}, false, err
|
||||
}
|
||||
quota = core.NewRecord(collection)
|
||||
quota.Set("user", user.Id)
|
||||
changed = true
|
||||
}
|
||||
if quota.GetString("day") != now.Format(time.DateOnly) {
|
||||
quota.Set("day", now.Format(time.DateOnly))
|
||||
quota.Set("used", 0)
|
||||
changed = true
|
||||
}
|
||||
limit := provider.dailyLimit
|
||||
used := quota.GetInt("used")
|
||||
return quota, quotaSnapshot{
|
||||
Limit: limit,
|
||||
Used: used,
|
||||
Remaining: max(0, limit-used),
|
||||
ResetsAt: time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC),
|
||||
}, changed, nil
|
||||
}
|
||||
|
||||
func (provider *homarrProvider) startRequest(
|
||||
app core.App,
|
||||
user *core.Record,
|
||||
) (quotaSnapshot, error) {
|
||||
var snapshot quotaSnapshot
|
||||
err := app.RunInTransaction(func(txApp core.App) error {
|
||||
now := provider.now().UTC()
|
||||
quota, current, _, err := provider.loadQuota(txApp, user, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
globalQuota, globalRemaining, _, err := provider.loadGlobalQuota(txApp, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if current.Remaining < 1 || globalRemaining < 1 {
|
||||
return "aExceededError{Snapshot: current}
|
||||
}
|
||||
current.Used++
|
||||
current.Remaining = max(0, current.Limit-current.Used)
|
||||
quota.Set("used", current.Used)
|
||||
globalQuota.Set("used", globalQuota.GetInt("used")+1)
|
||||
snapshot = current
|
||||
if err := txApp.Save(globalQuota); err != nil {
|
||||
return err
|
||||
}
|
||||
return txApp.Save(quota)
|
||||
})
|
||||
return snapshot, err
|
||||
}
|
||||
|
||||
func openAIError(message string) map[string]any {
|
||||
return map[string]any{"error": map[string]any{"message": message, "type": "homarr_provider_error"}}
|
||||
}
|
||||
|
||||
type flushingWriter struct {
|
||||
http.ResponseWriter
|
||||
}
|
||||
|
||||
func (writer flushingWriter) Write(chunk []byte) (int, error) {
|
||||
written, err := writer.ResponseWriter.Write(chunk)
|
||||
if flusher, ok := writer.ResponseWriter.(http.Flusher); ok {
|
||||
flusher.Flush()
|
||||
}
|
||||
return written, err
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeProviderPayload(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"model": homarrProviderModelID,
|
||||
"models": []string{"attacker/model"},
|
||||
"provider": map[string]any{"order": []string{"attacker"}},
|
||||
"route": "fallback",
|
||||
"plugins": []string{"web"},
|
||||
"transforms": []string{"middle-out"},
|
||||
"max_tokens": 1_000_000,
|
||||
"max_completion_tokens": 1_000_000,
|
||||
"n": 100,
|
||||
"parallel_tool_calls": true,
|
||||
"extra_body": map[string]any{"provider": map[string]any{"order": []string{"attacker"}}},
|
||||
"extra_headers": map[string]any{"Authorization": "Bearer attacker"},
|
||||
"metadata": map[string]any{"user": "private-user-id"},
|
||||
"audio": map[string]any{"format": "wav"},
|
||||
"modalities": []string{"text", "audio"},
|
||||
"logprobs": true,
|
||||
"top_logprobs": 20,
|
||||
"prediction": map[string]any{"content": strings.Repeat("x", 100)},
|
||||
"service_tier": "priority",
|
||||
"reasoning": map[string]any{"effort": "high", "max_tokens": 1_000_000},
|
||||
"user": "private-user-id",
|
||||
"tools": []any{
|
||||
map[string]any{"type": "function", "function": map[string]any{"name": "board_list"}},
|
||||
map[string]any{
|
||||
"type": "openrouter:web_search",
|
||||
"parameters": map[string]any{"max_results": 1000, "max_uses": 1000},
|
||||
},
|
||||
map[string]any{"type": "openrouter:web_search"},
|
||||
},
|
||||
"messages": []any{map[string]any{"role": "user", "content": "hello"}},
|
||||
}
|
||||
if err := sanitizeProviderPayload(payload, "mock/team-selected-model"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, field := range clientControlledRoutingFields {
|
||||
if field == "provider" {
|
||||
continue
|
||||
}
|
||||
if _, exists := payload[field]; exists {
|
||||
t.Fatalf("client-controlled routing field %q was forwarded", field)
|
||||
}
|
||||
}
|
||||
for _, field := range clientControlledCostFields {
|
||||
if _, exists := payload[field]; exists {
|
||||
t.Fatalf("client-controlled cost field %q was forwarded", field)
|
||||
}
|
||||
}
|
||||
if payload["model"] != "mock/team-selected-model" {
|
||||
t.Fatalf("unexpected upstream model: %v", payload["model"])
|
||||
}
|
||||
if payload["max_completion_tokens"] != maxChatOutputTokens || payload["n"] != 1 || payload["parallel_tool_calls"] != false {
|
||||
t.Fatalf("unsafe generation controls were forwarded: %#v", payload)
|
||||
}
|
||||
if _, exists := payload["max_tokens"]; exists {
|
||||
t.Fatal("deprecated client max_tokens was forwarded")
|
||||
}
|
||||
if _, exists := payload["user"]; exists {
|
||||
t.Fatal("client user identifier was forwarded")
|
||||
}
|
||||
if _, exists := payload["metadata"]; exists {
|
||||
t.Fatal("client metadata was forwarded")
|
||||
}
|
||||
reasoning := payload["reasoning"].(map[string]any)
|
||||
if _, exists := reasoning["max_tokens"]; exists || reasoning["effort"] != "high" {
|
||||
t.Fatalf("reasoning budget was not bounded safely: %#v", reasoning)
|
||||
}
|
||||
tools := payload["tools"].([]any)
|
||||
if len(tools) != 2 {
|
||||
t.Fatalf("unexpected sanitized tool count: %d", len(tools))
|
||||
}
|
||||
webSearch := tools[1].(map[string]any)
|
||||
parameters := webSearch["parameters"].(map[string]any)
|
||||
if parameters["max_results"] != maxWebSearchResults || parameters["max_uses"] != maxWebSearchUses {
|
||||
t.Fatalf("web search limits were not enforced: %#v", parameters)
|
||||
}
|
||||
usage, err := json.Marshal(payload["usage"])
|
||||
if err != nil || string(usage) != `{"include":true}` {
|
||||
t.Fatalf("unexpected usage options: %s, %v", usage, err)
|
||||
}
|
||||
privacy := payload["provider"].(map[string]any)
|
||||
if privacy["zdr"] != true || privacy["data_collection"] != "deny" {
|
||||
t.Fatalf("upstream privacy controls were not enforced: %#v", privacy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeProviderPayloadRejectsUnsupportedServerTools(t *testing.T) {
|
||||
payload := map[string]any{"tools": []any{map[string]any{"type": "openrouter:computer"}}}
|
||||
if err := sanitizeProviderPayload(payload, "mock/team-selected-model"); err == nil {
|
||||
t.Fatal("expected unsupported server tool to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUpstreamStatus(t *testing.T) {
|
||||
for _, status := range []int{401, 403} {
|
||||
if actual := safeUpstreamStatus(status); actual != 502 {
|
||||
t.Fatalf("expected upstream %d to become 502, got %d", status, actual)
|
||||
}
|
||||
}
|
||||
if actual := safeUpstreamStatus(429); actual != 429 {
|
||||
t.Fatalf("expected upstream 429 to be preserved, got %d", actual)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProviderInput(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"messages": []any{map[string]any{
|
||||
"role": "user",
|
||||
"content": []any{map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{"url": "data:image/png;base64," + strings.Repeat("a", 1_300_000)},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
if err := validateProviderInput(payload); err != nil {
|
||||
t.Fatalf("expected a supported Homarr image request, got %v", err)
|
||||
}
|
||||
payload["messages"] = []any{map[string]any{"role": "user", "content": strings.Repeat("x", maxChatTextBytes+1)}}
|
||||
if err := validateProviderInput(payload); !errors.Is(err, errInputTooLarge) {
|
||||
t.Fatalf("expected oversized text input to be rejected, got %v", err)
|
||||
}
|
||||
payload["messages"] = []any{map[string]any{
|
||||
"role": "user",
|
||||
"content": []any{map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{"url": "data:image/png;base64," + strings.Repeat("a", maxChatImageDataBytes)},
|
||||
}},
|
||||
}}
|
||||
if err := validateProviderInput(payload); !errors.Is(err, errTooManyImages) {
|
||||
t.Fatalf("expected oversized image input to be rejected, got %v", err)
|
||||
}
|
||||
payload["messages"] = []any{map[string]any{
|
||||
"role": "user",
|
||||
"content": []any{map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{"url": "https://attacker.example/expensive-image.png"},
|
||||
}},
|
||||
}}
|
||||
if err := validateProviderInput(payload); !errors.Is(err, errRemoteImage) {
|
||||
t.Fatalf("expected a remote image URL to be rejected, got %v", err)
|
||||
}
|
||||
payload["messages"] = []any{map[string]any{
|
||||
"role": "user",
|
||||
"content": []any{
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,YQ=="}},
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,YQ=="}},
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,YQ=="}},
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,YQ=="}},
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,YQ=="}},
|
||||
},
|
||||
}}
|
||||
if err := validateProviderInput(payload); err != nil {
|
||||
t.Fatalf("expected five uploaded images to be accepted, got %v", err)
|
||||
}
|
||||
payload["messages"].([]any)[0].(map[string]any)["content"] = append(
|
||||
payload["messages"].([]any)[0].(map[string]any)["content"].([]any),
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,YQ=="}},
|
||||
)
|
||||
if err := validateProviderInput(payload); !errors.Is(err, errTooManyImages) {
|
||||
t.Fatalf("expected six uploaded images to be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderInFlightLimits(t *testing.T) {
|
||||
provider := &homarrProvider{inFlightByUser: make(map[string]int)}
|
||||
if !provider.acquireRequest("one") {
|
||||
t.Fatal("expected the first user request to be admitted")
|
||||
}
|
||||
if !provider.acquireRequest("one") {
|
||||
t.Fatal("expected the second user request to be admitted")
|
||||
}
|
||||
if provider.acquireRequest("one") {
|
||||
t.Fatal("expected the third user request to be rejected")
|
||||
}
|
||||
provider.releaseRequest("one")
|
||||
provider.releaseRequest("one")
|
||||
for index := range maxGlobalInFlight {
|
||||
if !provider.acquireRequest(string(rune(index + 1))) {
|
||||
t.Fatalf("expected global request %d to be admitted", index)
|
||||
}
|
||||
}
|
||||
if provider.acquireRequest("overflow") {
|
||||
t.Fatal("expected the global in-flight limit to reject overflow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompletionModelAlias(t *testing.T) {
|
||||
body, err := aliasCompletionBody([]byte(`{"id":"one","model":"private/model","choices":[]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(body), "private/model") || !strings.Contains(string(body), `"model":"homarr/model"`) {
|
||||
t.Fatalf("private upstream model leaked in JSON: %s", body)
|
||||
}
|
||||
var stream strings.Builder
|
||||
_, err = copyAliasedSSE(
|
||||
&stream,
|
||||
strings.NewReader("data: {\"id\":\"one\",\"model\":\"private/model\",\"choices\":[]}\n\ndata: [DONE]\n\n"),
|
||||
1024,
|
||||
)
|
||||
if err != nil || strings.Contains(stream.String(), "private/model") ||
|
||||
!strings.Contains(stream.String(), `"model":"homarr/model"`) {
|
||||
t.Fatalf("private upstream model leaked in SSE: %s, %v", stream.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderEnvironment(t *testing.T) {
|
||||
t.Setenv("OPENROUTER_API_KEY", "test-key")
|
||||
t.Setenv("HOMARR_AI_OPENROUTER_BASE_URL", "https://router.example/v1/")
|
||||
t.Setenv("HOMARR_AI_OPENROUTER_MODEL", "mock/team-selected-model")
|
||||
t.Setenv("HOMARR_AI_DAILY_REQUEST_LIMIT", "75")
|
||||
t.Setenv("HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT", "900")
|
||||
provider, err := newHomarrProviderFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if provider.apiKey != "test-key" || provider.baseURL != "https://router.example/v1" ||
|
||||
provider.modelID != "mock/team-selected-model" || provider.dailyLimit != 75 || provider.globalDailyLimit != 900 {
|
||||
t.Fatalf("unexpected provider config: %#v", provider)
|
||||
}
|
||||
|
||||
t.Setenv("HOMARR_AI_DAILY_REQUEST_LIMIT", "0")
|
||||
if _, err := newHomarrProviderFromEnv(); err == nil {
|
||||
t.Fatal("expected invalid daily limit to fail")
|
||||
}
|
||||
|
||||
t.Setenv("HOMARR_AI_DAILY_REQUEST_LIMIT", "50")
|
||||
for _, unsafeURL := range []string{
|
||||
"https://user:password@router.example/v1",
|
||||
"https://router.example/v1?key=secret",
|
||||
"https://router.example/v1#fragment",
|
||||
} {
|
||||
t.Setenv("HOMARR_AI_OPENROUTER_BASE_URL", unsafeURL)
|
||||
if _, err := newHomarrProviderFromEnv(); err == nil {
|
||||
t.Fatalf("expected unsafe upstream URL %q to fail", unsafeURL)
|
||||
}
|
||||
}
|
||||
t.Setenv("HOMARR_AI_OPENROUTER_BASE_URL", "http://router.example/v1")
|
||||
if _, err := newHomarrProviderFromEnv(); err == nil {
|
||||
t.Fatal("expected an insecure upstream to fail without an explicit development opt-in")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadBoundedBody(t *testing.T) {
|
||||
body, err := readBoundedBody(strings.NewReader("1234"), 4)
|
||||
if err != nil || string(body) != "1234" {
|
||||
t.Fatalf("unexpected bounded body: %q, %v", body, err)
|
||||
}
|
||||
if _, err := readBoundedBody(strings.NewReader("12345"), 4); err != errProviderResponseTooLarge {
|
||||
t.Fatalf("expected response size error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyBounded(t *testing.T) {
|
||||
var target strings.Builder
|
||||
written, err := copyBounded(&target, strings.NewReader("1234"), 4)
|
||||
if err != nil || written != 4 || target.String() != "1234" {
|
||||
t.Fatalf("unexpected bounded copy: %q, %d, %v", target.String(), written, err)
|
||||
}
|
||||
|
||||
target.Reset()
|
||||
written, err = copyBounded(&target, strings.NewReader("12345"), 4)
|
||||
if err != errProviderResponseTooLarge || written != 4 || target.String() != "1234" {
|
||||
t.Fatalf("expected bounded copy failure, got %q, %d, %v", target.String(), written, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/pocketbase/pocketbase"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/plugins/jsvm"
|
||||
"github.com/pocketbase/pocketbase/plugins/migratecmd"
|
||||
"github.com/pocketbase/pocketbase/tools/hook"
|
||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||
)
|
||||
|
||||
func main() {
|
||||
app := pocketbase.New()
|
||||
|
||||
var hooksDir string
|
||||
app.RootCmd.PersistentFlags().StringVar(&hooksDir, "hooksDir", "", "the directory with the JS app hooks")
|
||||
|
||||
var hooksWatch bool
|
||||
app.RootCmd.PersistentFlags().BoolVar(&hooksWatch, "hooksWatch", true, "auto restart on JS hook changes")
|
||||
|
||||
var hooksPool int
|
||||
app.RootCmd.PersistentFlags().IntVar(&hooksPool, "hooksPool", 15, "the number of prewarmed JS runtimes")
|
||||
|
||||
var migrationsDir string
|
||||
app.RootCmd.PersistentFlags().StringVar(&migrationsDir, "migrationsDir", "", "the directory with JS migrations")
|
||||
|
||||
var automigrate bool
|
||||
app.RootCmd.PersistentFlags().BoolVar(&automigrate, "automigrate", true, "enable automatic migrations")
|
||||
|
||||
var publicDir string
|
||||
app.RootCmd.PersistentFlags().StringVar(&publicDir, "publicDir", defaultPublicDir(), "the static files directory")
|
||||
|
||||
var indexFallback bool
|
||||
app.RootCmd.PersistentFlags().BoolVar(&indexFallback, "indexFallback", true, "serve index.html for missing paths")
|
||||
|
||||
_ = app.RootCmd.ParseFlags(os.Args[1:])
|
||||
|
||||
jsvm.MustRegister(app, jsvm.Config{
|
||||
MigrationsDir: migrationsDir,
|
||||
HooksDir: hooksDir,
|
||||
HooksWatch: hooksWatch,
|
||||
HooksPoolSize: hooksPool,
|
||||
})
|
||||
migratecmd.MustRegister(app, app.RootCmd, migratecmd.Config{
|
||||
TemplateLang: migratecmd.TemplateLangJS,
|
||||
Automigrate: automigrate,
|
||||
Dir: migrationsDir,
|
||||
})
|
||||
|
||||
registerHomarrProvider(app)
|
||||
app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
|
||||
Func: func(event *core.ServeEvent) error {
|
||||
if !event.Router.HasRoute(http.MethodGet, "/{path...}") {
|
||||
event.Router.GET("/{path...}", apis.Static(os.DirFS(publicDir), indexFallback))
|
||||
}
|
||||
return event.Next()
|
||||
},
|
||||
Priority: 999,
|
||||
})
|
||||
|
||||
if err := app.Start(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultPublicDir() string {
|
||||
if osutils.IsProbablyGoRun() {
|
||||
return "./pb_public"
|
||||
}
|
||||
return filepath.Join(os.Args[0], "../pb_public")
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
migrate(
|
||||
(app) => {
|
||||
const users = app.findCollectionByNameOrId("users");
|
||||
const quotas = new Collection({
|
||||
type: "base",
|
||||
name: "assistant_quotas",
|
||||
listRule: null,
|
||||
viewRule: null,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{
|
||||
type: "relation",
|
||||
name: "user",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
collectionId: users.id,
|
||||
cascadeDelete: true,
|
||||
},
|
||||
{ type: "text", name: "day", required: true, min: 10, max: 10 },
|
||||
{ type: "number", name: "used", onlyInt: true, min: 0 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
quotas.addIndex("idx_assistant_quotas_user", true, "user", "");
|
||||
app.save(quotas);
|
||||
|
||||
const globalQuota = new Collection({
|
||||
type: "base",
|
||||
name: "assistant_global_quota",
|
||||
listRule: null,
|
||||
viewRule: null,
|
||||
createRule: null,
|
||||
updateRule: null,
|
||||
deleteRule: null,
|
||||
fields: [
|
||||
{ type: "text", name: "key", required: true, min: 1, max: 32 },
|
||||
{ type: "text", name: "day", required: true, min: 10, max: 10 },
|
||||
{ type: "number", name: "used", onlyInt: true, min: 0 },
|
||||
{ type: "autodate", name: "created", onCreate: true },
|
||||
{ type: "autodate", name: "updated", onCreate: true, onUpdate: true },
|
||||
],
|
||||
});
|
||||
globalQuota.addIndex("idx_assistant_global_quota_key", true, "key", "");
|
||||
app.save(globalQuota);
|
||||
|
||||
const globalRecord = new Record(globalQuota);
|
||||
globalRecord.set("key", "default");
|
||||
globalRecord.set("day", "1970-01-01");
|
||||
globalRecord.set("used", 0);
|
||||
app.save(globalRecord);
|
||||
},
|
||||
(app) => {
|
||||
app.delete(app.findCollectionByNameOrId("assistant_global_quota"));
|
||||
app.delete(app.findCollectionByNameOrId("assistant_quotas"));
|
||||
},
|
||||
);
|
||||
+25
-2
@@ -3,12 +3,17 @@ set -eu
|
||||
|
||||
WORKSHOP_TEST_PORT=${WORKSHOP_TEST_PORT:-18090}
|
||||
WORKSHOP_TEST_PROJECT="homarr-workshop-test-$$"
|
||||
export HOMARR_AI_OPENROUTER_BASE_URL=http://assistant-upstream:18091/api/v1
|
||||
export HOMARR_AI_OPENROUTER_MODEL=mock/team-selected-model
|
||||
export HOMARR_AI_ALLOW_INSECURE_UPSTREAM=true
|
||||
export OPENROUTER_API_KEY=workshop-test-openrouter-key
|
||||
|
||||
docker build --target pocketbase-test -f apps/workshop/Dockerfile .
|
||||
node apps/workshop/tests/workshop-contracts.mjs
|
||||
node apps/workshop/tests/workshop-migration.mjs
|
||||
|
||||
cleanup() {
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose --profile test -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml down --volumes --remove-orphans >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
@@ -16,7 +21,7 @@ trap cleanup EXIT
|
||||
HOMARR_WEBSITE_URL=https://preview.example.invalid \
|
||||
WORKSHOP_API_URL=https://api.preview.example.invalid \
|
||||
WORKSHOP_WEB_URL=https://preview.example.invalid/workshop \
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose --profile test -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml up --build -d
|
||||
|
||||
for attempt in $(seq 1 60); do
|
||||
@@ -59,6 +64,24 @@ for client_id in workshop-test-client workshop-rotated-client; do
|
||||
node apps/workshop/tests/oauth-sync.integration.mjs
|
||||
done
|
||||
|
||||
OPENROUTER_API_KEY="" PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml up -d --force-recreate workshop
|
||||
for attempt in $(seq 1 60); do
|
||||
if curl --fail --silent "http://127.0.0.1:$WORKSHOP_TEST_PORT/api/health" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 60 ]; then
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
models_status=$(curl --silent --output /dev/null --write-out '%{http_code}' \
|
||||
"http://127.0.0.1:$WORKSHOP_TEST_PORT/api/ai/v1/models")
|
||||
if [ "$models_status" != "503" ]; then
|
||||
echo "Expected an unconfigured Homarr provider to return 503, received $models_status"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
-f apps/workshop/docker-compose.yml stop workshop
|
||||
printf 'y\n' | PB_EXPOSE_PORT="$WORKSHOP_TEST_PORT" docker compose -p "$WORKSHOP_TEST_PROJECT" \
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
const enabled = process.env.HOMARR_AI_LIVE_SMOKE === "1";
|
||||
if (!enabled)
|
||||
throw new Error("Set HOMARR_AI_LIVE_SMOKE=1 to acknowledge that this check consumes a real provider request");
|
||||
|
||||
const baseUrl = process.env.WORKSHOP_LIVE_TEST_URL?.replace(/\/+$/u, "");
|
||||
const token = process.env.WORKSHOP_LIVE_TEST_TOKEN;
|
||||
if (!baseUrl || !token) {
|
||||
throw new Error("WORKSHOP_LIVE_TEST_URL and WORKSHOP_LIVE_TEST_TOKEN are required");
|
||||
}
|
||||
|
||||
const requestJson = async (path, init = {}) => {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers: { "content-type": "application/json", authorization: `Bearer ${token}`, ...init.headers },
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(`${response.status} ${path}: ${JSON.stringify(body)}`);
|
||||
return { body, headers: response.headers };
|
||||
};
|
||||
|
||||
const { body: models } = await requestJson("/api/ai/v1/models");
|
||||
if (models.data?.length !== 1 || models.data[0]?.id !== "homarr/model") {
|
||||
throw new Error("The live Workshop endpoint did not advertise the stable Homarr model");
|
||||
}
|
||||
|
||||
const { body: before } = await requestJson("/api/ai/usage");
|
||||
if (before.remaining < 1) throw new Error("The live test user has no remaining Homarr provider allowance");
|
||||
|
||||
const { body: completion, headers } = await requestJson("/api/ai/v1/chat/completions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: "homarr/model",
|
||||
messages: [{ role: "user", content: "Reply with exactly OK." }],
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
if (
|
||||
completion.model !== "homarr/model" ||
|
||||
typeof completion.choices?.[0]?.message?.content !== "string" ||
|
||||
completion.choices[0].message.content.length === 0 ||
|
||||
headers.get("x-homarr-quota-remaining") === null
|
||||
) {
|
||||
throw new Error("The live Homarr provider did not return an OpenAI-compatible completion with quota headers");
|
||||
}
|
||||
|
||||
const { body: after } = await requestJson("/api/ai/usage");
|
||||
if (after.used < before.used + 1 || after.remaining >= before.remaining) {
|
||||
throw new Error("The live completion was not charged to the authenticated Workshop user");
|
||||
}
|
||||
|
||||
process.stdout.write("Live Homarr provider smoke test passed\n");
|
||||
@@ -0,0 +1,203 @@
|
||||
import { createServer } from "node:http";
|
||||
|
||||
const port = Number(process.env.PORT ?? 18091);
|
||||
const expectedModel = process.env.EXPECTED_MODEL ?? "mock/team-selected-model";
|
||||
const expectedApiKey = process.env.EXPECTED_API_KEY ?? "workshop-test-openrouter-key";
|
||||
|
||||
const json = (response, status, body) => {
|
||||
response.writeHead(status, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify(body));
|
||||
};
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
if (request.method === "GET" && request.url === "/health") {
|
||||
json(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
if (request.method !== "POST" || request.url !== "/api/v1/chat/completions") {
|
||||
json(response, 404, { error: { message: "Not found" } });
|
||||
return;
|
||||
}
|
||||
if (request.headers.authorization !== `Bearer ${expectedApiKey}`) {
|
||||
json(response, 401, { error: { message: "Invalid upstream credentials" } });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
request.headers["http-referer"] !== "https://homarr.dev" ||
|
||||
request.headers["x-openrouter-title"] !== "Homarr AI Assistant" ||
|
||||
request.headers["x-openrouter-metadata"] !== "enabled"
|
||||
) {
|
||||
json(response, 400, { error: { message: "Homarr upstream attribution headers are missing or spoofed" } });
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
for await (const chunk of request) chunks.push(chunk);
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
if (body.model !== expectedModel) {
|
||||
json(response, 400, { error: { message: "Unexpected upstream model" } });
|
||||
return;
|
||||
}
|
||||
if (body.max_completion_tokens !== 32_768 || body.n !== 1 || body.parallel_tool_calls !== false) {
|
||||
json(response, 400, { error: { message: "Unsafe generation controls reached the upstream" } });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
"max_tokens" in body ||
|
||||
"user" in body ||
|
||||
"metadata" in body ||
|
||||
"extra_body" in body ||
|
||||
"extra_headers" in body ||
|
||||
"max_tokens" in (body.reasoning ?? {})
|
||||
) {
|
||||
json(response, 400, { error: { message: "Private or unbounded request fields reached the upstream" } });
|
||||
return;
|
||||
}
|
||||
const forbiddenCostField = ["audio", "modalities", "logprobs", "top_logprobs", "prediction", "service_tier"].find(
|
||||
(field) => field in body,
|
||||
);
|
||||
if (forbiddenCostField) {
|
||||
json(response, 400, { error: { message: `Client cost field was forwarded: ${forbiddenCostField}` } });
|
||||
return;
|
||||
}
|
||||
const forbiddenRoutingField = ["models", "route", "plugins", "transforms"].find((field) => field in body);
|
||||
if (forbiddenRoutingField) {
|
||||
json(response, 400, { error: { message: `Client routing field was forwarded: ${forbiddenRoutingField}` } });
|
||||
return;
|
||||
}
|
||||
if (body.provider?.zdr !== true || body.provider?.data_collection !== "deny") {
|
||||
json(response, 400, { error: { message: "Required upstream privacy controls are missing" } });
|
||||
return;
|
||||
}
|
||||
const webSearchTools = body.tools?.filter((tool) => tool.type === "openrouter:web_search") ?? [];
|
||||
if (
|
||||
webSearchTools.length > 1 ||
|
||||
webSearchTools.some((tool) => tool.parameters?.max_results !== 5 || tool.parameters?.max_uses !== 3)
|
||||
) {
|
||||
json(response, 400, { error: { message: "Unbounded web search controls reached the upstream" } });
|
||||
return;
|
||||
}
|
||||
if (body.messages?.some((message) => message.content === "upstream failure")) {
|
||||
json(response, 503, { error: { message: "Simulated upstream failure" } });
|
||||
return;
|
||||
}
|
||||
if (body.messages?.some((message) => message.content === "malformed stream")) {
|
||||
response.writeHead(200, { "content-type": "text/event-stream" });
|
||||
response.end('data: {"model":\n\n');
|
||||
return;
|
||||
}
|
||||
if (body.messages?.some((message) => message.content === "in-flight probe")) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
const wantsToolCall = body.messages?.some((message) => message.content === "inspect my dashboard");
|
||||
if (wantsToolCall && !body.tools?.some((tool) => tool.type === "function" && tool.function?.name === "board_list")) {
|
||||
json(response, 400, { error: { message: "The authenticated function tool was not forwarded" } });
|
||||
return;
|
||||
}
|
||||
|
||||
const completion = {
|
||||
id: "gen-workshop-test",
|
||||
object: "chat.completion",
|
||||
model: body.model,
|
||||
choices: [
|
||||
wantsToolCall
|
||||
? {
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call-board-list",
|
||||
type: "function",
|
||||
function: { name: "board_list", arguments: "{}" },
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: "tool_calls",
|
||||
}
|
||||
: {
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "Hello from the Homarr provider" },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 12, completion_tokens: 7, total_tokens: 19, cost: 0.00041 },
|
||||
};
|
||||
if (!body.stream) {
|
||||
json(response, 200, completion);
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache",
|
||||
"x-request-id": "mock-openrouter-request",
|
||||
});
|
||||
if (wantsToolCall) {
|
||||
response.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: completion.id,
|
||||
object: "chat.completion.chunk",
|
||||
model: body.model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call-board-list",
|
||||
type: "function",
|
||||
function: { name: "board_list", arguments: "{" },
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
})}\n\n`,
|
||||
);
|
||||
response.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: completion.id,
|
||||
object: "chat.completion.chunk",
|
||||
model: body.model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { tool_calls: [{ index: 0, function: { arguments: "}" } }] },
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
usage: completion.usage,
|
||||
})}\n\n`,
|
||||
);
|
||||
response.end("data: [DONE]\n\n");
|
||||
return;
|
||||
}
|
||||
response.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: completion.id,
|
||||
object: "chat.completion.chunk",
|
||||
model: body.model,
|
||||
choices: [{ index: 0, delta: { role: "assistant", content: "Hello from " }, finish_reason: null }],
|
||||
})}\n\n`,
|
||||
);
|
||||
setTimeout(() => {
|
||||
response.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: completion.id,
|
||||
object: "chat.completion.chunk",
|
||||
model: body.model,
|
||||
choices: [{ index: 0, delta: { content: "the Homarr provider" }, finish_reason: "stop" }],
|
||||
usage: completion.usage,
|
||||
})}\n\n`,
|
||||
);
|
||||
response.end("data: [DONE]\n\n");
|
||||
}, 150);
|
||||
});
|
||||
|
||||
server.listen(port, "0.0.0.0");
|
||||
@@ -73,6 +73,32 @@ const migration = await read("apps/workshop/pb_migrations/1784240000_workshop_wi
|
||||
for (const removedField of ["displayName", "avatarUrl", "githubProfileUrl", "githubUsername"]) {
|
||||
if (migration.includes(removedField)) throw new Error(`Redundant Workshop user field remains: ${removedField}`);
|
||||
}
|
||||
|
||||
const providerMigration = await read("apps/workshop/pb_migrations/1786500000_homarr_provider.js");
|
||||
for (const required of ['name: "assistant_quotas"', 'name: "assistant_global_quota"', "idx_assistant_quotas_user"]) {
|
||||
if (!providerMigration.includes(required)) throw new Error(`Homarr provider migration is missing: ${required}`);
|
||||
}
|
||||
for (const forbidden of ['name: "assistant_requests"', 'name: "assistant_activity"', 'name: "dailyLimit"']) {
|
||||
if (providerMigration.includes(forbidden)) throw new Error(`Homarr provider must not persist ${forbidden}`);
|
||||
}
|
||||
|
||||
const providerBackend = await read("apps/workshop/homarr_provider.go");
|
||||
for (const required of [
|
||||
'"homarr/model"',
|
||||
'apis.RequireAuth("users")',
|
||||
"DefaultActivityLoggerMiddlewareId",
|
||||
'os.Getenv("OPENROUTER_API_KEY")',
|
||||
'os.Getenv("HOMARR_AI_DAILY_REQUEST_LIMIT")',
|
||||
'os.Getenv("HOMARR_AI_GLOBAL_DAILY_REQUEST_LIMIT")',
|
||||
'os.Getenv("HOMARR_AI_OPENROUTER_MODEL")',
|
||||
'"data_collection": "deny"',
|
||||
'"zdr": true',
|
||||
]) {
|
||||
if (!providerBackend.includes(required)) throw new Error(`Homarr provider backend is missing: ${required}`);
|
||||
}
|
||||
if (/logger\(\)|\.Logger\(|log\.Print/u.test(providerBackend)) {
|
||||
throw new Error("The Homarr provider must not log request content or user tokens");
|
||||
}
|
||||
for (const required of [
|
||||
"workshop_migration_state",
|
||||
"addedUserFields",
|
||||
|
||||
@@ -35,10 +35,12 @@ for (const required of [
|
||||
"reports",
|
||||
"workshop_listings",
|
||||
"workshop_report_summaries",
|
||||
"assistant_quotas",
|
||||
"assistant_global_quota",
|
||||
]) {
|
||||
if (!collectionNames.has(required)) throw new Error(`Missing Workshop collection: ${required}`);
|
||||
}
|
||||
for (const removed of ["workshop_admin_actions", "workshop_admins"]) {
|
||||
for (const removed of ["workshop_admin_actions", "workshop_admins", "assistant_requests", "assistant_activity"]) {
|
||||
if (collectionNames.has(removed)) throw new Error(`Removed Workshop collection still exists: ${removed}`);
|
||||
}
|
||||
|
||||
@@ -99,6 +101,393 @@ if (unchangedAuthor.name !== "widget-author") {
|
||||
const visitorPassword = "WorkshopVisitor123!";
|
||||
const visitor = await createUser("widget-visitor@example.invalid", "widget-visitor", visitorPassword);
|
||||
const visitorSession = await signIn(visitor.email, visitorPassword);
|
||||
const concurrencyPassword = "WorkshopConcurrency123!";
|
||||
const concurrencyUser = await createUser(
|
||||
"provider-concurrency@example.invalid",
|
||||
"provider-concurrency",
|
||||
concurrencyPassword,
|
||||
);
|
||||
const concurrencySession = await signIn(concurrencyUser.email, concurrencyPassword);
|
||||
|
||||
const modelList = await request("/api/ai/v1/models");
|
||||
if (
|
||||
modelList.object !== "list" ||
|
||||
modelList.data.length !== 1 ||
|
||||
modelList.data[0].id !== "homarr/model" ||
|
||||
modelList.data[0].context_length !== 256 * 1024
|
||||
) {
|
||||
throw new Error("The Homarr provider must advertise exactly one model");
|
||||
}
|
||||
await expectStatus("/api/ai/usage", {}, 401);
|
||||
await expectStatus(
|
||||
"/api/ai/v1/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
model: "homarr/model",
|
||||
messages: [{ role: "user", content: "unauthenticated" }],
|
||||
}),
|
||||
},
|
||||
401,
|
||||
);
|
||||
await expectStatus(
|
||||
"/api/ai/v1/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { authorization: "Bearer forged-workshop-user-token" },
|
||||
body: JSON.stringify({
|
||||
model: "homarr/model",
|
||||
messages: [{ role: "user", content: "forged identity" }],
|
||||
}),
|
||||
},
|
||||
401,
|
||||
);
|
||||
|
||||
const inFlightProbe = () =>
|
||||
fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...concurrencySession.headers },
|
||||
body: JSON.stringify({
|
||||
model: "homarr/model",
|
||||
messages: [{ role: "user", content: "in-flight probe" }],
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
const inFlightResponses = await Promise.all([inFlightProbe(), inFlightProbe(), inFlightProbe()]);
|
||||
if (
|
||||
inFlightResponses
|
||||
.map((response) => response.status)
|
||||
.toSorted()
|
||||
.join(",") !== "200,200,429"
|
||||
) {
|
||||
throw new Error("The per-user in-flight request limit was not enforced");
|
||||
}
|
||||
const concurrencyUsage = await request("/api/ai/usage", { headers: concurrencySession.headers });
|
||||
if (concurrencyUsage.used !== 2) {
|
||||
throw new Error(`Rejected in-flight requests must not consume allowance: ${JSON.stringify(concurrencyUsage)}`);
|
||||
}
|
||||
|
||||
const initialUsage = await request("/api/ai/usage", { headers: authorSession.headers });
|
||||
const initialReset = new Date(initialUsage.resetsAt);
|
||||
if (
|
||||
initialUsage.limit !== 50 ||
|
||||
initialUsage.used !== 0 ||
|
||||
initialUsage.remaining !== 50 ||
|
||||
initialReset.getUTCHours() !== 0 ||
|
||||
initialReset <= new Date()
|
||||
) {
|
||||
throw new Error(`Unexpected initial Homarr provider allowance: ${JSON.stringify(initialUsage)}`);
|
||||
}
|
||||
|
||||
await expectStatus(
|
||||
"/api/ai/v1/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: authorSession.headers,
|
||||
body: JSON.stringify({
|
||||
model: "homarr/model",
|
||||
messages: [
|
||||
{ role: "user", content: "too many tools" },
|
||||
...Array.from({ length: 1000 }, (_, index) => ({
|
||||
role: "tool",
|
||||
tool_call_id: `tool-${index}`,
|
||||
content: "{}",
|
||||
})),
|
||||
],
|
||||
}),
|
||||
},
|
||||
400,
|
||||
);
|
||||
|
||||
await expectStatus(
|
||||
"/api/ai/v1/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: authorSession.headers,
|
||||
body: JSON.stringify({
|
||||
model: "homarr/model",
|
||||
messages: [{ role: "user", content: "x".repeat(256 * 1024 + 1) }],
|
||||
}),
|
||||
},
|
||||
413,
|
||||
);
|
||||
await expectStatus(
|
||||
"/api/ai/v1/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: authorSession.headers,
|
||||
body: JSON.stringify({
|
||||
model: "homarr/model",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "image_url", image_url: { url: "https://attacker.example/tracker.png" } }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
413,
|
||||
);
|
||||
const usageAfterRejectedRequest = await request("/api/ai/usage", { headers: authorSession.headers });
|
||||
if (usageAfterRejectedRequest.used !== 0) {
|
||||
throw new Error("A rejected oversized request-unit batch consumed allowance");
|
||||
}
|
||||
|
||||
await expectStatus(
|
||||
"/api/ai/v1/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: authorSession.headers,
|
||||
body: JSON.stringify({ model: "another-model", messages: [{ role: "user", content: "hello" }] }),
|
||||
},
|
||||
400,
|
||||
);
|
||||
|
||||
const streamStartedAt = performance.now();
|
||||
const streamResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...authorSession.headers },
|
||||
body: JSON.stringify({
|
||||
model: "homarr/model",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
if (!streamResponse.ok || streamResponse.headers.get("x-homarr-quota-remaining") !== "49") {
|
||||
throw new Error(`Streaming provider request failed: ${streamResponse.status}`);
|
||||
}
|
||||
const streamReader = streamResponse.body.getReader();
|
||||
const firstChunk = await streamReader.read();
|
||||
const firstChunkAt = performance.now();
|
||||
let streamText = new TextDecoder().decode(firstChunk.value ?? new Uint8Array());
|
||||
while (true) {
|
||||
const chunk = await streamReader.read();
|
||||
if (chunk.done) break;
|
||||
streamText += new TextDecoder().decode(chunk.value);
|
||||
}
|
||||
const dataFrames = streamText.split(/\r?\n/u).filter((line) => line.startsWith("data:"));
|
||||
if (
|
||||
firstChunk.done ||
|
||||
!streamText.includes("Hello from ") ||
|
||||
!streamText.includes("the Homarr provider") ||
|
||||
streamText.includes("mock/team-selected-model") ||
|
||||
!streamText.includes('"model":"homarr/model"') ||
|
||||
dataFrames.length < 3 ||
|
||||
dataFrames.at(-1)?.trim() !== "data: [DONE]" ||
|
||||
firstChunkAt - streamStartedAt > 5_000
|
||||
) {
|
||||
throw new Error("The Homarr provider did not preserve the upstream SSE stream");
|
||||
}
|
||||
const malformedStreamResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...concurrencySession.headers },
|
||||
body: JSON.stringify({
|
||||
model: "homarr/model",
|
||||
messages: [{ role: "user", content: "malformed stream" }],
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
const malformedStreamText = await malformedStreamResponse.text();
|
||||
if (
|
||||
!malformedStreamResponse.ok ||
|
||||
!malformedStreamText.includes('"type":"homarr_provider_error"') ||
|
||||
malformedStreamText.includes('data: {"model":') ||
|
||||
!malformedStreamText.endsWith("data: [DONE]\n\n")
|
||||
) {
|
||||
throw new Error(`Malformed upstream streams did not terminate safely: ${malformedStreamText}`);
|
||||
}
|
||||
const toolResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...authorSession.headers,
|
||||
"http-referer": "https://attacker.example",
|
||||
"x-openrouter-title": "Attacker",
|
||||
"x-openrouter-metadata": "attacker",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "homarr/model",
|
||||
messages: [
|
||||
{ role: "user", content: "inspect my dashboard" },
|
||||
{ role: "assistant", content: null, tool_calls: [{ id: "one" }, { id: "two" }] },
|
||||
{ role: "tool", tool_call_id: "one", content: "{}" },
|
||||
{ role: "tool", tool_call_id: "two", content: "{}" },
|
||||
],
|
||||
stream: false,
|
||||
models: ["attacker/model"],
|
||||
provider: { order: ["attacker"] },
|
||||
route: "fallback",
|
||||
plugins: ["web"],
|
||||
transforms: ["middle-out"],
|
||||
max_tokens: 1_000_000,
|
||||
max_completion_tokens: 1_000_000,
|
||||
n: 100,
|
||||
parallel_tool_calls: true,
|
||||
extra_body: { provider: { order: ["attacker"] } },
|
||||
extra_headers: { Authorization: "Bearer attacker" },
|
||||
metadata: { user: "must-not-reach-openrouter" },
|
||||
audio: { format: "wav", voice: "alloy" },
|
||||
modalities: ["text", "audio"],
|
||||
logprobs: true,
|
||||
top_logprobs: 20,
|
||||
prediction: { content: "attacker-controlled prediction" },
|
||||
service_tier: "priority",
|
||||
reasoning: { effort: "high", max_tokens: 1_000_000 },
|
||||
user: "must-not-reach-openrouter",
|
||||
tools: [
|
||||
{ type: "function", function: { name: "board_list" } },
|
||||
{ type: "openrouter:web_search", parameters: { max_results: 1000, max_uses: 1000 } },
|
||||
{ type: "openrouter:web_search" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!toolResponse.ok || toolResponse.headers.get("x-homarr-quota-remaining") !== "48") {
|
||||
throw new Error("Every upstream inference must consume one Homarr provider request unit");
|
||||
}
|
||||
const toolCompletion = await toolResponse.json();
|
||||
if (
|
||||
toolCompletion.model !== "homarr/model" ||
|
||||
toolCompletion.choices?.[0]?.finish_reason !== "tool_calls" ||
|
||||
toolCompletion.choices?.[0]?.message?.tool_calls?.[0]?.function?.name !== "board_list"
|
||||
) {
|
||||
throw new Error(
|
||||
`The public model alias or function tool response was not forwarded: ${JSON.stringify(toolCompletion)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const streamedToolResponse = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...concurrencySession.headers },
|
||||
body: JSON.stringify({
|
||||
model: "homarr/model",
|
||||
messages: [{ role: "user", content: "inspect my dashboard" }],
|
||||
tools: [{ type: "function", function: { name: "board_list", parameters: { type: "object" } } }],
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
const streamedToolText = await streamedToolResponse.text();
|
||||
if (
|
||||
!streamedToolResponse.ok ||
|
||||
streamedToolText.includes("mock/team-selected-model") ||
|
||||
!streamedToolText.includes('"model":"homarr/model"') ||
|
||||
!streamedToolText.includes('"name":"board_list"') ||
|
||||
!streamedToolText.includes('"arguments":"{"') ||
|
||||
!streamedToolText.includes('"arguments":"}"') ||
|
||||
!streamedToolText.includes('"finish_reason":"tool_calls"') ||
|
||||
!streamedToolText.endsWith("data: [DONE]\n\n")
|
||||
) {
|
||||
throw new Error(`Streamed function tool calls were not preserved safely: ${streamedToolText}`);
|
||||
}
|
||||
|
||||
const afterToolsUsage = await request("/api/ai/usage", { headers: authorSession.headers });
|
||||
if (afterToolsUsage.used !== 2 || afterToolsUsage.remaining !== 48) {
|
||||
throw new Error(`Tool request accounting is incorrect: ${JSON.stringify(afterToolsUsage)}`);
|
||||
}
|
||||
|
||||
const upstreamFailure = await fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...authorSession.headers },
|
||||
body: JSON.stringify({
|
||||
model: "homarr/model",
|
||||
messages: [{ role: "user", content: "upstream failure" }],
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
if (upstreamFailure.status !== 503 || upstreamFailure.headers.get("x-homarr-quota-remaining") !== "47") {
|
||||
throw new Error("Every request forwarded upstream must consume Homarr provider allowance");
|
||||
}
|
||||
const upstreamFailureBody = await upstreamFailure.text();
|
||||
if (upstreamFailureBody.includes("Simulated upstream failure")) {
|
||||
throw new Error("Upstream error details must not be exposed to clients");
|
||||
}
|
||||
const afterFailureUsage = await request("/api/ai/usage", { headers: authorSession.headers });
|
||||
if (afterFailureUsage.used !== 3 || afterFailureUsage.remaining !== 47) {
|
||||
throw new Error(`Forwarded upstream failure escaped quota accounting: ${JSON.stringify(afterFailureUsage)}`);
|
||||
}
|
||||
const visitorUsage = await request("/api/ai/usage", { headers: visitorSession.headers });
|
||||
if (visitorUsage.used !== 0 || visitorUsage.remaining !== 50) {
|
||||
throw new Error(`Provider allowance leaked across users: ${JSON.stringify(visitorUsage)}`);
|
||||
}
|
||||
|
||||
await expectStatus("/api/collections/assistant_quotas/records", { headers: authorSession.headers }, 403);
|
||||
await expectStatus("/api/collections/assistant_global_quota/records", { headers: authorSession.headers }, 403);
|
||||
const quotas = await request("/api/collections/assistant_quotas/records?perPage=20", { headers: rootHeaders });
|
||||
const authorQuota = quotas.items.find((item) => item.user === author.id);
|
||||
if (
|
||||
!authorQuota ||
|
||||
authorQuota.used !== 3 ||
|
||||
["dailyLimit", "inputTokens", "outputTokens", "totalTokens", "model", "cost"].some((field) => field in authorQuota)
|
||||
) {
|
||||
throw new Error(`Private provider quota is incorrect: ${JSON.stringify(authorQuota)}`);
|
||||
}
|
||||
|
||||
await request(`/api/collections/assistant_quotas/records/${authorQuota.id}`, {
|
||||
method: "PATCH",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({ day: "2000-01-01", used: 49 }),
|
||||
});
|
||||
const resetUsage = await request("/api/ai/usage", { headers: authorSession.headers });
|
||||
if (resetUsage.limit !== 50 || resetUsage.used !== 0 || resetUsage.remaining !== 50) {
|
||||
throw new Error(`Quota did not reset at the UTC day boundary: ${JSON.stringify(resetUsage)}`);
|
||||
}
|
||||
const resetQuota = (
|
||||
await request("/api/collections/assistant_quotas/records?perPage=20", { headers: rootHeaders })
|
||||
).items.find((item) => item.user === author.id);
|
||||
await request(`/api/collections/assistant_quotas/records/${resetQuota.id}`, {
|
||||
method: "PATCH",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({ used: 49 }),
|
||||
});
|
||||
|
||||
const allowanceProbe = () =>
|
||||
fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...authorSession.headers },
|
||||
body: JSON.stringify({
|
||||
model: "homarr/model",
|
||||
messages: [{ role: "user", content: "allowance probe" }],
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
const concurrentAllowanceResponses = await Promise.all([allowanceProbe(), allowanceProbe()]);
|
||||
const concurrentStatuses = concurrentAllowanceResponses.map((response) => response.status).toSorted();
|
||||
const exhaustedResponse = concurrentAllowanceResponses.find((response) => response.status === 429);
|
||||
if (
|
||||
concurrentStatuses.join(",") !== "200,429" ||
|
||||
exhaustedResponse?.headers.get("x-homarr-quota-remaining") !== "0" ||
|
||||
!exhaustedResponse.headers.get("x-homarr-quota-reset")
|
||||
) {
|
||||
throw new Error("The per-user allowance was not enforced atomically");
|
||||
}
|
||||
|
||||
const globalQuota = (await request("/api/collections/assistant_global_quota/records", { headers: rootHeaders }))
|
||||
.items[0];
|
||||
await request(`/api/collections/assistant_global_quota/records/${globalQuota.id}`, {
|
||||
method: "PATCH",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({ day: new Date().toISOString().slice(0, 10), used: 9_999 }),
|
||||
});
|
||||
await request(`/api/collections/assistant_quotas/records/${resetQuota.id}`, {
|
||||
method: "PATCH",
|
||||
headers: rootHeaders,
|
||||
body: JSON.stringify({ used: 0 }),
|
||||
});
|
||||
const globalResponses = await Promise.all([
|
||||
allowanceProbe(),
|
||||
fetch(`${baseUrl}/api/ai/v1/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", ...visitorSession.headers },
|
||||
body: JSON.stringify({ model: "homarr/model", messages: [{ role: "user", content: "global allowance probe" }] }),
|
||||
}),
|
||||
]);
|
||||
if (
|
||||
globalResponses
|
||||
.map((response) => response.status)
|
||||
.toSorted()
|
||||
.join(",") !== "200,429"
|
||||
) {
|
||||
throw new Error("The shared upstream allowance was not enforced atomically across users");
|
||||
}
|
||||
|
||||
const widget = {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { chromium, expect } from "@playwright/test";
|
||||
import type { Browser } from "@playwright/test";
|
||||
import type { StartedTestContainer } from "testcontainers";
|
||||
import { describe, test } from "vitest";
|
||||
|
||||
import * as sqliteSchema from "../packages/db/schema/sqlite";
|
||||
import { createHomarrContainer } from "./shared/create-homarr-container";
|
||||
import { createSqliteDbFileAsync } from "./shared/e2e-db";
|
||||
import { seedAdminUserAsync } from "./shared/seed-admin-user";
|
||||
|
||||
const adminCredentials = {
|
||||
username: "admin",
|
||||
password: "Comp(exP4sswOrd",
|
||||
};
|
||||
|
||||
// This is an opt-in documentation asset generator, not part of the normal E2E gate.
|
||||
// Run it explicitly with UPDATE_DOCS_SCREENSHOTS=true when the documented UI changes.
|
||||
describe.skipIf(process.env.UPDATE_DOCS_SCREENSHOTS !== "true")("Assistant documentation screenshots", () => {
|
||||
test("captures the management page and conversation panel", async () => {
|
||||
const { db, localMountPath } = await createSqliteDbFileAsync();
|
||||
await seedAdminUserAsync(db, adminCredentials);
|
||||
await db.insert(sqliteSchema.assistantConfigurations).values({
|
||||
id: "default",
|
||||
enabled: true,
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
modelDiscoveryPath: null,
|
||||
encryptedApiKey: "encrypted.key",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
});
|
||||
|
||||
const outputDirectory = path.resolve("apps/docs/static/img/assistant");
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
let homarrContainer: StartedTestContainer | undefined;
|
||||
let browser: Browser | undefined;
|
||||
|
||||
try {
|
||||
homarrContainer = await createHomarrContainer({
|
||||
environment: { AUTH_PROVIDERS: "credentials" },
|
||||
mounts: { "/appdata": localMountPath },
|
||||
}).start();
|
||||
const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
|
||||
browser = await chromium.launch();
|
||||
const context = await browser.newContext({
|
||||
colorScheme: "dark",
|
||||
deviceScaleFactor: 1,
|
||||
viewport: { width: 1440, height: 1000 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
await page.goto(`${baseUrl}/auth/login`);
|
||||
await page.getByLabel("Username").fill(adminCredentials.username);
|
||||
await page.locator("#password").fill(adminCredentials.password);
|
||||
await page.getByRole("button", { name: "Login" }).click();
|
||||
await expect(page).not.toHaveURL(/\/auth\/login/, { timeout: 30_000 });
|
||||
|
||||
await page.goto(`${baseUrl}/manage/assistant`);
|
||||
await expect(page.getByRole("heading", { name: "Assistant" })).toBeVisible();
|
||||
await expect(page.getByText("Connection ready")).toBeVisible();
|
||||
await page
|
||||
.getByRole("main")
|
||||
.first()
|
||||
.screenshot({
|
||||
animations: "disabled",
|
||||
path: path.join(outputDirectory, "configuration.png"),
|
||||
});
|
||||
|
||||
await page.goto(baseUrl);
|
||||
await expect(page.getByRole("main").first()).toBeVisible();
|
||||
await page.locator("body").click({ position: { x: 12, y: 12 } });
|
||||
await page.keyboard.press("Shift+A");
|
||||
const assistantDialog = page.getByRole("dialog", { name: "Homarr Assistant" });
|
||||
await expect(assistantDialog).toBeVisible();
|
||||
await assistantDialog.screenshot({
|
||||
animations: "disabled",
|
||||
path: path.join(outputDirectory, "conversation.png"),
|
||||
});
|
||||
|
||||
await db.update(sqliteSchema.assistantConfigurations).set({
|
||||
provider: "homarr",
|
||||
baseUrl: "https://homarr.dev/api/ai/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
encryptedApiKey: null,
|
||||
modelId: "homarr/model",
|
||||
});
|
||||
await page.goto(`${baseUrl}/manage/assistant`);
|
||||
await expect(page.getByRole("heading", { name: "Assistant" })).toBeVisible();
|
||||
await expect(page.getByText("Homarr", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Refresh models" })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByText("Homarr model")).toBeVisible();
|
||||
} finally {
|
||||
await Promise.allSettled([browser?.close(), homarrContainer?.stop()]);
|
||||
}
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -49,6 +49,7 @@
|
||||
"test": "cross-env NODE_ENV=development CI=true vitest run --project \"!e2e\" --retry 2 --coverage.enabled ",
|
||||
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e --retry 2 --maxWorkers 2",
|
||||
"test:workshop": "sh apps/workshop/test.sh",
|
||||
"test:workshop-live-provider": "node apps/workshop/tests/openrouter-live-smoke.mjs",
|
||||
"test:workshop-image": "sh apps/workshop/test-image.sh",
|
||||
"test:ui": "cross-env NODE_ENV=development CI=true vitest --project \"!e2e\" --ui --coverage.enabled",
|
||||
"typecheck": "turbo typecheck",
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
|
||||
import type { createTRPCContext } from "../trpc";
|
||||
import { fetchOpenRouterGenerationTelemetryAsync } from "../assistant-generation-telemetry";
|
||||
import { env } from "../env";
|
||||
import { orderMessagesByParent } from "../assistant-message-order";
|
||||
import { verifyAssistantGenerationAccessToken } from "../assistant-generation-access";
|
||||
import { createTRPCRouter, isDemoMode, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
@@ -129,6 +130,9 @@ const normalizeDiscoveryPath = (value: string | null) => {
|
||||
return path;
|
||||
};
|
||||
|
||||
const getHomarrProviderBaseUrl = () =>
|
||||
`${(env.WORKSHOP_API_URL ?? env.HOMARR_WEBSITE_URL).replace(/\/+$/u, "")}/api/ai/v1`;
|
||||
|
||||
const getConfigurationAsync = async (db: Database) => {
|
||||
return await db.query.assistantConfigurations.findFirst({
|
||||
where: eq(assistantConfigurations.id, configurationId),
|
||||
@@ -160,7 +164,7 @@ const getProviderHeaders = (configuration: AssistantConfiguration) => {
|
||||
}
|
||||
if (configuration.provider === "openrouter") {
|
||||
headers["HTTP-Referer"] ??= "https://homarr.dev";
|
||||
headers["X-Title"] ??= "Homarr Assistant";
|
||||
headers["X-OpenRouter-Title"] ??= "Homarr AI Assistant";
|
||||
}
|
||||
return { ...headers, ...decryptCustomHeaders(configuration.encryptedHeaders) };
|
||||
};
|
||||
@@ -231,7 +235,7 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
|
||||
const models = (Array.isArray(body) ? body : (body.data ?? body.models ?? [])).slice(0, 1_000);
|
||||
return models
|
||||
.filter((model) => {
|
||||
if (configuration.provider !== "openrouter") return true;
|
||||
if (configuration.provider !== "openrouter" && configuration.provider !== "homarr") return true;
|
||||
const parameters = Array.isArray(model.supported_parameters) ? model.supported_parameters : [];
|
||||
const outputModalities = Array.isArray(model.architecture?.output_modalities)
|
||||
? model.architecture.output_modalities
|
||||
@@ -260,6 +264,7 @@ const fetchModelsAsync = async (configuration: AssistantConfiguration) => {
|
||||
: null;
|
||||
const toolSupport =
|
||||
configuration.provider === "openrouter" ||
|
||||
configuration.provider === "homarr" ||
|
||||
model.capabilities?.function_calling === true ||
|
||||
configuration.provider === "anthropic"
|
||||
? ("confirmed" as const)
|
||||
@@ -479,7 +484,7 @@ export const assistantRouter = createTRPCRouter({
|
||||
const configuration = await getConfigurationAsync(ctx.db);
|
||||
if (isDemoMode) {
|
||||
return {
|
||||
provider: "homarr",
|
||||
provider: "custom" as const,
|
||||
defaultModelId: "homarr-assistant",
|
||||
models: [
|
||||
{
|
||||
@@ -546,23 +551,30 @@ export const assistantRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const baseUrl = normalizeBaseUrl(input.baseUrl);
|
||||
const modelDiscoveryPath = normalizeDiscoveryPath(input.modelDiscoveryPath);
|
||||
const baseUrl = input.provider === "homarr" ? getHomarrProviderBaseUrl() : normalizeBaseUrl(input.baseUrl);
|
||||
const modelDiscoveryPath =
|
||||
input.provider === "homarr" ? "/models" : normalizeDiscoveryPath(input.modelDiscoveryPath);
|
||||
const existing = await getConfigurationAsync(ctx.db);
|
||||
const destinationChanged =
|
||||
existing !== undefined && (existing.provider !== input.provider || existing.baseUrl !== baseUrl);
|
||||
const connectionChanged =
|
||||
existing !== undefined && (destinationChanged || existing.modelDiscoveryPath !== modelDiscoveryPath);
|
||||
const encryptedApiKey = input.apiKey
|
||||
? encryptSecret(input.apiKey)
|
||||
: input.clearApiKey || destinationChanged
|
||||
const encryptedApiKey =
|
||||
input.provider === "homarr"
|
||||
? null
|
||||
: (existing?.encryptedApiKey ?? null);
|
||||
const encryptedHeaders = input.customHeaders
|
||||
? encryptSecret(JSON.stringify(input.customHeaders))
|
||||
: input.clearCustomHeaders || destinationChanged
|
||||
: input.apiKey
|
||||
? encryptSecret(input.apiKey)
|
||||
: input.clearApiKey || destinationChanged
|
||||
? null
|
||||
: (existing?.encryptedApiKey ?? null);
|
||||
const encryptedHeaders =
|
||||
input.provider === "homarr"
|
||||
? null
|
||||
: (existing?.encryptedHeaders ?? null);
|
||||
: input.customHeaders
|
||||
? encryptSecret(JSON.stringify(input.customHeaders))
|
||||
: input.clearCustomHeaders || destinationChanged
|
||||
? null
|
||||
: (existing?.encryptedHeaders ?? null);
|
||||
|
||||
if (existing) {
|
||||
await ctx.db
|
||||
|
||||
@@ -121,6 +121,32 @@ describe("assistantRouter.updateConnection", () => {
|
||||
modelId: "example/model",
|
||||
});
|
||||
});
|
||||
|
||||
test("never stores administrator credentials for the Homarr provider", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
await caller.updateConnection({
|
||||
provider: "homarr",
|
||||
baseUrl: "https://attacker.example/v1",
|
||||
modelDiscoveryPath: "/attacker-models",
|
||||
apiKey: "must-not-be-stored",
|
||||
customHeaders: { "X-Secret": "must-not-be-stored" },
|
||||
clearApiKey: false,
|
||||
clearCustomHeaders: false,
|
||||
});
|
||||
|
||||
const [configuration] = await db.select().from(assistantConfigurations);
|
||||
expect(configuration).toMatchObject({
|
||||
provider: "homarr",
|
||||
baseUrl: "https://homarr.dev/api/ai/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
encryptedApiKey: null,
|
||||
encryptedHeaders: null,
|
||||
enabled: false,
|
||||
modelId: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("assistantRouter.updateConfiguration", () => {
|
||||
@@ -170,6 +196,49 @@ describe("assistantRouter.updateConfiguration", () => {
|
||||
expect(configuration?.webSearchEnabled).toBe(true);
|
||||
});
|
||||
|
||||
test("allows the Homarr OpenRouter proxy to enable web search", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
await db
|
||||
.update(assistantConfigurations)
|
||||
.set({
|
||||
provider: "homarr",
|
||||
baseUrl: "https://homarr.dev/api/ai/v1",
|
||||
encryptedApiKey: null,
|
||||
encryptedHeaders: null,
|
||||
modelId: "homarr/model",
|
||||
})
|
||||
.where(eq(assistantConfigurations.id, "default"));
|
||||
const caller = assistantRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
Response.json({
|
||||
data: [
|
||||
{
|
||||
id: "homarr/model",
|
||||
name: "Homarr model",
|
||||
supported_parameters: ["tools"],
|
||||
architecture: { output_modalities: ["text"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await caller.updateConfiguration({
|
||||
enabled: true,
|
||||
modelId: "homarr/model",
|
||||
webSearchEnabled: true,
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
|
||||
const [configuration] = await db.select().from(assistantConfigurations);
|
||||
expect(configuration?.webSearchEnabled).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects OpenRouter server tools for an incompatible provider", async () => {
|
||||
const db = await createConfiguredAssistantAsync();
|
||||
await db
|
||||
|
||||
@@ -25,7 +25,9 @@ describe("assistant provider presets", () => {
|
||||
|
||||
test("requires credentials for hosted providers but not local or custom endpoints", () => {
|
||||
for (const provider of assistantProviderIds) {
|
||||
expect(assistantProviderRequiresApiKey(provider)).toBe(assistantProviderPresets[provider].category === "hosted");
|
||||
expect(assistantProviderRequiresApiKey(provider)).toBe(
|
||||
assistantProviderPresets[provider].category === "hosted" && provider !== "homarr",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getCustomWidgetJsonSchema } from "../src/core/schema";
|
||||
import type { CustomWidgetAiEvaluationCase } from "./ai-evaluation-cases";
|
||||
import {
|
||||
DEFAULT_GENERATOR_MODEL,
|
||||
getAiProviderChatCompletionsUrl,
|
||||
getDeterministicEvaluationIssues,
|
||||
judgePasses,
|
||||
parseJudgeResult,
|
||||
@@ -441,12 +442,13 @@ function buildAssistantPrompt(testCase: CustomWidgetAiEvaluationCase, feedback:
|
||||
|
||||
async function callAssistantStep(args: {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
model: string;
|
||||
messages: OpenRouterMessage[];
|
||||
tools: ToolDefinition[];
|
||||
toolChoice?: string;
|
||||
}): Promise<OpenRouterAssistantMessage> {
|
||||
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
const response = await fetch(getAiProviderChatCompletionsUrl(args.baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${args.apiKey}`,
|
||||
@@ -467,10 +469,10 @@ async function callAssistantStep(args: {
|
||||
});
|
||||
const payload = (await response.json()) as OpenRouterResponse;
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenRouter request failed (${response.status}): ${payload.error?.message ?? "Unknown error"}`);
|
||||
throw new Error(`AI provider request failed (${response.status}): ${payload.error?.message ?? "Unknown error"}`);
|
||||
}
|
||||
const message = payload.choices?.[0]?.message;
|
||||
if (!message) throw new Error("OpenRouter returned no assistant message");
|
||||
if (!message) throw new Error("AI provider returned no assistant message");
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -498,6 +500,7 @@ export const getAssistantEvaluationRecoveryToolName = (state: AssistantAttemptSt
|
||||
|
||||
async function callAssistantStepWithRecovery(args: {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
model: string;
|
||||
messages: OpenRouterMessage[];
|
||||
tools: ToolDefinition[];
|
||||
@@ -530,6 +533,7 @@ async function callAssistantStepWithRecovery(args: {
|
||||
async function runAssistantAttempt(args: {
|
||||
testCase: CustomWidgetAiEvaluationCase;
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
model: string;
|
||||
feedback: readonly string[];
|
||||
}) {
|
||||
@@ -553,6 +557,7 @@ async function runAssistantAttempt(args: {
|
||||
: customWidgetAssistantEvaluationToolDefinitions;
|
||||
const message = await callAssistantStepWithRecovery({
|
||||
apiKey: args.apiKey,
|
||||
baseUrl: args.baseUrl,
|
||||
model: args.model,
|
||||
messages,
|
||||
tools,
|
||||
@@ -592,6 +597,7 @@ async function runAssistantAttempt(args: {
|
||||
export async function evaluateCustomWidgetAssistantCase(args: {
|
||||
testCase: CustomWidgetAiEvaluationCase;
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
outputRoot: string;
|
||||
maxLoops: number;
|
||||
generatorModel?: string;
|
||||
@@ -612,6 +618,7 @@ export async function evaluateCustomWidgetAssistantCase(args: {
|
||||
run = await runAssistantAttempt({
|
||||
testCase: args.testCase,
|
||||
apiKey: args.apiKey,
|
||||
baseUrl: args.baseUrl,
|
||||
model: args.generatorModel ?? DEFAULT_GENERATOR_MODEL,
|
||||
feedback,
|
||||
});
|
||||
@@ -651,6 +658,7 @@ export async function evaluateCustomWidgetAssistantCase(args: {
|
||||
testCase: args.testCase,
|
||||
widget: run.state.createdWidget,
|
||||
apiKey: args.apiKey,
|
||||
baseUrl: args.baseUrl,
|
||||
judgeModel: args.judgeModel,
|
||||
});
|
||||
await writeFile(path.join(caseDirectory, `judge-${attempt}.json`), judgeRaw, "utf8");
|
||||
|
||||
@@ -14,7 +14,29 @@ import type { CustomWidgetAiEvaluationCase } from "./ai-evaluation-cases";
|
||||
// deepseek/deepseek-v4-flash-latest alias is rejected by the chat-completions API.
|
||||
export const DEFAULT_GENERATOR_MODEL = "~deepseek/deepseek-v4-flash-latest";
|
||||
export const DEFAULT_JUDGE_MODEL = "~deepseek/deepseek-v4-flash-latest";
|
||||
export const DEFAULT_AI_PROVIDER_BASE_URL = "https://openrouter.ai/api/v1";
|
||||
export const MAX_AI_EVALUATION_LOOPS = 10;
|
||||
export const getAiProviderChatCompletionsUrl = (baseUrl = DEFAULT_AI_PROVIDER_BASE_URL) =>
|
||||
`${baseUrl.replace(/\/+$/u, "")}/chat/completions`;
|
||||
export const resolveAiEvaluationProviderConfig = (environment: Record<string, string | undefined>) => {
|
||||
const configuredBaseUrl = environment.AI_PROVIDER_BASE_URL?.trim();
|
||||
const baseUrl = (configuredBaseUrl || DEFAULT_AI_PROVIDER_BASE_URL).replace(/\/+$/u, "");
|
||||
const homarrProvider = baseUrl.endsWith("/api/ai/v1");
|
||||
const openRouterProvider = baseUrl === DEFAULT_AI_PROVIDER_BASE_URL;
|
||||
const providerDefaultModel = homarrProvider ? "homarr/model" : DEFAULT_GENERATOR_MODEL;
|
||||
return {
|
||||
apiKey: environment.AI_PROVIDER_API_KEY ?? (openRouterProvider ? environment.OPENROUTER_API_KEY : undefined),
|
||||
baseUrl,
|
||||
generatorModel:
|
||||
environment.AI_PROVIDER_MODEL ??
|
||||
(openRouterProvider ? environment.OPENROUTER_GENERATOR_MODEL : undefined) ??
|
||||
providerDefaultModel,
|
||||
judgeModel:
|
||||
environment.AI_PROVIDER_JUDGE_MODEL ??
|
||||
(openRouterProvider ? environment.OPENROUTER_JUDGE_MODEL : undefined) ??
|
||||
(homarrProvider ? providerDefaultModel : DEFAULT_JUDGE_MODEL),
|
||||
};
|
||||
};
|
||||
|
||||
const scoreSchema = z.number().int().min(0).max(100);
|
||||
const judgeCategoriesSchema = z.strictObject({
|
||||
@@ -263,10 +285,12 @@ export async function requestCustomWidgetJudge(args: {
|
||||
testCase: CustomWidgetAiEvaluationCase;
|
||||
widget: HomarrCustomWidgetV2;
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
judgeModel?: string;
|
||||
}) {
|
||||
return callOpenRouter({
|
||||
apiKey: args.apiKey,
|
||||
baseUrl: args.baseUrl,
|
||||
model: args.judgeModel ?? DEFAULT_JUDGE_MODEL,
|
||||
prompt: buildJudgePrompt(args.testCase, args.widget),
|
||||
purpose: "judge",
|
||||
@@ -277,6 +301,7 @@ export async function judgeCustomWidgetCase(args: {
|
||||
testCase: CustomWidgetAiEvaluationCase;
|
||||
widget: HomarrCustomWidgetV2;
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
judgeModel?: string;
|
||||
}) {
|
||||
const raw = await requestCustomWidgetJudge(args);
|
||||
@@ -286,6 +311,7 @@ export async function judgeCustomWidgetCase(args: {
|
||||
export async function evaluateCustomWidgetCase(args: {
|
||||
testCase: CustomWidgetAiEvaluationCase;
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
outputRoot: string;
|
||||
maxLoops: number;
|
||||
generatorModel?: string;
|
||||
@@ -305,6 +331,7 @@ export async function evaluateCustomWidgetCase(args: {
|
||||
try {
|
||||
response = await callOpenRouter({
|
||||
apiKey: args.apiKey,
|
||||
baseUrl: args.baseUrl,
|
||||
model: args.generatorModel ?? DEFAULT_GENERATOR_MODEL,
|
||||
prompt,
|
||||
purpose: "generation",
|
||||
@@ -340,6 +367,7 @@ export async function evaluateCustomWidgetCase(args: {
|
||||
testCase: args.testCase,
|
||||
widget: canonical,
|
||||
apiKey: args.apiKey,
|
||||
baseUrl: args.baseUrl,
|
||||
judgeModel: args.judgeModel,
|
||||
});
|
||||
await writeFile(path.join(caseDirectory, `judge-${attempt}.json`), judgeRaw, "utf8");
|
||||
@@ -413,12 +441,13 @@ async function writeWidgetFiles(directory: string, widget: HomarrCustomWidgetV2,
|
||||
|
||||
async function callOpenRouter(args: {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
purpose: "generation" | "judge";
|
||||
}): Promise<string> {
|
||||
const isJudge = args.purpose === "judge";
|
||||
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
const response = await fetch(getAiProviderChatCompletionsUrl(args.baseUrl), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${args.apiKey}`,
|
||||
@@ -438,8 +467,8 @@ async function callOpenRouter(args: {
|
||||
});
|
||||
const payload = (await response.json()) as OpenRouterResponse;
|
||||
if (!response.ok)
|
||||
throw new Error(`OpenRouter request failed (${response.status}): ${payload.error?.message ?? "Unknown error"}`);
|
||||
throw new Error(`AI provider request failed (${response.status}): ${payload.error?.message ?? "Unknown error"}`);
|
||||
const content = payload.choices?.[0]?.message?.content;
|
||||
if (!content) throw new Error("OpenRouter returned no message content");
|
||||
if (!content) throw new Error("AI provider returned no message content");
|
||||
return content;
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import path from "node:path";
|
||||
import { CUSTOM_WIDGET_AI_EVALUATION_CASES } from "./ai-evaluation-cases";
|
||||
import { evaluateCustomWidgetAssistantCase } from "./ai-assistant-evaluation";
|
||||
import type { CustomWidgetAssistantEvaluationResult } from "./ai-assistant-evaluation";
|
||||
import { DEFAULT_GENERATOR_MODEL, DEFAULT_JUDGE_MODEL, evaluateCustomWidgetCase } from "./ai-evaluation";
|
||||
import { evaluateCustomWidgetCase, resolveAiEvaluationProviderConfig } from "./ai-evaluation";
|
||||
import type { AiEvaluationResult } from "./ai-evaluation";
|
||||
|
||||
const apiKey = process.env.OPENROUTER_API_KEY;
|
||||
if (!apiKey) throw new Error("OPENROUTER_API_KEY is required for the live Custom Widget AI evaluation");
|
||||
const { apiKey, baseUrl: providerBaseUrl, generatorModel, judgeModel } = resolveAiEvaluationProviderConfig(process.env);
|
||||
if (!apiKey) {
|
||||
throw new Error("AI_PROVIDER_API_KEY or OPENROUTER_API_KEY is required for the live Custom Widget AI evaluation");
|
||||
}
|
||||
|
||||
const requestedCase = process.argv.find((value) => value.startsWith("--case="))?.slice("--case=".length);
|
||||
const assistantMode = process.argv.includes("--assistant");
|
||||
@@ -43,18 +45,20 @@ for (const testCase of selectedCases) {
|
||||
? await evaluateCustomWidgetAssistantCase({
|
||||
testCase,
|
||||
apiKey,
|
||||
baseUrl: providerBaseUrl,
|
||||
outputRoot,
|
||||
maxLoops,
|
||||
generatorModel: process.env.OPENROUTER_GENERATOR_MODEL ?? DEFAULT_GENERATOR_MODEL,
|
||||
judgeModel: process.env.OPENROUTER_JUDGE_MODEL ?? DEFAULT_JUDGE_MODEL,
|
||||
generatorModel,
|
||||
judgeModel,
|
||||
})
|
||||
: await evaluateCustomWidgetCase({
|
||||
testCase,
|
||||
apiKey,
|
||||
baseUrl: providerBaseUrl,
|
||||
outputRoot,
|
||||
maxLoops,
|
||||
generatorModel: process.env.OPENROUTER_GENERATOR_MODEL ?? DEFAULT_GENERATOR_MODEL,
|
||||
judgeModel: process.env.OPENROUTER_JUDGE_MODEL ?? DEFAULT_JUDGE_MODEL,
|
||||
generatorModel,
|
||||
judgeModel,
|
||||
});
|
||||
results.push(result);
|
||||
remainingLoopBudget -= result.attempts;
|
||||
@@ -68,8 +72,9 @@ for (const testCase of selectedCases) {
|
||||
const summary = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
mode: assistantMode ? "assistant-tool-loop" : "manifest",
|
||||
generatorModel: process.env.OPENROUTER_GENERATOR_MODEL ?? DEFAULT_GENERATOR_MODEL,
|
||||
judgeModel: process.env.OPENROUTER_JUDGE_MODEL ?? DEFAULT_JUDGE_MODEL,
|
||||
providerBaseUrl,
|
||||
generatorModel,
|
||||
judgeModel,
|
||||
results: results.map((result) => ({
|
||||
caseId: result.caseId,
|
||||
attempts: result.attempts,
|
||||
|
||||
@@ -5,13 +5,16 @@ import {
|
||||
buildEvaluationPrompt,
|
||||
buildJudgePrompt,
|
||||
buildRepairPrompt,
|
||||
DEFAULT_AI_PROVIDER_BASE_URL,
|
||||
DEFAULT_GENERATOR_MODEL,
|
||||
DEFAULT_JUDGE_MODEL,
|
||||
getAiProviderChatCompletionsUrl,
|
||||
getDeterministicEvaluationIssues,
|
||||
getEvaluationResponseFixtureText,
|
||||
getJudgeResponseFormat,
|
||||
judgePasses,
|
||||
parseJudgeResult,
|
||||
resolveAiEvaluationProviderConfig,
|
||||
} from "../../scripts/ai-evaluation";
|
||||
import type { CustomWidgetJudgeResult } from "../../scripts/ai-evaluation";
|
||||
|
||||
@@ -178,6 +181,51 @@ describe("AI authoring evaluation", () => {
|
||||
expect(format.json_schema.schema).toMatchObject({ type: "object", additionalProperties: false });
|
||||
});
|
||||
|
||||
it("can run the same evaluation against the Homarr provider endpoint", () => {
|
||||
expect(getAiProviderChatCompletionsUrl("https://homarr.dev/api/ai/v1/")).toBe(
|
||||
"https://homarr.dev/api/ai/v1/chat/completions",
|
||||
);
|
||||
expect(getAiProviderChatCompletionsUrl()).toBe("https://openrouter.ai/api/v1/chat/completions");
|
||||
expect(
|
||||
resolveAiEvaluationProviderConfig({
|
||||
AI_PROVIDER_BASE_URL: "https://homarr.dev/api/ai/v1/",
|
||||
AI_PROVIDER_API_KEY: "workshop-token",
|
||||
}),
|
||||
).toEqual({
|
||||
apiKey: "workshop-token",
|
||||
baseUrl: "https://homarr.dev/api/ai/v1",
|
||||
generatorModel: "homarr/model",
|
||||
judgeModel: "homarr/model",
|
||||
});
|
||||
expect(
|
||||
resolveAiEvaluationProviderConfig({
|
||||
AI_PROVIDER_BASE_URL: " ",
|
||||
OPENROUTER_API_KEY: "legacy-key",
|
||||
OPENROUTER_GENERATOR_MODEL: "legacy-generator",
|
||||
OPENROUTER_JUDGE_MODEL: "legacy-judge",
|
||||
}),
|
||||
).toEqual({
|
||||
apiKey: "legacy-key",
|
||||
baseUrl: DEFAULT_AI_PROVIDER_BASE_URL,
|
||||
generatorModel: "legacy-generator",
|
||||
judgeModel: "legacy-judge",
|
||||
});
|
||||
expect(
|
||||
resolveAiEvaluationProviderConfig({
|
||||
AI_PROVIDER_BASE_URL: "https://homarr.dev/api/ai/v1",
|
||||
AI_PROVIDER_API_KEY: "workshop-token",
|
||||
OPENROUTER_API_KEY: "must-not-leak",
|
||||
OPENROUTER_GENERATOR_MODEL: "must-not-apply",
|
||||
OPENROUTER_JUDGE_MODEL: "must-not-apply",
|
||||
}),
|
||||
).toEqual({
|
||||
apiKey: "workshop-token",
|
||||
baseUrl: "https://homarr.dev/api/ai/v1",
|
||||
generatorModel: "homarr/model",
|
||||
judgeModel: "homarr/model",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires exceptional goal, design, practicality, and complexity scores", () => {
|
||||
expect(judgePasses(makeJudgeResult(90))).toBe(true);
|
||||
expect(
|
||||
|
||||
@@ -488,6 +488,7 @@ export const assistantConfigurations = mysqlTable("assistant_configuration", {
|
||||
webSearchEnabled: boolean().notNull().default(false),
|
||||
provider: varchar({ length: 32 })
|
||||
.$type<
|
||||
| "homarr"
|
||||
| "openrouter"
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
|
||||
@@ -486,6 +486,7 @@ export const assistantConfigurations = pgTable("assistant_configuration", {
|
||||
webSearchEnabled: boolean().notNull().default(false),
|
||||
provider: varchar({ length: 32 })
|
||||
.$type<
|
||||
| "homarr"
|
||||
| "openrouter"
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
|
||||
@@ -471,6 +471,7 @@ export const assistantConfigurations = sqliteTable("assistant_configuration", {
|
||||
webSearchEnabled: int({ mode: "boolean" }).notNull().default(false),
|
||||
provider: text()
|
||||
.$type<
|
||||
| "homarr"
|
||||
| "openrouter"
|
||||
| "openai"
|
||||
| "anthropic"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const assistantProviderIds = [
|
||||
"homarr",
|
||||
"openrouter",
|
||||
"openai",
|
||||
"anthropic",
|
||||
@@ -15,6 +16,7 @@ export const assistantProviderIds = [
|
||||
|
||||
export type AssistantProvider = (typeof assistantProviderIds)[number];
|
||||
export type AssistantProviderCategory = "hosted" | "local" | "custom";
|
||||
export const assistantHomarrProviderTokenHeader = "X-Homarr-Provider-Token";
|
||||
|
||||
export const assistantReasoningModes = ["auto", "none", "minimal", "low", "medium", "high"] as const;
|
||||
export type AssistantReasoningMode = (typeof assistantReasoningModes)[number];
|
||||
@@ -43,6 +45,15 @@ interface AssistantProviderPreset {
|
||||
}
|
||||
|
||||
export const assistantProviderPresets = {
|
||||
homarr: {
|
||||
// Browser and server code replace this public fallback with WORKSHOP_API_URL at runtime.
|
||||
baseUrl: "https://homarr.dev/api/ai/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
requiresApiKey: false,
|
||||
category: "hosted",
|
||||
discoveryAuthentication: "bearer",
|
||||
iconUrl: "https://homarr.dev/img/logo.png",
|
||||
},
|
||||
openrouter: {
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
modelDiscoveryPath: "/models",
|
||||
@@ -149,5 +160,5 @@ export const assistantProviderRequiresApiKey = (provider: AssistantProvider) =>
|
||||
* OpenRouter and Homarr Router implement OpenRouter server tools directly.
|
||||
* Custom endpoints may opt in when they proxy the OpenRouter request format.
|
||||
*/
|
||||
export const assistantProviderCanUseOpenRouterServerTools = (provider: AssistantProvider | "homarr") =>
|
||||
export const assistantProviderCanUseOpenRouterServerTools = (provider: AssistantProvider) =>
|
||||
provider === "openrouter" || provider === "homarr" || provider === "custom";
|
||||
@@ -2083,6 +2083,29 @@
|
||||
"high": "High"
|
||||
}
|
||||
},
|
||||
"providerQuota": {
|
||||
"title": "Homarr provider",
|
||||
"communityWorkshop": "Community Workshop",
|
||||
"dailyAllowance": "Daily request allowance",
|
||||
"remainingLabel": "{remaining} of {limit} Homarr provider requests remaining",
|
||||
"loading": "Loading Homarr provider allowance",
|
||||
"signInRequired": "Workshop sign-in required for the Homarr provider",
|
||||
"signInDescription": "Sign in with GitHub through the Community Workshop to receive your separate daily allowance.",
|
||||
"exhaustedDescription": "Your Homarr provider allowance is used for today. It resets at midnight UTC.",
|
||||
"unavailableDescription": "Sign in to the Workshop or wait for your Homarr provider allowance to reset.",
|
||||
"signIn": "Sign in with GitHub",
|
||||
"signInError": "GitHub sign-in failed. Try again in a moment.",
|
||||
"loadError": "The Homarr provider allowance could not be loaded.",
|
||||
"reset": "Resets {relative} at <time></time>",
|
||||
"toolCalls": "Each model request and completed tool call uses one request.",
|
||||
"retry": "Reload allowance",
|
||||
"level": {
|
||||
"ok": "OK",
|
||||
"warning": "Warning",
|
||||
"bad": "Low",
|
||||
"dead": "Used"
|
||||
}
|
||||
},
|
||||
"stop": "Stop response",
|
||||
"send": "Send message",
|
||||
"copy": "Copy response",
|
||||
@@ -6176,7 +6199,8 @@
|
||||
"title": "Credentials",
|
||||
"description": "Add the secrets this endpoint needs. Stored values stay encrypted and cannot be read back.",
|
||||
"required": "Key required",
|
||||
"optional": "Key optional"
|
||||
"optional": "Key optional",
|
||||
"workshop": "Per-user GitHub sign-in"
|
||||
},
|
||||
"provider": {
|
||||
"title": "Provider",
|
||||
@@ -6187,7 +6211,8 @@
|
||||
},
|
||||
"options": {
|
||||
"homarr": {
|
||||
"label": "Homarr"
|
||||
"label": "Homarr",
|
||||
"description": "Community-funded access to the model selected by the Homarr team. Each user signs in with the Community Workshop and receives a daily allowance."
|
||||
},
|
||||
"openrouter": {
|
||||
"label": "OpenRouter",
|
||||
@@ -6238,7 +6263,10 @@
|
||||
"description": "Any other endpoint compatible with OpenAI Chat Completions."
|
||||
}
|
||||
},
|
||||
"comingSoon": "Coming soon"
|
||||
"homarrAuth": {
|
||||
"title": "No administrator API key required",
|
||||
"description": "Homarr keeps the upstream OpenRouter credential on the Community Workshop server. Each user authorizes requests with their own Workshop GitHub session, so their daily allowance stays separate."
|
||||
}
|
||||
},
|
||||
"baseUrl": {
|
||||
"title": "API base URL",
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
|
||||
import type {
|
||||
WorkshopComment,
|
||||
WorkshopAssistantUsage,
|
||||
WorkshopReport,
|
||||
WorkshopReportSummary,
|
||||
WorkshopSubmissionDetail,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
WORKSHOP_REQUEST_TIMEOUT_MS,
|
||||
WORKSHOP_SCHEMA_BY_TYPE,
|
||||
workshopCommentSchema,
|
||||
workshopAssistantUsageSchema,
|
||||
workshopReportSchema,
|
||||
workshopReportSummarySchema,
|
||||
workshopSubmissionDetailSchema,
|
||||
@@ -271,13 +273,19 @@ export class WorkshopBackend {
|
||||
}
|
||||
|
||||
public get currentUser(): WorkshopUser | null {
|
||||
if (!this.pocketBase.authStore.isValid) return null;
|
||||
const record = this.pocketBase.authStore.record;
|
||||
if (!record) return null;
|
||||
return workshopUserSchema.parse({
|
||||
const parsed = workshopUserSchema.safeParse({
|
||||
id: record.id,
|
||||
name: record.name || "",
|
||||
isAdmin: record.isAdmin === true,
|
||||
});
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
public get authToken(): string | null {
|
||||
return this.pocketBase.authStore.isValid && this.currentUser ? this.pocketBase.authStore.token : null;
|
||||
}
|
||||
|
||||
public subscribeToAuth(listener: (user: WorkshopUser | null) => void) {
|
||||
@@ -285,7 +293,10 @@ export class WorkshopBackend {
|
||||
}
|
||||
|
||||
public async refreshAuth() {
|
||||
if (!this.pocketBase.authStore.isValid) return null;
|
||||
if (!this.pocketBase.authStore.isValid) {
|
||||
this.pocketBase.authStore.clear();
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await this.pocketBase.collection("users").authRefresh();
|
||||
return this.currentUser;
|
||||
@@ -304,7 +315,9 @@ export class WorkshopBackend {
|
||||
provider: "github",
|
||||
});
|
||||
this.pocketBase.authStore.save(auth.token, auth.record);
|
||||
return this.currentUser;
|
||||
const user = this.currentUser;
|
||||
if (!user) throw new Error("The Workshop returned an invalid user session.");
|
||||
return user;
|
||||
} catch (error) {
|
||||
throw workshopError(error, "GitHub sign-in failed");
|
||||
}
|
||||
@@ -314,6 +327,19 @@ export class WorkshopBackend {
|
||||
this.pocketBase.authStore.clear();
|
||||
}
|
||||
|
||||
public async getAssistantUsage(signal?: AbortSignal): Promise<WorkshopAssistantUsage> {
|
||||
try {
|
||||
const value = await this.pocketBase.send("/api/ai/usage", {
|
||||
method: "GET",
|
||||
signal: requestSignal(signal),
|
||||
requestKey: null,
|
||||
});
|
||||
return workshopAssistantUsageSchema.parse(value);
|
||||
} catch (error) {
|
||||
throw workshopError(error, "Failed to load the Homarr provider allowance");
|
||||
}
|
||||
}
|
||||
|
||||
public async list(options: WorkshopListOptions = {}): Promise<WorkshopPage<WorkshopSubmissionSummary>> {
|
||||
try {
|
||||
const page = options.page ?? 1;
|
||||
|
||||
@@ -6,17 +6,31 @@ const mocks = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
getFullList: vi.fn(),
|
||||
getList: vi.fn(),
|
||||
send: vi.fn(),
|
||||
update: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("pocketbase", () => {
|
||||
class MockPocketBase {
|
||||
public readonly authStore = {
|
||||
token: "",
|
||||
record: null as Record<string, unknown> | null,
|
||||
isValid: false,
|
||||
save: (_token: string, record: Record<string, unknown>) => {
|
||||
get isValid() {
|
||||
try {
|
||||
const payload = JSON.parse(atob(this.token.split(".")[1] ?? "")) as { exp?: unknown };
|
||||
return typeof payload.exp === "number" && payload.exp > Date.now() / 1000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
save: (token: string, record: Record<string, unknown>) => {
|
||||
this.authStore.token = token;
|
||||
this.authStore.record = record;
|
||||
},
|
||||
clear: () => {
|
||||
this.authStore.token = "";
|
||||
this.authStore.record = null;
|
||||
},
|
||||
};
|
||||
|
||||
public collection() {
|
||||
@@ -28,6 +42,10 @@ vi.mock("pocketbase", () => {
|
||||
return this;
|
||||
}
|
||||
|
||||
public send(path: string, options: unknown) {
|
||||
return mocks.send(path, options);
|
||||
}
|
||||
|
||||
public filter(template: string, values: Record<string, string>) {
|
||||
return template.replaceAll(/\{:(\w+)\}/gu, (_match, key: string) => `'${values[key]}'`);
|
||||
}
|
||||
@@ -50,6 +68,8 @@ import { WorkshopBackend } from "./backend";
|
||||
import type { WorkshopSubmissionRecord } from "./backend";
|
||||
import * as workshopClient from "./client";
|
||||
|
||||
const validWorkshopToken = "header.eyJleHAiOjQxMDI0NDQ4MDB9.signature";
|
||||
|
||||
describe("WorkshopBackend", () => {
|
||||
test("resolves the advertised client module", () => {
|
||||
expect(workshopClient.WorkshopBackend).toBe(WorkshopBackend);
|
||||
@@ -62,12 +82,13 @@ describe("WorkshopBackend", () => {
|
||||
mocks.create.mockReset();
|
||||
mocks.getFullList.mockReset();
|
||||
mocks.getList.mockReset();
|
||||
mocks.send.mockReset();
|
||||
mocks.update.mockReset();
|
||||
});
|
||||
|
||||
test("lets PocketBase own the complete GitHub popup flow", async () => {
|
||||
mocks.authWithOAuth2.mockResolvedValue({
|
||||
token: "token",
|
||||
token: validWorkshopToken,
|
||||
record: {
|
||||
id: "user-id",
|
||||
name: "octocat",
|
||||
@@ -91,9 +112,42 @@ describe("WorkshopBackend", () => {
|
||||
expect(mocks.autoCancellation).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("reuses a valid Workshop session for the Homarr provider allowance", async () => {
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
expect(client.authToken).toBeNull();
|
||||
client.pocketBase.authStore.save(validWorkshopToken, { id: "author-id", name: "octocat" } as never);
|
||||
mocks.send.mockResolvedValue({
|
||||
limit: 50,
|
||||
used: 7,
|
||||
remaining: 43,
|
||||
resetsAt: "2026-08-12T00:00:00Z",
|
||||
});
|
||||
|
||||
await expect(client.getAssistantUsage()).resolves.toMatchObject({ limit: 50, remaining: 43 });
|
||||
expect(client.authToken).toBe(validWorkshopToken);
|
||||
expect(mocks.send).toHaveBeenCalledWith(
|
||||
"/api/ai/usage",
|
||||
expect.objectContaining({ method: "GET", requestKey: null, signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
});
|
||||
|
||||
test("fails closed for expired tokens and malformed Workshop user records", () => {
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
client.pocketBase.authStore.save("header.eyJleHAiOjF9.signature", {
|
||||
id: "author-id",
|
||||
name: "octocat",
|
||||
} as never);
|
||||
expect(client.currentUser).toBeNull();
|
||||
expect(client.authToken).toBeNull();
|
||||
|
||||
client.pocketBase.authStore.save(validWorkshopToken, { id: 42 } as never);
|
||||
expect(client.currentUser).toBeNull();
|
||||
expect(client.authToken).toBeNull();
|
||||
});
|
||||
|
||||
test("sends the exported schema while leaving initial revision ownership to PocketBase", async () => {
|
||||
const client = new WorkshopBackend("https://workshop.example.com");
|
||||
client.pocketBase.authStore.save("token", { id: "author-id" } as never);
|
||||
client.pocketBase.authStore.save(validWorkshopToken, { id: "author-id" } as never);
|
||||
mocks.create.mockResolvedValue({ id: "submission-id" });
|
||||
vi.spyOn(client, "get").mockResolvedValue(listingRecord() as never);
|
||||
|
||||
|
||||
@@ -102,6 +102,14 @@ export const workshopUserSchema = z.object({
|
||||
});
|
||||
export type WorkshopUser = z.infer<typeof workshopUserSchema>;
|
||||
|
||||
export const workshopAssistantUsageSchema = z.object({
|
||||
limit: z.number().int().nonnegative(),
|
||||
used: z.number().int().nonnegative(),
|
||||
remaining: z.number().int().nonnegative(),
|
||||
resetsAt: z.iso.datetime({ offset: true }),
|
||||
});
|
||||
export type WorkshopAssistantUsage = z.infer<typeof workshopAssistantUsageSchema>;
|
||||
|
||||
const normalizeWorkshopSubmissionRecord = (value: unknown) => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return value;
|
||||
const record = value as Record<string, unknown>;
|
||||
|
||||
Generated
-11
@@ -885,9 +885,6 @@ importers:
|
||||
prism-react-renderer:
|
||||
specifier: ^2.4.1
|
||||
version: 2.4.1(react@19.2.7)
|
||||
random-word-slugs:
|
||||
specifier: ^0.1.7
|
||||
version: 0.1.7
|
||||
react:
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.7
|
||||
@@ -1198,9 +1195,6 @@ importers:
|
||||
jszip:
|
||||
specifier: 'catalog:'
|
||||
version: 3.10.1
|
||||
lucide-react:
|
||||
specifier: 'catalog:'
|
||||
version: 1.24.0(react@19.2.7)
|
||||
mantine-datatable:
|
||||
specifier: 'catalog:'
|
||||
version: 9.3.1(@mantine/core@9.4.1(@mantine/hooks@9.4.1(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(@mantine/hooks@9.4.1(react@19.2.7))(clsx@2.1.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -14878,9 +14872,6 @@ packages:
|
||||
radix3@1.1.2:
|
||||
resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==}
|
||||
|
||||
random-word-slugs@0.1.7:
|
||||
resolution: {integrity: sha512-8cyzxOIDeLFvwSPTgCItMXHGT5ZPkjhuFKUTww06Xg1dNMXuGxIKlARvS7upk6JXIm41ZKXmtlKR1iCRWklKmg==}
|
||||
|
||||
randombytes@2.1.0:
|
||||
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
|
||||
|
||||
@@ -30730,8 +30721,6 @@ snapshots:
|
||||
|
||||
radix3@1.1.2: {}
|
||||
|
||||
random-word-slugs@0.1.7: {}
|
||||
|
||||
randombytes@2.1.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
Reference in new issue
Block a user