fix: harden assistant authoring and UI
This commit is contained in:
1 parent
fa0e55cc93
commit
edc7b4d55b
51 files changed
+4118
-916
No files matched your search
@@ -33,7 +33,9 @@ on a dashboard. By default it follows the current conversation. Widget settings
|
||||
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.
|
||||
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.
|
||||
|
||||
When the assistant is unavailable, its command-menu result remains visible but disabled and explains that
|
||||
an administrator must configure and enable it.
|
||||
@@ -121,13 +123,19 @@ 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.
|
||||
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 or dropping files onto the composer. A request can contain up to five attachments.
|
||||
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.
|
||||
|
||||
@@ -143,24 +151,30 @@ their description, context size and image-input capability, plus the thinking-le
|
||||
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. The **Thinking** disclosure can be opened or closed without interrupting the response.
|
||||
streams it through assistant-ui's compact chain-of-thought disclosure. The **Thinking** disclosure renders
|
||||
Markdown, can be opened or closed without interrupting the response, and keeps the user's collapsed state when
|
||||
another message starts.
|
||||
|
||||
Each completed response includes a circular context-window meter. Select it to open request details when
|
||||
the provider reports them: input, output, cached-input and reasoning tokens, used and remaining context,
|
||||
time to first output, total duration, output speed, agent-step timing, and cost. OpenRouter-reported cost
|
||||
is labeled as reported.
|
||||
While the next response is still streaming, the meter keeps the last completed reading for the same model and
|
||||
context capacity instead of briefly showing an incorrect zero. It switches to the new request when token usage
|
||||
arrives; incompatible model capacities are never mixed.
|
||||
OpenRouter responses request inline usage and routing metadata. When request details are opened, Homarr also
|
||||
loads the server-side generation record for each model step and briefly polls while OpenRouter finishes
|
||||
processing it. The details separate Homarr's end-to-end timing from OpenRouter's provider latency,
|
||||
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.
|
||||
A compact context control beside the model selector gives a quick glance at cumulative conversation tokens and
|
||||
cost. Its popover keeps the latest complete context reading while 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. Closing and reopening the popover reads persisted
|
||||
conversation metadata instead of resetting the display to zero.
|
||||
|
||||
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.
|
||||
|
||||
@@ -193,7 +207,12 @@ Custom-widget tools let an administrator list, inspect, create, and update defin
|
||||
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.
|
||||
provided. Before authoring, the assistant lazily loads the complete installed Custom Widget skill, including
|
||||
all bundled schema, runtime, and security references. Multiline JSX uses `templateLines` to avoid fragile streamed
|
||||
JSON escaping. The assistant validates the candidate, creates a preview, executes every returned preview query,
|
||||
checks the actual HTTP status and response shape, and iterates before saving. 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 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,
|
||||
@@ -207,8 +226,9 @@ When the assistant genuinely needs missing information or a choice, it can pause
|
||||
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 answered choice
|
||||
then remains in the conversation history.
|
||||
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
|
||||
|
||||
@@ -17,7 +17,9 @@ Install the Agent Skill from the verified Homarr repository:
|
||||
npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget
|
||||
```
|
||||
|
||||
The portable skill gives concise authoring guidance. The Homarr MCP prompt gives connected agents a retrieval workflow: read the live schema, then fetch only the component documentation needed for the widget.
|
||||
The portable skill gives concise authoring guidance. For connected agents, Homarr returns the complete `SKILL.md` and
|
||||
all bundled skill references once in one lazy tool response. The Homarr MCP prompt then gives the agent a
|
||||
retrieval workflow: read the live schema and fetch only the component documentation needed for the widget.
|
||||
|
||||
The workbench's **Copy AI prompt** action is separate. It creates a self-contained prompt below 12,000 characters for an ordinary ChatGPT or Claude conversation. It includes the lean contract, bindings, recommended components, and two polished examples. It never tells an offline model to call MCP tools.
|
||||
|
||||
@@ -37,38 +39,55 @@ Repository code cannot give this attribution.
|
||||
|
||||
The authoring-prompt tool description contains one sentence. The prompt contains the workflow instructions and resource links.
|
||||
|
||||
`GET /api/custom-widgets/skill` and the MCP tool return the concise skill discovery payload:
|
||||
`GET /api/custom-widgets/skill` and `customWidget_getSkill` return a self-contained structured skill payload:
|
||||
|
||||
```ts
|
||||
{
|
||||
name: "homarr-custom-widget";
|
||||
version: string;
|
||||
skillMd: string;
|
||||
skillMd: string; // Complete SKILL.md
|
||||
references: Record<string, string>;
|
||||
skillsShUrl: string;
|
||||
sourceUrl: string;
|
||||
installCommand: string;
|
||||
}
|
||||
```
|
||||
|
||||
The full installed component catalog stays available through `homarr://custom-widgets/components` and per-component resources. It is deliberately not embedded in the skill or clipboard prompt.
|
||||
The `homarr://custom-widgets/skill` MCP resource returns the same `SKILL.md` and references once as bundled Markdown.
|
||||
|
||||
The component catalog is a compact index of names, categories, safety levels, shared prop names, policies, and example
|
||||
IDs. Per-component results contain only component-specific props and a reference to shared prop documentation. Use
|
||||
`customWidget_getSharedProps` once with only the shared prop names needed across the selected components. This avoids
|
||||
repeating hundreds of global props in every tool result. The catalog is deliberately not embedded in the skill or
|
||||
clipboard prompt; the skill response still contains the complete authoring, schema, runtime, and security references.
|
||||
|
||||
These interfaces do not return an archive URL or archive hash.
|
||||
|
||||
## Create a Custom Widget with MCP
|
||||
|
||||
1. Get the prompt, live schema, and component catalog.
|
||||
2. Read the external API documentation.
|
||||
3. Build one definition.
|
||||
4. Validate the definition without saving it.
|
||||
5. Create a preview session.
|
||||
6. Test the queries and actions.
|
||||
7. Open the preview URL with browser tools.
|
||||
8. Inspect the redacted journal and revise the local candidate until it is valid.
|
||||
9. Create the widget, including any source URLs and credentials the user already supplied.
|
||||
10. Otherwise, ask the user to configure the saved definition with the ten-minute Homarr source-setup URL.
|
||||
11. Create a final preview with the saved definition ID so Homarr loads its stored credentials, then test it again.
|
||||
12. Update the saved definition if the final verification finds a problem.
|
||||
13. Repeat the procedure for the next widget.
|
||||
1. Load `customWidget_getSkill`, then get the prompt and live schema.
|
||||
2. Call `customWidget_getComponentCatalog`, followed by `customWidget_getComponent` for only the relevant installed
|
||||
components and `customWidget_getExample` when one catalog example matches the interaction pattern. If shared props
|
||||
are needed, collect their catalog names and call `customWidget_getSharedProps` once for that set.
|
||||
3. Read the external API documentation.
|
||||
4. Build one definition, using `templateLines` for multiline JSX tool input.
|
||||
5. Validate the definition without saving it and repair every reported issue.
|
||||
6. Create a preview session.
|
||||
7. Run `customWidget_previewQuery` for every query returned by the preview and inspect its actual status and data shape.
|
||||
8. Test relevant actions in simulation mode.
|
||||
9. Open the returned preview path with browser tools.
|
||||
10. Inspect the redacted journal and revise the local candidate until validation, data, and rendering all work.
|
||||
11. Create the widget, including any source URLs and credentials the user already supplied.
|
||||
12. Open its returned management path, and use a structured question when the user must choose whether to place it on a board.
|
||||
13. Otherwise, ask the user to configure the saved definition with the ten-minute Homarr source-setup URL.
|
||||
14. Create a final preview with the saved definition ID so Homarr loads its stored credentials, then test it again.
|
||||
15. Update the saved definition if the final verification finds a problem. `customWidget_update` also accepts
|
||||
`templateLines` for multiline JSX without resetting omitted options.
|
||||
16. Repeat the procedure for the next widget.
|
||||
|
||||
Successful validation returns a compact summary instead of echoing the full widget and template. Preview creation
|
||||
returns its session, links, and query checklist without repeating the submitted definition. Keep the candidate locally
|
||||
and reuse it for the next lifecycle step.
|
||||
|
||||
Each source-configuration URL applies to one definition or preview and one source. It is short-lived, single-use, and
|
||||
can only be opened and completed by an authenticated Homarr administrator. The administrator confirms the server URL,
|
||||
|
||||
@@ -195,21 +195,36 @@ An agent creates one `homarr-custom-widget-v2` definition at a time. Start with
|
||||
The API key owner must be a Homarr administrator. The same rule applies to management, import, migration, preview,
|
||||
secrets, publication, Workshop installation, and the HTTP authoring resources.
|
||||
|
||||
Then, read the live schema and component resources. These resources describe the installed Mantine components.
|
||||
Load the installed skill before authoring. `customWidget_getSkill` returns the complete `SKILL.md` entrypoint and every
|
||||
bundled reference in one response, so an MCP client does not need filesystem access or a second documentation fetch.
|
||||
Then read the live schema and only the component resources required by the design. These resources describe the
|
||||
installed Mantine components.
|
||||
|
||||
1. Call `customWidget_getAuthoringPrompt` and `customWidget_schema`.
|
||||
2. Build one Custom Widget definition.
|
||||
3. Call `customWidget_validate` without saving the definition.
|
||||
4. Call `customWidget_previewCreate`.
|
||||
5. Test named requests with `customWidget_previewQuery` and `customWidget_previewAction`.
|
||||
6. Open the returned preview URL.
|
||||
7. Examine `customWidget_previewJournal`.
|
||||
8. Revise the local candidate and create another preview session until it is ready.
|
||||
9. Call `customWidget_create`, supplying any credentials and deployment-specific source URLs the user already provided.
|
||||
10. If the user must enter the source URL or credentials, call `customWidget_configurationRequestUser` with the new definition ID and wait for completion.
|
||||
11. Create a final preview with that definition ID so Homarr loads its stored credentials, then test it again.
|
||||
12. Use `customWidget_update` or `customWidget_templatePatch` for any final saved-definition corrections.
|
||||
13. Repeat this procedure for the next Custom Widget.
|
||||
1. Call `customWidget_getSkill`, `customWidget_getAuthoringPrompt`, `customWidget_schema`, and
|
||||
`customWidget_getComponentCatalog`.
|
||||
2. Fetch only the relevant installed component documentation with `customWidget_getComponent` and, when useful,
|
||||
one interaction example with `customWidget_getExample`. Component results contain component-specific props; collect
|
||||
any needed shared prop names from the compact catalog and fetch them together once with `customWidget_getSharedProps`.
|
||||
3. Build one Custom Widget definition. Use `templateLines` for generated multiline JSX to avoid a fragile JSON-escaped
|
||||
template string.
|
||||
4. Call `customWidget_validate` without saving the definition and repair every returned issue path.
|
||||
5. Call `customWidget_previewCreate`.
|
||||
6. Call `customWidget_previewQuery` for every entry in the returned `queries` list. Supply the listed invocation
|
||||
parameters for manual queries and inspect the real HTTP status and response shape before binding template fields.
|
||||
7. Test relevant actions with `customWidget_previewAction` in simulation mode.
|
||||
8. Open the returned `previewPath` or `previewUrl`.
|
||||
9. Examine `customWidget_previewJournal`.
|
||||
10. Revise the local candidate and create another preview session until validation, query data, actions, and rendering
|
||||
are ready.
|
||||
11. Call `customWidget_create`, supplying any credentials and deployment-specific source URLs the user already provided.
|
||||
12. Open the returned `managementPath` to review the exact saved definition.
|
||||
13. If the user must enter the source URL or credentials, call `customWidget_configurationRequestUser` with the new definition ID and wait for completion.
|
||||
14. Create a final preview with that definition ID so Homarr loads its stored credentials, then test it again.
|
||||
15. Use `customWidget_update` or `customWidget_templatePatch` for any final saved-definition corrections.
|
||||
16. Repeat this procedure for the next Custom Widget.
|
||||
|
||||
Validation and preview creation return compact lifecycle metadata. They do not echo the submitted widget or duplicate
|
||||
its template as both a string and line array; the agent should retain and reuse its local candidate.
|
||||
|
||||
The agent receives the configuration-request status. The agent does not receive plaintext credentials. A preview-scoped setup URL configures only that temporary preview; use a definition-scoped URL when the values must persist.
|
||||
|
||||
@@ -245,6 +260,10 @@ The Custom Widget tools are:
|
||||
- `customWidget_workshopGet`
|
||||
- `customWidget_workshopInstall`
|
||||
- `customWidget_getAuthoringPrompt`
|
||||
- `customWidget_getComponentCatalog`
|
||||
- `customWidget_getComponent`
|
||||
- `customWidget_getSharedProps`
|
||||
- `customWidget_getExample`
|
||||
- `customWidget_getSkill`
|
||||
|
||||
Custom Widget permissions are not assignable group capabilities. Administrator status is required for the complete
|
||||
|
||||
@@ -41,17 +41,19 @@ active conversation silently: select **Open conversation** inside the widget bef
|
||||
### Conversation features
|
||||
|
||||
- Create, switch, rename, and delete conversations.
|
||||
- Send text and supported file or image attachments.
|
||||
- Send text and supported file or image attachments by picker, drag and drop, or image paste.
|
||||
- Mention permitted apps, integrations, boards, widgets, and tools with `@`.
|
||||
- Use `/` shortcuts for common dashboard tasks.
|
||||
- Review tool calls, approve mutations, and answer structured assistant questions inline.
|
||||
- Answer pending structured questions from the dock above the composer without scrolling through the conversation.
|
||||
- Enable conversation-scoped automatic approvals from the shield control to approve protected mutations, use presented form details, and choose answers categorized as affirmative without individual clicks.
|
||||
- Edit user messages and copy, regenerate, branch, quote, export, or rate responses.
|
||||
- Inspect Markdown, sources, generated files, tool results, reasoning, token use, context-window use, timing,
|
||||
speed, provider routing, and cost.
|
||||
- Inspect syntax-highlighted Markdown, sources, generated files, tool results, streaming chain-of-thought,
|
||||
cumulative conversation usage, per-turn token and context use, timing, speed, provider routing, and cost.
|
||||
- Change the model and reasoning effort for the active conversation.
|
||||
- Refresh the current board from the conversation header. Approved mutations also refresh visible board data automatically.
|
||||
|
||||
The expanded assistant and board widget use the same persisted thread runtime. A response continues in the
|
||||
background when the widget leaves the viewport or the expanded panel is minimized.
|
||||
background when the widget leaves the viewport or the expanded panel is minimized. Each visible surface keeps an
|
||||
independent draft, attachments, selection, and focus, so typing in a board widget cannot move or overwrite the
|
||||
floating panel composer.
|
||||
@@ -107,6 +107,7 @@
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"react-error-boundary": "catalog:",
|
||||
"react-shiki": "catalog:",
|
||||
"react-simple-code-editor": "catalog:",
|
||||
"remark-breaks": "catalog:",
|
||||
"remark-directive": "catalog:",
|
||||
|
||||
@@ -3,6 +3,12 @@ import { describe, expect, test } from "vitest";
|
||||
import { getAssistantStreamErrorMessage } from "./assistant-stream-error";
|
||||
|
||||
describe("assistant stream errors", () => {
|
||||
test("explains malformed streamed tool input without implying the action ran", () => {
|
||||
expect(getAssistantStreamErrorMessage(new Error("Error in input stream"))).toBe(
|
||||
"The model produced incomplete tool input, so Homarr did not run the action. Try again; multiline custom-widget JSX will be sent as templateLines.",
|
||||
);
|
||||
});
|
||||
|
||||
test("explains invalid provider model identifiers", () => {
|
||||
expect(
|
||||
getAssistantStreamErrorMessage({
|
||||
|
||||
@@ -29,6 +29,9 @@ export const getAssistantStreamErrorMessage = (error: unknown) => {
|
||||
const statusCode = getStatusCode(error);
|
||||
const message = getErrorMessage(error);
|
||||
|
||||
if (/AI_InvalidToolInputError|Invalid input for tool|\bError in input stream\b/iu.test(message)) {
|
||||
return "The model produced incomplete tool input, so Homarr did not run the action. Try again; multiline custom-widget JSX will be sent as templateLines.";
|
||||
}
|
||||
if (
|
||||
/\bmodel(?:\s+id)?\b/iu.test(message) &&
|
||||
/\b(invalid|unknown|unavailable|not found|not a valid)\b/iu.test(message)
|
||||
|
||||
@@ -27,4 +27,77 @@ describe("getSafeAssistantToolError", () => {
|
||||
"The Homarr tool could not complete this request.",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves safe custom-widget validation paths and messages", () => {
|
||||
const cause = Object.assign(new Error("validation failed"), {
|
||||
issues: [
|
||||
{ path: ["requests", "fixtures", "path"], message: "Required" },
|
||||
{ path: ["template"], message: "Use Image instead of img (line 8, column 3)" },
|
||||
],
|
||||
});
|
||||
const error = new TRPCError({ code: "BAD_REQUEST", cause });
|
||||
|
||||
expect(getSafeAssistantToolError(error, { toolName: "customWidget_create" })).toBe(
|
||||
"The custom widget input was invalid: requests.fixtures.path: Required; template: Use Image instead of img (line 8, column 3)",
|
||||
);
|
||||
});
|
||||
|
||||
test("turns joined templateLines parser failures into actionable assistant and MCP text", () => {
|
||||
const cause = Object.assign(new Error("validation failed"), {
|
||||
issues: [{ path: ["template"], message: "Element 'img' is not supported; use Image instead" }],
|
||||
});
|
||||
const error = new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "The custom widget authoring input is invalid",
|
||||
cause,
|
||||
});
|
||||
|
||||
expect(getSafeAssistantToolError(error, { toolName: "customWidget_previewCreate" })).toBe(
|
||||
"The custom widget input was invalid: template: Element 'img' is not supported; use Image instead",
|
||||
);
|
||||
});
|
||||
|
||||
test("does not expose custom-widget credentials in validation details", () => {
|
||||
const error = new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "headers.Authorization: Bearer sk-secret-123456 at https://private.example/api",
|
||||
});
|
||||
|
||||
const result = getSafeAssistantToolError(error, { toolName: "customWidget_validate" });
|
||||
expect(result).toContain("[REDACTED]");
|
||||
expect(result).toContain("[URL]");
|
||||
expect(result).not.toContain("sk-secret-123456");
|
||||
expect(result).not.toContain("private.example");
|
||||
});
|
||||
|
||||
test("keeps non-custom-widget bad requests generic", () => {
|
||||
expect(
|
||||
getSafeAssistantToolError(new TRPCError({ code: "BAD_REQUEST", message: "sensitive validation" }), {
|
||||
toolName: "integration_create",
|
||||
}),
|
||||
).toBe("The tool input was not valid.");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["BAD_GATEWAY", "data source could not complete", "External request failed for https://private.example/api"],
|
||||
["PAYLOAD_TOO_LARGE", "exceeded a safety limit", "Response exceeds the 1 MiB limit"],
|
||||
["CONFLICT", "changed while this request was running", "Preview session changed; retry"],
|
||||
] as const)("keeps custom-widget %s preview failures actionable and redacted", (code, expected, message) => {
|
||||
const error = new TRPCError({ code, message });
|
||||
|
||||
const result = getSafeAssistantToolError(error, { toolName: "customWidget_previewQuery" });
|
||||
|
||||
expect(result).toContain(expected);
|
||||
expect(result).not.toContain("private.example");
|
||||
});
|
||||
|
||||
test("keeps unrelated gateway, payload, and conflict failures generic", () => {
|
||||
for (const code of ["BAD_GATEWAY", "PAYLOAD_TOO_LARGE", "CONFLICT"] as const) {
|
||||
expect(
|
||||
getSafeAssistantToolError(new TRPCError({ code, message: "private upstream detail" }), {
|
||||
toolName: "integration_create",
|
||||
}),
|
||||
).toBe("The Homarr tool could not complete this request.");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -11,8 +11,48 @@ const getErrorChain = (error: unknown) => {
|
||||
return chain;
|
||||
};
|
||||
|
||||
export const getSafeAssistantToolError = (error: unknown) => {
|
||||
const sanitizeValidationText = (value: string) =>
|
||||
value
|
||||
.replace(/https?:\/\/\S+/giu, "[URL]")
|
||||
.replace(/\b(Bearer|Basic)\s+\S+/giu, "$1 [REDACTED]")
|
||||
.replace(/\b(api[_ -]?key|authorization|password|token)\s*[:=]\s*\S+/giu, "$1=[REDACTED]")
|
||||
.replace(/\s+/gu, " ")
|
||||
.trim()
|
||||
.slice(0, 240);
|
||||
|
||||
const getCustomWidgetValidationDetails = (chain: Record<string, unknown>[]) => {
|
||||
const details = chain
|
||||
.flatMap((candidate) => (Array.isArray(candidate.issues) ? candidate.issues : []))
|
||||
.flatMap((issue) => {
|
||||
const record = asErrorRecord(issue);
|
||||
if (!record || typeof record.message !== "string") return [];
|
||||
const path = Array.isArray(record.path)
|
||||
? record.path
|
||||
.filter((segment): segment is string | number => typeof segment === "string" || typeof segment === "number")
|
||||
.map(String)
|
||||
.join(".")
|
||||
: "";
|
||||
const message = sanitizeValidationText(record.message);
|
||||
return message ? [`${path || "widget"}: ${message}`] : [];
|
||||
})
|
||||
.slice(0, 6);
|
||||
if (details.length > 0) return details.join("; ");
|
||||
|
||||
const safeMessage = chain
|
||||
.map((candidate) => (typeof candidate.message === "string" ? sanitizeValidationText(candidate.message) : ""))
|
||||
.find(
|
||||
(message) =>
|
||||
message.length > 0 &&
|
||||
!["BAD_REQUEST", "Invalid input", "Invalid request parameters"].includes(message) &&
|
||||
!message.startsWith("[") &&
|
||||
!message.startsWith("{"),
|
||||
);
|
||||
return safeMessage;
|
||||
};
|
||||
|
||||
export const getSafeAssistantToolError = (error: unknown, options?: { toolName?: string }) => {
|
||||
const chain = getErrorChain(error);
|
||||
const isCustomWidgetTool = options?.toolName?.startsWith("customWidget_") === true;
|
||||
if (
|
||||
chain.some(
|
||||
(candidate) =>
|
||||
@@ -31,7 +71,35 @@ export const getSafeAssistantToolError = (error: unknown) => {
|
||||
case "NOT_FOUND":
|
||||
return "The requested resource was not found or is not compatible with this tool.";
|
||||
case "BAD_REQUEST":
|
||||
if (isCustomWidgetTool) {
|
||||
const details = getCustomWidgetValidationDetails(chain);
|
||||
if (details) return `The custom widget input was invalid: ${details}`;
|
||||
}
|
||||
return "The tool input was not valid.";
|
||||
case "BAD_GATEWAY":
|
||||
if (isCustomWidgetTool) {
|
||||
const details = getCustomWidgetValidationDetails(chain);
|
||||
return details
|
||||
? `The custom widget data source could not complete the preview request: ${details}`
|
||||
: "The custom widget data source could not complete the preview request.";
|
||||
}
|
||||
break;
|
||||
case "PAYLOAD_TOO_LARGE":
|
||||
if (isCustomWidgetTool) {
|
||||
const details = getCustomWidgetValidationDetails(chain);
|
||||
return details
|
||||
? `The custom widget preview exceeded a safety limit: ${details}`
|
||||
: "The custom widget preview exceeded a response or request size safety limit.";
|
||||
}
|
||||
break;
|
||||
case "CONFLICT":
|
||||
if (isCustomWidgetTool) {
|
||||
const details = getCustomWidgetValidationDetails(chain);
|
||||
return details
|
||||
? `The custom widget preview changed while this request was running: ${details}`
|
||||
: "The custom widget preview changed while this request was running. Retry the preview step.";
|
||||
}
|
||||
break;
|
||||
case "PRECONDITION_FAILED":
|
||||
return "The requested resource is not ready for this operation. Check its configuration and try again.";
|
||||
case "TOO_MANY_REQUESTS":
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { repairCustomWidgetToolInput } from "./assistant-tool-input-repair";
|
||||
|
||||
describe("repairCustomWidgetToolInput", () => {
|
||||
test("repairs literal newlines generated inside a multiline Custom JSX template", () => {
|
||||
const input = `{"definition":{"template":"<Stack>
|
||||
<Text>{data.fixtures?.length} fixtures</Text>
|
||||
</Stack>"}}`;
|
||||
const repaired = repairCustomWidgetToolInput({ toolName: "customWidget_previewCreate", input });
|
||||
|
||||
expect(repaired).not.toBeNull();
|
||||
expect(JSON.parse(repaired?.input ?? "")).toEqual({
|
||||
definition: { template: "<Stack>\n <Text>{data.fixtures?.length} fixtures</Text>\n</Stack>" },
|
||||
});
|
||||
});
|
||||
|
||||
test("does not alter unrelated tools or guess at truncated JSON", () => {
|
||||
expect(repairCustomWidgetToolInput({ toolName: "app_create", input: '{"name":"Wiki\npedia"}' })).toBeNull();
|
||||
expect(
|
||||
repairCustomWidgetToolInput({ toolName: "customWidget_create", input: '{"template":"<Text>broken' }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
interface AssistantToolCallInput {
|
||||
toolName: string;
|
||||
input: string;
|
||||
}
|
||||
|
||||
const escapeControlCharactersInsideJsonStrings = (value: string) => {
|
||||
let result = "";
|
||||
let insideString = false;
|
||||
let escaped = false;
|
||||
for (const character of value) {
|
||||
if (!insideString) {
|
||||
result += character;
|
||||
if (character === '"') insideString = true;
|
||||
continue;
|
||||
}
|
||||
if (escaped) {
|
||||
result += character;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (character === "\\") {
|
||||
result += character;
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (character === '"') {
|
||||
result += character;
|
||||
insideString = false;
|
||||
continue;
|
||||
}
|
||||
switch (character) {
|
||||
case "\n":
|
||||
result += "\\n";
|
||||
break;
|
||||
case "\r":
|
||||
result += "\\r";
|
||||
break;
|
||||
case "\t":
|
||||
result += "\\t";
|
||||
break;
|
||||
default:
|
||||
result += character;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const repairCustomWidgetToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
|
||||
if (!toolCall.toolName.startsWith("customWidget_")) return null;
|
||||
const repairedInput = escapeControlCharactersInsideJsonStrings(toolCall.input);
|
||||
if (repairedInput === toolCall.input) return null;
|
||||
try {
|
||||
JSON.parse(repairedInput);
|
||||
return { ...toolCall, input: repairedInput };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { UIMessage } from "ai";
|
||||
|
||||
import { getForcedAssistantToolName, withAssistantToolPolicy } from "./assistant-tool-policy";
|
||||
import {
|
||||
customWidgetAssistantInstructions,
|
||||
getForcedAssistantToolName,
|
||||
getRequiredAssistantToolNames,
|
||||
withAssistantToolPolicy,
|
||||
} from "./assistant-tool-policy";
|
||||
|
||||
const assistantMessage = (...parts: UIMessage["parts"]): UIMessage => ({
|
||||
id: "assistant-message",
|
||||
@@ -23,6 +28,27 @@ describe("withAssistantToolPolicy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("customWidgetAssistantInstructions", () => {
|
||||
test("requires the complete skill and real preview-query data before creation", () => {
|
||||
expect(customWidgetAssistantInstructions).toContain("first call customWidget_getSkill");
|
||||
expect(customWidgetAssistantInstructions).toContain("customWidget_schema");
|
||||
expect(customWidgetAssistantInstructions).toContain("customWidget_getComponentCatalog");
|
||||
expect(customWidgetAssistantInstructions).toContain("customWidget_getComponent");
|
||||
expect(customWidgetAssistantInstructions).toContain("customWidget_getSharedProps once");
|
||||
expect(customWidgetAssistantInstructions).toContain("customWidget_getExample");
|
||||
expect(customWidgetAssistantInstructions).toContain("templateLines");
|
||||
expect(customWidgetAssistantInstructions).toContain("customWidget_previewQuery for every query");
|
||||
expect(customWidgetAssistantInstructions).toContain("Call customWidget_create only after");
|
||||
expect(customWidgetAssistantInstructions).toContain("managementPath");
|
||||
});
|
||||
|
||||
test("routes meaningful follow-up choices through ask_user instead of prose", () => {
|
||||
expect(customWidgetAssistantInstructions).toContain("call ask_user with explicit choices");
|
||||
expect(customWidgetAssistantInstructions).toContain("Never end a custom-widget response with a prose question");
|
||||
expect(customWidgetAssistantInstructions).toContain("purely rhetorical questions do not require ask_user");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getForcedAssistantToolName", () => {
|
||||
test("continues directly from the reviewed app form to app creation", () => {
|
||||
expect(
|
||||
@@ -152,3 +178,137 @@ describe("getForcedAssistantToolName", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRequiredAssistantToolNames", () => {
|
||||
test("requires a structured placement decision after custom widget creation", () => {
|
||||
expect(
|
||||
getRequiredAssistantToolNames([
|
||||
assistantMessage({
|
||||
type: "dynamic-tool",
|
||||
toolName: "customWidget_create",
|
||||
toolCallId: "create-widget-1",
|
||||
input: { name: "PSG fixtures" },
|
||||
state: "output-available",
|
||||
output: {
|
||||
id: "widget-1",
|
||||
managementPath: "/manage/custom-widgets/edit/widget-1",
|
||||
},
|
||||
}),
|
||||
]),
|
||||
).toEqual(["configure_widget", "ask_user"]);
|
||||
});
|
||||
|
||||
test("does not force a follow-up after failed custom widget creation", () => {
|
||||
expect(
|
||||
getRequiredAssistantToolNames([
|
||||
assistantMessage({
|
||||
type: "dynamic-tool",
|
||||
toolName: "customWidget_create",
|
||||
toolCallId: "create-widget-1",
|
||||
input: { name: "PSG fixtures" },
|
||||
state: "output-available",
|
||||
output: { error: "The custom widget input was invalid." },
|
||||
}),
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("requires the structured follow-up immediately after creation in the same agent run", () => {
|
||||
const completedSteps = [
|
||||
{
|
||||
toolResults: [
|
||||
{
|
||||
toolName: "customWidget_previewQuery",
|
||||
output: { status: 200, data: { events: [] } },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
toolResults: [
|
||||
{
|
||||
toolName: "customWidget_create",
|
||||
output: {
|
||||
id: "widget-1",
|
||||
managementPath: "/manage/custom-widgets/edit/widget-1",
|
||||
nextAction: {
|
||||
type: "place-custom-widget",
|
||||
widgetKind: "customApi",
|
||||
options: { definitionId: "widget-1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(getRequiredAssistantToolNames([], completedSteps)).toEqual(["configure_widget", "ask_user"]);
|
||||
});
|
||||
|
||||
test("requires the structured follow-up after an approved creation executes before step zero", () => {
|
||||
const responseMessages = [
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "create-widget-1",
|
||||
toolName: "customWidget_create",
|
||||
output: {
|
||||
type: "json",
|
||||
value: {
|
||||
id: "widget-1",
|
||||
managementPath: "/manage/custom-widgets/edit/widget-1",
|
||||
nextAction: {
|
||||
type: "place-custom-widget",
|
||||
widgetKind: "customApi",
|
||||
options: { definitionId: "widget-1" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
expect(getRequiredAssistantToolNames([], [], responseMessages)).toEqual(["configure_widget", "ask_user"]);
|
||||
});
|
||||
|
||||
test("does not keep forcing the creation follow-up after a later step has completed", () => {
|
||||
const completedSteps = [
|
||||
{
|
||||
toolResults: [
|
||||
{
|
||||
toolName: "customWidget_create",
|
||||
output: {
|
||||
id: "widget-1",
|
||||
managementPath: "/manage/custom-widgets/edit/widget-1",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
toolResults: [{ toolName: "board_all", output: { boards: [] } }],
|
||||
},
|
||||
];
|
||||
|
||||
expect(getRequiredAssistantToolNames([], completedSteps)).toEqual([]);
|
||||
});
|
||||
|
||||
test("does not require a follow-up for an in-request creation error", () => {
|
||||
expect(
|
||||
getRequiredAssistantToolNames(
|
||||
[],
|
||||
[
|
||||
{
|
||||
toolResults: [
|
||||
{
|
||||
toolName: "customWidget_create",
|
||||
output: { error: "The custom widget input was invalid." },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,16 @@ import type { UIMessage } from "ai";
|
||||
const mutationApprovalInstruction =
|
||||
"This action is protected by Homarr's native approval UI. When the user's request and the required inputs are clear, call this tool immediately. The tool call only requests approval; it does not execute until the user selects Approve and run. Never ask for confirmation in prose before calling it.";
|
||||
|
||||
export const customWidgetAssistantInstructions = `
|
||||
|
||||
Custom Widget authoring rules:
|
||||
- For every request to create, repair, validate, preview, or explain a Custom JSX widget, first call customWidget_getSkill. Its response includes the complete installed skill and all references in one lazy tool result. Follow that result as authoritative. Then call customWidget_schema and the compact customWidget_getComponentCatalog for the installed release. Fetch only the relevant named component documentation with customWidget_getComponent and, when its interaction pattern applies, one named example with customWidget_getExample before authoring. Component results contain only component-specific props; if the design needs shared props such as spacing or color, collect their names from the catalog and call customWidget_getSharedProps once with only those names.
|
||||
- Use templateLines for multiline JSX tool input. Do not send a heavily escaped multiline template string when templateLines is accepted.
|
||||
- Never skip the authoring lifecycle: customWidget_validate, repair all issues, customWidget_previewCreate, then customWidget_previewQuery for every query listed by the preview. Inspect the real status and returned data shape, correct request paths and template bindings, and repeat validation and preview after changes. Call customWidget_create only after the definition and its preview queries succeed.
|
||||
- A preview session is not evidence that its API queries work. Never claim live data works until customWidget_previewQuery returned it. Use the returned previewPath when the user should inspect the rendered result.
|
||||
- After creating a widget, include the returned managementPath as a Markdown link so the user can open and edit the exact widget in Homarr.
|
||||
- If the user must choose what happens next, such as whether to add the created widget to a board or leave it unplaced, call ask_user with explicit choices. Never end a custom-widget response with a prose question that expects a typed answer. Open-ended conversation and purely rhetorical questions do not require ask_user; state optional next steps declaratively instead of asking about them.`;
|
||||
|
||||
export const withAssistantToolPolicy = (description: string | undefined, requiresApproval: boolean) => {
|
||||
if (!requiresApproval) return description;
|
||||
return description ? `${description}\n\n${mutationApprovalInstruction}` : mutationApprovalInstruction;
|
||||
@@ -35,3 +45,106 @@ export const getForcedAssistantToolName = (messages: UIMessage[]) => {
|
||||
? nextToolByHumanTool[toolName as keyof typeof nextToolByHumanTool]
|
||||
: undefined;
|
||||
};
|
||||
|
||||
type AssistantToolExecutionStep = {
|
||||
toolResults: readonly {
|
||||
toolName: string;
|
||||
output: unknown;
|
||||
}[];
|
||||
};
|
||||
|
||||
type AssistantToolResponseMessage = {
|
||||
role: string;
|
||||
content: unknown;
|
||||
};
|
||||
|
||||
const isSuccessfulCustomWidgetCreateOutput = (output: unknown) =>
|
||||
typeof output === "object" &&
|
||||
output !== null &&
|
||||
"id" in output &&
|
||||
typeof output.id === "string" &&
|
||||
output.id.length > 0 &&
|
||||
"managementPath" in output &&
|
||||
typeof output.managementPath === "string" &&
|
||||
output.managementPath.length > 0 &&
|
||||
(!("error" in output) || output.error === undefined);
|
||||
|
||||
const getCustomWidgetCreateOutputFromResponseMessages = (responseMessages: readonly AssistantToolResponseMessage[]) => {
|
||||
for (const message of responseMessages.toReversed()) {
|
||||
if (message.role !== "tool" || !Array.isArray(message.content)) continue;
|
||||
for (const part of message.content.toReversed()) {
|
||||
if (
|
||||
typeof part !== "object" ||
|
||||
part === null ||
|
||||
!("type" in part) ||
|
||||
part.type !== "tool-result" ||
|
||||
!("toolName" in part) ||
|
||||
part.toolName !== "customWidget_create" ||
|
||||
!("output" in part)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const output = part.output;
|
||||
if (
|
||||
typeof output === "object" &&
|
||||
output !== null &&
|
||||
"type" in output &&
|
||||
output.type === "json" &&
|
||||
"value" in output
|
||||
) {
|
||||
return output.value;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getRequiredAssistantToolNames = (
|
||||
messages: UIMessage[],
|
||||
completedSteps: readonly AssistantToolExecutionStep[] = [],
|
||||
responseMessages: readonly AssistantToolResponseMessage[] = [],
|
||||
) => {
|
||||
// prepareStep receives every completed server-side step in the current stream. Prefer its most
|
||||
// recent result so a customWidget_create performed mid-run is handled before the model can fall
|
||||
// back to a prose question. Once another step has completed, the creation is no longer the
|
||||
// immediately preceding action and must not keep forcing the same follow-up.
|
||||
if (completedSteps.length > 0) {
|
||||
const latestCreateResult = completedSteps
|
||||
.at(-1)
|
||||
?.toolResults.toReversed()
|
||||
.find((result) => result.toolName === "customWidget_create");
|
||||
return latestCreateResult && isSuccessfulCustomWidgetCreateOutput(latestCreateResult.output)
|
||||
? (["configure_widget", "ask_user"] as const)
|
||||
: [];
|
||||
}
|
||||
|
||||
// Approval-gated tools execute immediately before the first model step of the follow-up request.
|
||||
// AI SDK exposes those fresh outputs through responseMessages, not completedSteps or the incoming
|
||||
// UI history, so inspect them before falling back to the persisted UI part.
|
||||
const responseCreateOutput = getCustomWidgetCreateOutputFromResponseMessages(responseMessages);
|
||||
if (responseCreateOutput !== undefined) {
|
||||
return isSuccessfulCustomWidgetCreateOutput(responseCreateOutput)
|
||||
? (["configure_widget", "ask_user"] as const)
|
||||
: [];
|
||||
}
|
||||
|
||||
// Approval-gated tools finish in a later HTTP request. At step zero their result is already in
|
||||
// the incoming UI history when the client has already received it.
|
||||
const latestMessage = messages.at(-1);
|
||||
if (latestMessage?.role !== "assistant") return [];
|
||||
const latestToolPart = latestMessage.parts.toReversed().find((part) => isToolUIPart(part));
|
||||
if (
|
||||
latestToolPart === undefined ||
|
||||
latestToolPart.state !== "output-available" ||
|
||||
getToolName(latestToolPart) !== "customWidget_create"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
if (!isSuccessfulCustomWidgetCreateOutput(latestToolPart.output)) return [];
|
||||
|
||||
// A successful definition creation always has one structured follow-up: place the widget when
|
||||
// a target is already known, or ask whether it should remain unplaced. Requiring one of these
|
||||
// tools prevents the model from falling back to a prose "Want me to add it?" question.
|
||||
return ["configure_widget", "ask_user"] as const;
|
||||
};
|
||||
@@ -56,10 +56,21 @@ import { toProviderOptionsKey } from "./assistant-provider-options";
|
||||
import { assistantExecutionPolicy } from "./assistant-execution-policy";
|
||||
import { getAssistantStreamErrorMessage } from "./assistant-stream-error";
|
||||
import { getSafeAssistantToolError } from "./assistant-tool-error";
|
||||
import { getForcedAssistantToolName, withAssistantToolPolicy } from "./assistant-tool-policy";
|
||||
import { repairCustomWidgetToolInput } from "./assistant-tool-input-repair";
|
||||
import {
|
||||
customWidgetAssistantInstructions,
|
||||
getForcedAssistantToolName,
|
||||
getRequiredAssistantToolNames,
|
||||
withAssistantToolPolicy,
|
||||
} from "./assistant-tool-policy";
|
||||
|
||||
export const maxDuration = 300;
|
||||
|
||||
// Five 1 MB image attachments expand to roughly 6.7 MB as base64 before the surrounding message
|
||||
// history and JSON envelope are added. Keep the transport ceiling above the composer contract while
|
||||
// retaining a bounded request size for this authenticated endpoint.
|
||||
const assistantRequestMaxBytes = 12_000_000;
|
||||
|
||||
const logger = createLogger({ module: "assistant" });
|
||||
const getToolApprovalSecret = () =>
|
||||
Buffer.from(
|
||||
@@ -117,7 +128,7 @@ Homarr concepts:
|
||||
|
||||
Action rules:
|
||||
- Prefer read-only tools before actions.
|
||||
- When required information is missing or the user must choose between meaningful alternatives, call ask_user. Do not ask the question only in prose. Offer distinct choices, categorize agreement or proceeding as affirmative, refusal as negative, and unrelated selections as alternative, then leave the freeform Other answer enabled. A confirmation question must have exactly one affirmative option.
|
||||
- Whenever you need an answer from the user before choosing the next action, call ask_user. Never end a prose response with a question that expects the user to type a reply. Offer distinct choices, categorize agreement or proceeding as affirmative, refusal as negative, and unrelated selections as alternative, then leave the freeform Other answer enabled. A confirmation question must have exactly one affirmative option. Purely rhetorical questions and open-ended conversation that does not block an action may remain prose, but optional next steps should be stated declaratively instead of phrased as a question.
|
||||
- Mutating Homarr tools already pause for native user approval. Calling a mutation only proposes the action; it cannot execute until the user selects Approve and run. Once the requested change is sufficiently specified, call the mutation immediately so the approval UI appears.
|
||||
- A prose response such as "Please confirm if you would like me to proceed", "Would you like me to proceed?", or a parameter summary that asks for confirmation is incorrect. Never ask for a second textual confirmation before an approval-gated tool call.
|
||||
- Do not retry a denied action.
|
||||
@@ -359,7 +370,7 @@ const createDemoAssistantResponse = (request: z.infer<typeof requestSchema>) =>
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const contentLength = Number(request.headers.get("content-length") ?? 0);
|
||||
if (contentLength > 2_000_000) {
|
||||
if (contentLength > assistantRequestMaxBytes) {
|
||||
return Response.json({ error: "The assistant request is too large." }, { status: 413 });
|
||||
}
|
||||
|
||||
@@ -369,7 +380,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const requestBody = await request.text();
|
||||
if (requestBody.length > 2_000_000) {
|
||||
if (requestBody.length > assistantRequestMaxBytes) {
|
||||
return Response.json({ error: "The assistant request is too large." }, { status: 413 });
|
||||
}
|
||||
const parsed = requestSchema.safeParse(
|
||||
@@ -493,7 +504,7 @@ export async function POST(request: Request) {
|
||||
toolName: mcpTool.name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return { error: getSafeAssistantToolError(error) };
|
||||
return { error: getSafeAssistantToolError(error, { toolName: mcpTool.name }) };
|
||||
}
|
||||
},
|
||||
}),
|
||||
@@ -562,15 +573,25 @@ export async function POST(request: Request) {
|
||||
let hasUpstreamCost = false;
|
||||
const result = streamText({
|
||||
model: provider(modelId),
|
||||
instructions: `${assistantInstructions}${openRouterServerToolsEnabled ? webSearchInstructions : ""}${mentionContext}`,
|
||||
instructions: `${assistantInstructions}${customWidgetAssistantInstructions}${openRouterServerToolsEnabled ? webSearchInstructions : ""}${mentionContext}`,
|
||||
messages: await convertToModelMessages(prepareMessagesForModel(parsed.data.messages as UIMessage[])),
|
||||
tools: availableTools,
|
||||
prepareStep: ({ stepNumber }) => {
|
||||
if (stepNumber !== 0 || forcedToolName === undefined || !(forcedToolName in availableTools)) return undefined;
|
||||
return {
|
||||
activeTools: [forcedToolName],
|
||||
toolChoice: { type: "tool", toolName: forcedToolName },
|
||||
};
|
||||
prepareStep: ({ stepNumber, steps, responseMessages }) => {
|
||||
if (stepNumber === 0 && forcedToolName !== undefined && forcedToolName in availableTools) {
|
||||
return {
|
||||
activeTools: [forcedToolName],
|
||||
toolChoice: { type: "tool", toolName: forcedToolName },
|
||||
};
|
||||
}
|
||||
const requiredToolNames = getRequiredAssistantToolNames(
|
||||
parsed.data.messages as UIMessage[],
|
||||
steps,
|
||||
responseMessages,
|
||||
).filter((toolName) => toolName in availableTools);
|
||||
if (requiredToolNames.length > 0) {
|
||||
return { activeTools: requiredToolNames, toolChoice: "required" };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
stopWhen: stepCountIs(assistantExecutionPolicy.maxSteps),
|
||||
abortSignal: request.signal,
|
||||
@@ -581,6 +602,7 @@ export async function POST(request: Request) {
|
||||
},
|
||||
maxOutputTokens: assistantExecutionPolicy.maxOutputTokens,
|
||||
maxRetries: 2,
|
||||
experimental_repairToolCall: ({ toolCall }) => Promise.resolve(repairCustomWidgetToolInput(toolCall)),
|
||||
reasoning: parsed.data.reasoning === "auto" ? undefined : parsed.data.reasoning,
|
||||
providerOptions:
|
||||
configuration.provider === "openrouter" || openRouterServerToolsEnabled
|
||||
|
||||
@@ -21,6 +21,7 @@ import { customJsxExamples, getCustomWidgetJsonSchema } from "@homarr/custom-wid
|
||||
import { db } from "@homarr/db";
|
||||
|
||||
import { getPackageVersion } from "~/versions/package-reader";
|
||||
import { getSafeAssistantToolError } from "../assistant/chat/assistant-tool-error";
|
||||
import { extractMcpTools } from "./_extract-tools";
|
||||
import { createMcpProtocolHandler } from "./_protocol";
|
||||
|
||||
@@ -204,7 +205,8 @@ const SAFE_ERROR_MESSAGES: Record<string, string> = {
|
||||
PRECONDITION_FAILED: "A precondition for this operation was not met",
|
||||
};
|
||||
|
||||
function sanitizeErrorMessage(error: unknown): string {
|
||||
function sanitizeErrorMessage(error: unknown, toolName: string): string {
|
||||
if (toolName.startsWith("customWidget_")) return getSafeAssistantToolError(error, { toolName });
|
||||
if (error instanceof Error && "code" in error) {
|
||||
const trpcCode = (error as Error & { code: string }).code;
|
||||
const safeMessage = SAFE_ERROR_MESSAGES[trpcCode];
|
||||
|
||||
@@ -99,6 +99,30 @@ describe("MCP v2 protocol", () => {
|
||||
expect(response.result.content).toEqual([{ type: "text", text: '{"message":"hello"}' }]);
|
||||
});
|
||||
|
||||
test("passes the tool name to safe error formatting", async () => {
|
||||
const formatToolError = vi.fn((_error: unknown, toolName: string) => `${toolName} input was invalid`);
|
||||
const handler = createMcpProtocolHandler({
|
||||
caller: {
|
||||
echo: vi.fn(async () => {
|
||||
throw new Error("private details");
|
||||
}),
|
||||
},
|
||||
tools: [echoTool],
|
||||
version: "1.0.0",
|
||||
instructions: "Test server",
|
||||
formatToolError,
|
||||
});
|
||||
const response = await parseResponse(
|
||||
await handler.fetch(createRequest("tools/call", { name: "echo", arguments: { message: "hello" } }, "echo")),
|
||||
);
|
||||
|
||||
expect(formatToolError).toHaveBeenCalledWith(expect.any(Error), "echo");
|
||||
expect(response.result).toMatchObject({
|
||||
isError: true,
|
||||
content: [{ type: "text", text: '{"error":"echo input was invalid"}' }],
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps stateless compatibility with 2025 Streamable HTTP clients", async () => {
|
||||
const { handler } = createTestHandler();
|
||||
const request = new Request("http://homarr.test/api/mcp", {
|
||||
|
||||
@@ -8,7 +8,7 @@ interface CreateMcpProtocolHandlerOptions {
|
||||
version: string;
|
||||
instructions: string;
|
||||
configureServer?: (server: McpServer) => void;
|
||||
formatToolError: (error: unknown) => string;
|
||||
formatToolError: (error: unknown, toolName: string) => string;
|
||||
onToolError?: (toolName: string, error: unknown) => void;
|
||||
onProtocolError?: (error: Error) => void;
|
||||
}
|
||||
@@ -70,7 +70,9 @@ export const createMcpProtocolHandler = ({
|
||||
} catch (error) {
|
||||
onToolError?.(tool.name, error);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify({ error: formatToolError(error) }) }],
|
||||
content: [
|
||||
{ type: "text" as const, text: JSON.stringify({ error: formatToolError(error, tool.name) }) },
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { UIMessage } from "ai";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { prepareAssistantMessagesForTransport, prepareAssistantRequestBody } from "./assistant-attachment-payload";
|
||||
|
||||
const routeRequestBudget = 12_000_000;
|
||||
// A valid padded base64 payload that decodes to exactly the composer's 1,000,000-byte image limit.
|
||||
const oneMegabyteBase64 = `${"A".repeat(1_333_334)}==`;
|
||||
|
||||
const createAttachmentTurn = (id: string, payloadMarker: string): UIMessage => ({
|
||||
id,
|
||||
role: "user",
|
||||
parts: [
|
||||
{ type: "text", text: `Review attachments from ${id}` },
|
||||
...Array.from({ length: 5 }, (_, index) => ({
|
||||
type: "file" as const,
|
||||
filename: `${id}-${index}.png`,
|
||||
mediaType: "image/png",
|
||||
url: `data:image/png;base64,${payloadMarker}${oneMegabyteBase64.slice(payloadMarker.length)}`,
|
||||
})),
|
||||
],
|
||||
});
|
||||
|
||||
describe("assistant attachment payload", () => {
|
||||
test("keeps five current attachments but removes historical bytes before a second heavy turn", () => {
|
||||
const firstMarker = "FIRSTPAYLOAD";
|
||||
const secondMarker = "SECONDPAYLOAD";
|
||||
const messages: UIMessage[] = [
|
||||
createAttachmentTurn("user-1", firstMarker),
|
||||
{ id: "assistant-1", role: "assistant", parts: [{ type: "text", text: "I reviewed the first set." }] },
|
||||
createAttachmentTurn("user-2", secondMarker),
|
||||
];
|
||||
|
||||
const preparedMessages = prepareAssistantMessagesForTransport(messages);
|
||||
const body = JSON.stringify({ id: "thread-1", messages: preparedMessages });
|
||||
|
||||
expect(new TextEncoder().encode(body).byteLength).toBeLessThan(routeRequestBudget);
|
||||
expect(body).not.toContain(firstMarker);
|
||||
expect(body.match(/data:image\/png;base64,/gu)).toHaveLength(5);
|
||||
expect(body).toContain(secondMarker);
|
||||
expect(preparedMessages[0]?.parts).toContainEqual(
|
||||
expect.objectContaining({ type: "text", text: expect.stringContaining('"filename":"user-1-0.png"') }),
|
||||
);
|
||||
expect(messages[0]?.parts.filter((part) => part.type === "file")).toHaveLength(5);
|
||||
expect(JSON.stringify(messages[0])).toContain(firstMarker);
|
||||
});
|
||||
|
||||
test("prunes the serialized transport envelope without changing its initialized thread id", () => {
|
||||
const originalBody = JSON.stringify({
|
||||
id: "remote-thread-id",
|
||||
reasoning: "auto",
|
||||
messages: [
|
||||
createAttachmentTurn("user-1", "OLDINLINEBYTES"),
|
||||
{ id: "assistant-1", role: "assistant", parts: [{ type: "text", text: "Done" }] },
|
||||
{ id: "user-2", role: "user", parts: [{ type: "text", text: "Follow up" }] },
|
||||
],
|
||||
});
|
||||
|
||||
const prepared = prepareAssistantRequestBody(originalBody);
|
||||
|
||||
expect(typeof prepared).toBe("string");
|
||||
expect(prepared).not.toContain("OLDINLINEBYTES");
|
||||
expect(JSON.parse(prepared as string)).toMatchObject({ id: "remote-thread-id", reasoning: "auto" });
|
||||
});
|
||||
|
||||
test("keeps the active user turn available during an automatic assistant continuation", () => {
|
||||
const messages: UIMessage[] = [
|
||||
createAttachmentTurn("user-1", "ORIGINALTURNBYTES"),
|
||||
{ id: "assistant-1", role: "assistant", parts: [{ type: "text", text: "Calling a tool" }] },
|
||||
];
|
||||
|
||||
const prepared = prepareAssistantMessagesForTransport(messages);
|
||||
|
||||
expect(JSON.stringify(prepared)).toContain("ORIGINALTURNBYTES");
|
||||
expect(prepared[0]?.parts.filter((part) => part.type === "file")).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("preserves hosted and provider-referenced files in historical messages", () => {
|
||||
const hostedPart = {
|
||||
type: "file" as const,
|
||||
filename: "report.pdf",
|
||||
mediaType: "application/pdf",
|
||||
url: "https://files.example.test/report.pdf",
|
||||
};
|
||||
const providerPart = {
|
||||
type: "file" as const,
|
||||
filename: "provider-report.pdf",
|
||||
mediaType: "application/pdf",
|
||||
url: "",
|
||||
providerReference: { openai: "file-1" },
|
||||
};
|
||||
const messages: UIMessage[] = [
|
||||
{ id: "user-1", role: "user", parts: [hostedPart, providerPart] },
|
||||
{ id: "assistant-1", role: "assistant", parts: [{ type: "text", text: "Done" }] },
|
||||
{ id: "user-2", role: "user", parts: [{ type: "text", text: "Follow up" }] },
|
||||
];
|
||||
|
||||
const prepared = prepareAssistantMessagesForTransport(messages);
|
||||
|
||||
expect(prepared[0]?.parts).toEqual([hostedPart, providerPart]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { UIMessage } from "ai";
|
||||
|
||||
const inlineAttachmentUrlPattern = /^data:[^,]+;base64,/u;
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const isFilePart = (value: unknown): value is Record<string, unknown> & { type: "file" } =>
|
||||
isRecord(value) && value.type === "file";
|
||||
|
||||
const isUIMessage = (value: unknown): value is UIMessage =>
|
||||
isRecord(value) &&
|
||||
typeof value.id === "string" &&
|
||||
(value.role === "user" || value.role === "assistant" || value.role === "system") &&
|
||||
Array.isArray(value.parts);
|
||||
|
||||
const replaceControlCharacters = (value: string) =>
|
||||
[...value]
|
||||
.map((character) => {
|
||||
const codePoint = character.codePointAt(0);
|
||||
return codePoint !== undefined && (codePoint <= 0x1f || codePoint === 0x7f) ? " " : character;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const getAttachmentMetadataPart = (part: Record<string, unknown>) => {
|
||||
const filename =
|
||||
typeof part.filename === "string" && part.filename.trim().length > 0
|
||||
? replaceControlCharacters(part.filename).trim().slice(0, 160)
|
||||
: "attachment";
|
||||
const mediaType = typeof part.mediaType === "string" ? part.mediaType.slice(0, 100) : "application/octet-stream";
|
||||
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: `[Historical attachment; binary omitted after its original turn: ${JSON.stringify({ filename, mediaType })}]`,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Keep inline bytes only for the most recent user message. The provider receives those bytes on
|
||||
* the attachment's original turn; replaying every prior data URL on later turns wastes the route's
|
||||
* bounded request budget and can make two otherwise valid five-image turns fail with HTTP 413.
|
||||
*/
|
||||
export const prepareAssistantMessagesForTransport = <TMessage extends UIMessage>(
|
||||
messages: readonly TMessage[],
|
||||
): TMessage[] => {
|
||||
const currentAttachmentMessageIndex = messages.findLastIndex((message) => message.role === "user");
|
||||
|
||||
return messages.map((message, messageIndex) => {
|
||||
let changed = false;
|
||||
const parts: UIMessage["parts"] = [];
|
||||
for (const part of message.parts) {
|
||||
if (!isFilePart(part)) {
|
||||
parts.push(part);
|
||||
continue;
|
||||
}
|
||||
const url = typeof part.url === "string" ? part.url : "";
|
||||
if (!inlineAttachmentUrlPattern.test(url) || messageIndex === currentAttachmentMessageIndex) {
|
||||
parts.push(part);
|
||||
continue;
|
||||
}
|
||||
|
||||
changed = true;
|
||||
parts.push(getAttachmentMetadataPart(part));
|
||||
}
|
||||
|
||||
return changed ? ({ ...message, parts } as TMessage) : message;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* AssistantChatTransport has already initialized the remote thread id when its fetch middleware is
|
||||
* called. Prune the serialized envelope here so changing the message body cannot accidentally
|
||||
* replace that remote id with assistant-ui's pre-initialization local id.
|
||||
*/
|
||||
export const prepareAssistantRequestBody = (body: BodyInit | null | undefined): BodyInit | null | undefined => {
|
||||
if (typeof body !== "string") return body;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(body) as unknown;
|
||||
if (!isRecord(parsed) || !Array.isArray(parsed.messages)) return body;
|
||||
if (!parsed.messages.every(isUIMessage)) return body;
|
||||
return JSON.stringify({ ...parsed, messages: prepareAssistantMessagesForTransport(parsed.messages) });
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image";
|
||||
import { getSafeAssistantAttachmentImageSource, getSafeAssistantMarkdownImageSource } from "./assistant-markdown-image";
|
||||
|
||||
describe("getSafeAssistantMarkdownImageSource", () => {
|
||||
test("allows Homarr-relative and ordinary remote images", () => {
|
||||
@@ -17,3 +17,25 @@ describe("getSafeAssistantMarkdownImageSource", () => {
|
||||
expect(getSafeAssistantMarkdownImageSource("https://user:secret@example.com/icon.svg")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSafeAssistantAttachmentImageSource", () => {
|
||||
test("allows persisted raster image data and same-origin image URLs", () => {
|
||||
expect(getSafeAssistantAttachmentImageSource("data:image/png;base64,iVBORw0KGgo=", "https://homarr.local")).toBe(
|
||||
"data:image/png;base64,iVBORw0KGgo=",
|
||||
);
|
||||
expect(getSafeAssistantAttachmentImageSource("/api/assistant/file/one", "https://homarr.local")).toBe(
|
||||
"/api/assistant/file/one",
|
||||
);
|
||||
expect(
|
||||
getSafeAssistantAttachmentImageSource("https://homarr.local/api/assistant/file/one", "https://homarr.local"),
|
||||
).toBe("https://homarr.local/api/assistant/file/one");
|
||||
});
|
||||
|
||||
test("rejects active image formats, unsafe schemes, and cross-origin URLs", () => {
|
||||
expect(
|
||||
getSafeAssistantAttachmentImageSource("data:image/svg+xml;base64,PHN2Zy8+", "https://homarr.local"),
|
||||
).toBeNull();
|
||||
expect(getSafeAssistantAttachmentImageSource("javascript:alert(1)", "https://homarr.local")).toBeNull();
|
||||
expect(getSafeAssistantAttachmentImageSource("https://example.com/private.png", "https://homarr.local")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,33 @@
|
||||
import { getSafeAssistantHttpUrl } from "./assistant-url";
|
||||
|
||||
export const getSafeAssistantMarkdownImageSource = (source: string | Blob | undefined) => {
|
||||
if (typeof source !== "string" || !source) return null;
|
||||
if (source.startsWith("/") && !source.startsWith("//")) return source;
|
||||
|
||||
return getSafeAssistantHttpUrl(source);
|
||||
};
|
||||
|
||||
const safeAttachmentDataUrl =
|
||||
/^data:image\/(?:gif|jpeg|png|webp);base64,(?:[a-z\d+/]{4})*(?:[a-z\d+/]{2}==|[a-z\d+/]{3}=)?$/iu;
|
||||
|
||||
export const getSafeAssistantAttachmentImageSource = (source: unknown, currentOrigin?: string) => {
|
||||
if (typeof source !== "string" || !source) return null;
|
||||
if (safeAttachmentDataUrl.test(source)) return source;
|
||||
if (source.startsWith("/") && !source.startsWith("//")) return source;
|
||||
if (!currentOrigin) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(source);
|
||||
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) return null;
|
||||
return url.toString();
|
||||
const sourceUrl = new URL(source);
|
||||
const originUrl = new URL(currentOrigin);
|
||||
if (
|
||||
sourceUrl.origin !== originUrl.origin ||
|
||||
(sourceUrl.protocol !== "http:" && sourceUrl.protocol !== "https:") ||
|
||||
sourceUrl.username ||
|
||||
sourceUrl.password
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return sourceUrl.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,42 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
getAssistantConversationUsage,
|
||||
getAssistantTelemetry,
|
||||
getAssistantUsage,
|
||||
resolveAssistantContextWindowTelemetry,
|
||||
} from "./assistant-message-metadata";
|
||||
|
||||
const createUsageMetadata = ({
|
||||
requestId,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cost,
|
||||
contextUsed,
|
||||
}: {
|
||||
requestId: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cost: number;
|
||||
contextUsed?: number;
|
||||
}) => ({
|
||||
usage: { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens },
|
||||
custom: {
|
||||
telemetry: {
|
||||
requestId,
|
||||
provider: "openrouter",
|
||||
modelId: "example/model",
|
||||
startedAt: "2026-08-04T10:00:00.000Z",
|
||||
completedAt: "2026-08-04T10:00:01.000Z",
|
||||
durationMs: 1000,
|
||||
cost,
|
||||
contextLength: 128_000,
|
||||
...(contextUsed === undefined ? {} : { contextUsed }),
|
||||
steps: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("getAssistantTelemetry", () => {
|
||||
test("extracts persisted request telemetry", () => {
|
||||
expect(
|
||||
@@ -171,3 +202,63 @@ describe("resolveAssistantContextWindowTelemetry", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAssistantConversationUsage", () => {
|
||||
test("aggregates unique request usage and keeps the latest reported context reading", () => {
|
||||
const first = createUsageMetadata({
|
||||
requestId: "first",
|
||||
inputTokens: 100,
|
||||
outputTokens: 25,
|
||||
cost: 0.001,
|
||||
contextUsed: 125,
|
||||
});
|
||||
const second = createUsageMetadata({ requestId: "second", inputTokens: 200, outputTokens: 50, cost: 0.002 });
|
||||
|
||||
expect(getAssistantConversationUsage([first, second, second, undefined])).toMatchObject({
|
||||
inputTokens: 300,
|
||||
outputTokens: 75,
|
||||
totalTokens: 375,
|
||||
cost: 0.003,
|
||||
contextUsed: 125,
|
||||
contextLength: 128_000,
|
||||
turns: [{ requestId: "first" }, { requestId: "second" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to step metrics when top-level usage is unavailable", () => {
|
||||
const result = getAssistantConversationUsage([
|
||||
{
|
||||
custom: {
|
||||
telemetry: {
|
||||
requestId: "step-only",
|
||||
provider: "openrouter",
|
||||
modelId: "example/model",
|
||||
startedAt: "2026-08-04T10:00:00.000Z",
|
||||
steps: [
|
||||
{
|
||||
index: 1,
|
||||
durationMs: 100,
|
||||
modelDurationMs: 100,
|
||||
toolDurationMs: 0,
|
||||
inputTokens: 80,
|
||||
outputTokens: 20,
|
||||
cachedInputTokens: 10,
|
||||
reasoningTokens: 5,
|
||||
cost: 0.0004,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
inputTokens: 80,
|
||||
outputTokens: 20,
|
||||
totalTokens: 100,
|
||||
cachedInputTokens: 10,
|
||||
reasoningTokens: 5,
|
||||
cost: 0.0004,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -92,6 +92,36 @@ export type AssistantContextWindowTelemetry = {
|
||||
source: "current" | "previous";
|
||||
};
|
||||
|
||||
export type AssistantConversationTurnUsage = {
|
||||
requestId: string;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
startedAt: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
cachedInputTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
cost?: number;
|
||||
durationMs?: number;
|
||||
contextUsed?: number;
|
||||
contextLength?: number;
|
||||
usage: AssistantUsage;
|
||||
telemetry: AssistantRequestTelemetry;
|
||||
};
|
||||
|
||||
export type AssistantConversationUsage = {
|
||||
turns: AssistantConversationTurnUsage[];
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
cachedInputTokens: number;
|
||||
reasoningTokens: number;
|
||||
cost: number;
|
||||
contextUsed?: number;
|
||||
contextLength?: number;
|
||||
};
|
||||
|
||||
const getFiniteNonNegativeNumber = (value: unknown) =>
|
||||
typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
||||
|
||||
@@ -306,6 +336,82 @@ export const getAssistantTelemetry = (metadata: unknown): AssistantRequestTeleme
|
||||
};
|
||||
};
|
||||
|
||||
const sumReportedStepMetric = (
|
||||
steps: AssistantRequestStep[],
|
||||
getValue: (step: AssistantRequestStep) => number | undefined,
|
||||
) => {
|
||||
const values = steps.flatMap((step) => {
|
||||
const value = getValue(step);
|
||||
return value === undefined ? [] : [value];
|
||||
});
|
||||
return values.length > 0 ? values.reduce((sum, value) => sum + value, 0) : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Produces a stable conversation-level usage snapshot from persisted message metadata.
|
||||
*
|
||||
* A newly-created or streaming message can temporarily have no usage metadata. Keeping
|
||||
* the latest complete context reading from earlier turns avoids flashing the composer
|
||||
* meter back to zero while the next request starts or when its popover is reopened.
|
||||
*/
|
||||
export const getAssistantConversationUsage = (messageMetadata: unknown[]): AssistantConversationUsage => {
|
||||
const turnsByRequestId = new Map<string, AssistantConversationTurnUsage>();
|
||||
|
||||
for (const metadata of messageMetadata) {
|
||||
const telemetry = getAssistantTelemetry(metadata);
|
||||
if (!telemetry) continue;
|
||||
|
||||
const usage = getAssistantUsage(metadata);
|
||||
const inputTokens = usage?.inputTokens ?? sumReportedStepMetric(telemetry.steps, (step) => step.inputTokens);
|
||||
const outputTokens = usage?.outputTokens ?? sumReportedStepMetric(telemetry.steps, (step) => step.outputTokens);
|
||||
const totalTokens =
|
||||
usage?.totalTokens ??
|
||||
(inputTokens !== undefined || outputTokens !== undefined ? (inputTokens ?? 0) + (outputTokens ?? 0) : undefined);
|
||||
const cachedInputTokens =
|
||||
usage?.cachedInputTokens ?? sumReportedStepMetric(telemetry.steps, (step) => step.cachedInputTokens);
|
||||
const reasoningTokens =
|
||||
usage?.reasoningTokens ?? sumReportedStepMetric(telemetry.steps, (step) => step.reasoningTokens);
|
||||
const cost = telemetry.cost ?? sumReportedStepMetric(telemetry.steps, (step) => step.cost);
|
||||
|
||||
turnsByRequestId.set(telemetry.requestId, {
|
||||
requestId: telemetry.requestId,
|
||||
provider: telemetry.provider,
|
||||
modelId: telemetry.modelId,
|
||||
startedAt: telemetry.startedAt,
|
||||
...(inputTokens !== undefined ? { inputTokens } : {}),
|
||||
...(outputTokens !== undefined ? { outputTokens } : {}),
|
||||
...(totalTokens !== undefined ? { totalTokens } : {}),
|
||||
...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
|
||||
...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
|
||||
...(cost !== undefined ? { cost } : {}),
|
||||
...(telemetry.durationMs !== undefined ? { durationMs: telemetry.durationMs } : {}),
|
||||
...(telemetry.contextUsed !== undefined ? { contextUsed: telemetry.contextUsed } : {}),
|
||||
...(telemetry.contextLength !== undefined ? { contextLength: telemetry.contextLength } : {}),
|
||||
usage: usage ?? {},
|
||||
telemetry,
|
||||
});
|
||||
}
|
||||
|
||||
const turns = [...turnsByRequestId.values()];
|
||||
const latestContextTurn = turns.findLast(
|
||||
(turn) => turn.contextUsed !== undefined && turn.contextLength !== undefined,
|
||||
);
|
||||
const sum = (getValue: (turn: AssistantConversationTurnUsage) => number | undefined) =>
|
||||
turns.reduce((total, turn) => total + (getValue(turn) ?? 0), 0);
|
||||
|
||||
return {
|
||||
turns,
|
||||
inputTokens: sum((turn) => turn.inputTokens),
|
||||
outputTokens: sum((turn) => turn.outputTokens),
|
||||
totalTokens: sum((turn) => turn.totalTokens),
|
||||
cachedInputTokens: sum((turn) => turn.cachedInputTokens),
|
||||
reasoningTokens: sum((turn) => turn.reasoningTokens),
|
||||
cost: sum((turn) => turn.cost),
|
||||
...(latestContextTurn?.contextUsed !== undefined ? { contextUsed: latestContextTurn.contextUsed } : {}),
|
||||
...(latestContextTurn?.contextLength !== undefined ? { contextLength: latestContextTurn.contextLength } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const resolveAssistantContextWindowTelemetry = (
|
||||
current: AssistantRequestTelemetry,
|
||||
previousMetadata: unknown[],
|
||||
|
||||
@@ -205,7 +205,7 @@
|
||||
}
|
||||
|
||||
.composerWrap {
|
||||
padding: 0.45rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.composer {
|
||||
@@ -225,6 +225,15 @@
|
||||
.suggestions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.humanToolOption {
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.humanToolOptionBadge {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
@container assistant-widget (max-width: 22rem) {
|
||||
@@ -251,6 +260,15 @@
|
||||
.runtimeSelectorEffort {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.composerContext {
|
||||
width: 1.9rem;
|
||||
padding-inline: 0.15rem;
|
||||
}
|
||||
|
||||
.composerContext > :last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container assistant-widget (max-width: 18rem) {
|
||||
@@ -493,6 +511,33 @@
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
}
|
||||
|
||||
.syntaxHighlighter {
|
||||
max-width: 100%;
|
||||
margin-block: 0.75rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-default);
|
||||
}
|
||||
|
||||
.syntaxHighlighter :global(pre) {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 0.75rem;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent !important;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.syntaxHighlighter :global(code) {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
font-family: var(--mantine-font-family-monospace);
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
}
|
||||
|
||||
.messageMarkdown hr {
|
||||
margin-block: 1rem;
|
||||
border: 0;
|
||||
@@ -638,19 +683,25 @@
|
||||
}
|
||||
|
||||
.pendingQuestionDock {
|
||||
flex: 0 1 auto;
|
||||
max-height: min(22rem, 50cqh);
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.65rem 0.75rem 0.35rem;
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
padding: 0.75rem;
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.thread[data-pending-question] .viewport {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pendingQuestionTarget {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
max-width: 44rem;
|
||||
margin-inline: auto;
|
||||
align-items: safe center;
|
||||
}
|
||||
|
||||
.pendingQuestionTarget .humanTool {
|
||||
@@ -723,6 +774,11 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.humanToolOptionBadge {
|
||||
min-width: max-content;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.humanToolOptionText {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -770,6 +826,10 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.toolResultNavigation {
|
||||
padding-top: 0.125rem;
|
||||
}
|
||||
|
||||
.toolResultProperties {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr));
|
||||
@@ -849,9 +909,8 @@
|
||||
|
||||
.reasoning {
|
||||
margin-block: 0.45rem;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-default);
|
||||
padding-inline-start: 0.6rem;
|
||||
border-inline-start: 2px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.reasoningToggle {
|
||||
@@ -859,6 +918,7 @@
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-block: 0.25rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -877,11 +937,16 @@
|
||||
}
|
||||
|
||||
.reasoningContent {
|
||||
padding-top: 0.45rem;
|
||||
padding: 0.3rem 0 0.2rem;
|
||||
}
|
||||
|
||||
.reasoningText {
|
||||
white-space: pre-wrap;
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-size: var(--mantine-font-size-sm);
|
||||
}
|
||||
|
||||
.reasoningParts {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.source {
|
||||
@@ -941,30 +1006,57 @@
|
||||
|
||||
.attachment {
|
||||
display: inline-flex;
|
||||
min-width: min(11rem, 100%);
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0.35rem 0.35rem 0;
|
||||
padding: 0.3rem;
|
||||
}
|
||||
|
||||
.attachmentThumb {
|
||||
.attachmentImage,
|
||||
.attachmentFileIcon {
|
||||
display: grid;
|
||||
width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
color: var(--mantine-color-dimmed);
|
||||
background: var(--mantine-color-body);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.attachmentImage {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.attachmentCopy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.attachmentName {
|
||||
max-width: 12rem;
|
||||
max-width: 13rem;
|
||||
}
|
||||
|
||||
.attachmentType {
|
||||
max-width: 13rem;
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4));
|
||||
}
|
||||
|
||||
.messageActions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 1.75rem;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
margin-top: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.messageActionBar {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
.userActions {
|
||||
@@ -973,11 +1065,41 @@
|
||||
|
||||
.branchPicker {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
min-height: 1.75rem;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.providerAction,
|
||||
.conversationTurnProvider {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.providerAction {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
.providerAction:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.providerAction:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.providerIcon {
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.editComposer {
|
||||
margin-block: 0.9rem;
|
||||
padding: 0.65rem;
|
||||
@@ -997,75 +1119,6 @@
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.telemetry {
|
||||
margin-top: 0.45rem;
|
||||
padding-inline: 0.15rem;
|
||||
}
|
||||
|
||||
.telemetryFacts {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.modelBadge {
|
||||
max-width: min(100%, 14rem);
|
||||
}
|
||||
|
||||
.contextMeter {
|
||||
display: grid;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
color: var(--mantine-color-text);
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.contextMeter:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.contextMeter:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.contextMeter:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.contextMeterLabel {
|
||||
font-size: 0.55rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.telemetryPopover {
|
||||
max-height: min(32rem, calc(100dvh - 2rem));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.contextStats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.contextStats > div {
|
||||
min-width: 0;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.contextStats :global(.mantine-Text-root) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usageGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
|
||||
@@ -1086,9 +1139,72 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.composerContext {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 14rem;
|
||||
height: 1.75rem;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding-inline: 0.25rem 0.45rem;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
color: var(--mantine-color-text);
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.composerContext:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.composerContext:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.conversationContextPopover {
|
||||
max-height: min(34rem, calc(100dvh - 1rem));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.conversationUsageSummary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.conversationUsageSummary > div {
|
||||
min-width: 0;
|
||||
padding: 0.45rem 0.5rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
background: var(--mantine-color-default);
|
||||
}
|
||||
|
||||
.conversationTurn {
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.55rem;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
.conversationTurnTrigger {
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.conversationTurnTrigger:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.conversationTurnProvider {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.composerWrap {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.65rem 0.75rem 0.75rem;
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
@@ -1097,20 +1213,18 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 44rem;
|
||||
margin-inline: auto;
|
||||
max-width: none;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
background: var(--mantine-color-default);
|
||||
padding: 0.45rem 0.75rem 0.55rem;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
|
||||
.composerDropzone[data-dragging] .composer {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
box-shadow: 0 0 0 2px var(--mantine-primary-color-light);
|
||||
box-shadow: inset 0 0 0 2px var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.composerQuote {
|
||||
@@ -1147,10 +1261,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.composer:focus-within {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.composerInput {
|
||||
position: relative;
|
||||
min-height: 2.25rem;
|
||||
@@ -1180,7 +1290,7 @@
|
||||
.composerInput :global(.aui-lexical-placeholder) {
|
||||
position: absolute;
|
||||
inset: 0.35rem 0.45rem auto;
|
||||
color: var(--mantine-color-dimmed);
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-5));
|
||||
line-height: 1.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -1194,6 +1304,11 @@
|
||||
padding-inline: 0.2rem;
|
||||
}
|
||||
|
||||
.composerControls {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.runtimeSelectorTrigger {
|
||||
width: fit-content;
|
||||
min-width: 0;
|
||||
@@ -1246,7 +1361,7 @@
|
||||
|
||||
.modelOption {
|
||||
margin-inline: 0.25rem;
|
||||
padding: 0.32rem 0.45rem;
|
||||
padding: 0.32rem 0.45rem 0.32rem 0.3rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
}
|
||||
|
||||
@@ -1254,7 +1369,16 @@
|
||||
.modelOption[data-combobox-active] {
|
||||
color: var(--mantine-color-text);
|
||||
background: var(--mantine-color-default-hover);
|
||||
box-shadow: inset 2px 0 0 var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.modelOptionDivider {
|
||||
height: 2rem;
|
||||
align-self: center;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.modelOptionDivider[data-active] {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
}
|
||||
|
||||
.modelOptionText {
|
||||
@@ -1337,6 +1461,10 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emptyDescription {
|
||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-5));
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
@@ -1411,7 +1539,7 @@
|
||||
}
|
||||
|
||||
.composerWrap {
|
||||
padding: 0.5rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.composer {
|
||||
@@ -1482,6 +1610,19 @@
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.conversationUsageSummary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.humanToolOption {
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.humanToolOptionBadge {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.approvalActions {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1492,6 +1633,10 @@
|
||||
padding: 0.8rem;
|
||||
}
|
||||
|
||||
.pendingQuestionDock {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.boardSettingsActions,
|
||||
.boardSettingsSubmitGroup {
|
||||
width: 100%;
|
||||
@@ -1506,7 +1651,6 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.activityBar,
|
||||
.floatingPanel,
|
||||
.contextMeter,
|
||||
.disclosureIcon,
|
||||
.reasoningChevron {
|
||||
animation: none;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -28,13 +28,18 @@ import { AssistantWidgetRendererProvider } from "@homarr/widgets";
|
||||
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
|
||||
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
|
||||
import { AssistantAutoApprovalProvider } from "./assistant-auto-approval";
|
||||
import { prepareAssistantRequestBody } from "./assistant-attachment-payload";
|
||||
import { createAssistantBrowserToolExecutors } from "./assistant-browser-tool-executors";
|
||||
import { AssistantAskUserTool, AssistantConfigureAppTool } from "./assistant-human-tools";
|
||||
import { AssistantPanel } from "./assistant-panel";
|
||||
import { getPendingAssistantAction } from "./assistant-pending-action";
|
||||
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
|
||||
import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
|
||||
import { AssistantRuntimeProviderWithTools } from "./assistant-runtime-provider";
|
||||
import {
|
||||
AssistantComposerSurfaceProvider,
|
||||
AssistantRunFocusPreserver,
|
||||
AssistantRuntimeProviderWithTools,
|
||||
} from "./assistant-runtime-provider";
|
||||
import { sendAssistantPrompt as sendPromptThroughRuntime } from "./assistant-send";
|
||||
import { createAssistantPromptInteraction } from "./assistant-spotlight";
|
||||
import { browserToolContracts } from "./assistant-tool-contracts";
|
||||
@@ -69,14 +74,9 @@ const fileToDataUrlAsync = (file: File) =>
|
||||
});
|
||||
|
||||
const createAssistantAttachmentAdapter = (allowImages: boolean): AttachmentAdapter => {
|
||||
const pendingAttachmentIds = new Set<string>();
|
||||
|
||||
return {
|
||||
accept: [...(allowImages ? assistantImageAttachmentTypes : []), ...assistantDocumentAttachmentTypes].join(","),
|
||||
async add({ file }) {
|
||||
if (pendingAttachmentIds.size >= 5) {
|
||||
throw new Error("A message can include up to 5 attachments.");
|
||||
}
|
||||
const isImage = file.type.startsWith("image/");
|
||||
if (isImage && !allowImages) {
|
||||
throw new Error("The selected model does not support image input.");
|
||||
@@ -93,7 +93,6 @@ const createAssistantAttachmentAdapter = (allowImages: boolean): AttachmentAdapt
|
||||
throw new Error(isImage ? "Images must be smaller than 1 MB." : "Documents must be smaller than 350 KB.");
|
||||
}
|
||||
const id = createId();
|
||||
pendingAttachmentIds.add(id);
|
||||
return {
|
||||
id,
|
||||
type: isImage ? "image" : "document",
|
||||
@@ -104,26 +103,20 @@ const createAssistantAttachmentAdapter = (allowImages: boolean): AttachmentAdapt
|
||||
};
|
||||
},
|
||||
async send(attachment) {
|
||||
try {
|
||||
return {
|
||||
...attachment,
|
||||
status: { type: "complete" },
|
||||
content: [
|
||||
{
|
||||
type: "file",
|
||||
filename: attachment.name,
|
||||
mimeType: attachment.contentType ?? "application/octet-stream",
|
||||
data: await fileToDataUrlAsync(attachment.file),
|
||||
},
|
||||
],
|
||||
};
|
||||
} finally {
|
||||
pendingAttachmentIds.delete(attachment.id);
|
||||
}
|
||||
},
|
||||
async remove(attachment) {
|
||||
pendingAttachmentIds.delete(attachment.id);
|
||||
return {
|
||||
...attachment,
|
||||
status: { type: "complete" },
|
||||
content: [
|
||||
{
|
||||
type: "file",
|
||||
filename: attachment.name,
|
||||
mimeType: attachment.contentType ?? "application/octet-stream",
|
||||
data: await fileToDataUrlAsync(attachment.file),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
async remove() {},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -334,6 +327,7 @@ const AssistantThreadRuntime = () => {
|
||||
new AssistantChatTransport({
|
||||
api: "/api/assistant/chat",
|
||||
body: preferences.getRequestBody,
|
||||
fetch: (input, init) => globalThis.fetch(input, { ...init, body: prepareAssistantRequestBody(init?.body) }),
|
||||
}),
|
||||
[preferences.getRequestBody],
|
||||
);
|
||||
@@ -460,6 +454,7 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<AssistantRuntimeProviderWithTools runtime={runtime} toolkit={toolkit}>
|
||||
<AssistantRuntimeEvents />
|
||||
<AssistantRunFocusPreserver />
|
||||
<AssistantPreferenceSync />
|
||||
{children}
|
||||
</AssistantRuntimeProviderWithTools>
|
||||
@@ -690,30 +685,32 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
<AssistantAutoApprovalProvider conversationId={conversationId}>
|
||||
<AssistantContext.Provider value={value}>
|
||||
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
|
||||
<AssistantPanel
|
||||
opened={opened}
|
||||
onOpen={open}
|
||||
onClose={close}
|
||||
onDismissActivity={() => {
|
||||
markRead();
|
||||
setActivityDismissed(true);
|
||||
}}
|
||||
activityDismissed={activityDismissed}
|
||||
isRunning={assistantIsRunning}
|
||||
unreadCount={unreadCount}
|
||||
latestAssistantText={latestAssistantText}
|
||||
latestUserText={queuedPrompt ?? latestUserText}
|
||||
latestStatus={latestStatus}
|
||||
pendingAction={pendingAction}
|
||||
modelId={preferences.modelId}
|
||||
models={preferences.models}
|
||||
modelOptionsLoading={preferences.isLoading}
|
||||
reasoning={preferences.reasoning}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={refreshCurrentView}
|
||||
onModelChange={selectModel}
|
||||
onReasoningChange={preferences.setReasoning}
|
||||
/>
|
||||
<AssistantComposerSurfaceProvider surfaceId="floating-panel">
|
||||
<AssistantPanel
|
||||
opened={opened}
|
||||
onOpen={open}
|
||||
onClose={close}
|
||||
onDismissActivity={() => {
|
||||
markRead();
|
||||
setActivityDismissed(true);
|
||||
}}
|
||||
activityDismissed={activityDismissed}
|
||||
isRunning={assistantIsRunning}
|
||||
unreadCount={unreadCount}
|
||||
latestAssistantText={latestAssistantText}
|
||||
latestUserText={queuedPrompt ?? latestUserText}
|
||||
latestStatus={latestStatus}
|
||||
pendingAction={pendingAction}
|
||||
modelId={preferences.modelId}
|
||||
models={preferences.models}
|
||||
modelOptionsLoading={preferences.isLoading}
|
||||
reasoning={preferences.reasoning}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={refreshCurrentView}
|
||||
onModelChange={selectModel}
|
||||
onReasoningChange={preferences.setReasoning}
|
||||
/>
|
||||
</AssistantComposerSurfaceProvider>
|
||||
</AssistantContext.Provider>
|
||||
</AssistantAutoApprovalProvider>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { act, createElement, useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { ChatModelAdapter, ThreadMessageLike } from "@assistant-ui/react";
|
||||
import { defineToolkit, MessagePrimitive, ThreadPrimitive, useLocalRuntime } from "@assistant-ui/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import type {
|
||||
AssistantClient,
|
||||
AssistantRuntime,
|
||||
AttachmentAdapter,
|
||||
ChatModelAdapter,
|
||||
ThreadMessageLike,
|
||||
} from "@assistant-ui/react";
|
||||
import {
|
||||
defineToolkit,
|
||||
INTERNAL,
|
||||
MessagePrimitive,
|
||||
ThreadPrimitive,
|
||||
useAui,
|
||||
useLocalRuntime,
|
||||
} from "@assistant-ui/react";
|
||||
import { LexicalComposerInput } from "@assistant-ui/react-lexical";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { AssistantRuntimeProviderWithTools } from "./assistant-runtime-provider";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
|
||||
import {
|
||||
AssistantComposerSurfaceProvider,
|
||||
AssistantRunFocusPreserver,
|
||||
AssistantRuntimeProviderWithTools,
|
||||
assistantSurfaceComposerCacheLimit,
|
||||
} from "./assistant-runtime-provider";
|
||||
|
||||
vi.mock("@homarr/notifications", () => ({ showErrorNotification: vi.fn() }));
|
||||
vi.mock("@homarr/translation/client", () => ({ useScopedI18n: () => (key: string) => key }));
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const noOpAdapter: ChatModelAdapter = { async *run() {} };
|
||||
const createAttachmentAdapter = (accept: string): AttachmentAdapter => ({
|
||||
accept,
|
||||
async add({ file }) {
|
||||
return {
|
||||
id: `${file.name}-${file.size}`,
|
||||
type: file.type.startsWith("image/") ? "image" : "document",
|
||||
name: file.name,
|
||||
contentType: file.type,
|
||||
file,
|
||||
status: { type: "requires-action", reason: "composer-send" },
|
||||
};
|
||||
},
|
||||
async send(attachment) {
|
||||
return {
|
||||
...attachment,
|
||||
status: { type: "complete" },
|
||||
content: [{ type: "text", text: attachment.name }],
|
||||
};
|
||||
},
|
||||
async remove() {},
|
||||
});
|
||||
const toolkit = defineToolkit({
|
||||
ask_user: {
|
||||
type: "human",
|
||||
@@ -54,6 +100,104 @@ const TestAssistant = () => {
|
||||
);
|
||||
};
|
||||
|
||||
interface SurfaceProbeProps {
|
||||
id: string;
|
||||
onReady: (id: string, aui: AssistantClient) => void;
|
||||
}
|
||||
|
||||
const SurfaceProbe = ({ id, onReady }: SurfaceProbeProps) => {
|
||||
const aui = useAui();
|
||||
|
||||
useEffect(() => {
|
||||
onReady(id, aui);
|
||||
}, [aui, id, onReady]);
|
||||
|
||||
return createElement(LexicalComposerInput, {
|
||||
"data-assistant-composer-input": true,
|
||||
"data-testid": `input-${id}`,
|
||||
} as ComponentProps<typeof LexicalComposerInput>);
|
||||
};
|
||||
|
||||
const SurfaceTestAssistant = ({
|
||||
onReady,
|
||||
onRuntimeReady,
|
||||
allowImages = true,
|
||||
}: {
|
||||
onReady: SurfaceProbeProps["onReady"];
|
||||
onRuntimeReady: (runtime: AssistantRuntime) => void;
|
||||
allowImages?: boolean;
|
||||
}) => {
|
||||
const runtime = useLocalRuntime(noOpAdapter, {
|
||||
adapters: { attachments: createAttachmentAdapter(allowImages ? "image/png,text/plain" : "text/plain") },
|
||||
});
|
||||
useEffect(() => onRuntimeReady(runtime), [onRuntimeReady, runtime]);
|
||||
return createElement(
|
||||
AssistantRuntimeProviderWithTools,
|
||||
{ runtime, toolkit },
|
||||
createElement(AssistantRunFocusPreserver),
|
||||
createElement(
|
||||
AssistantComposerSurfaceProvider,
|
||||
{ surfaceId: "surface-a" },
|
||||
createElement(SurfaceProbe, { id: "a", onReady }),
|
||||
),
|
||||
createElement(
|
||||
AssistantComposerSurfaceProvider,
|
||||
{ surfaceId: "surface-b" },
|
||||
createElement(SurfaceProbe, { id: "b", onReady }),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const renderSurfaceTestAssistant = async ({ allowImages = true }: { allowImages?: boolean } = {}) => {
|
||||
const container = document.createElement("div");
|
||||
containers.push(container);
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
const clients = new Map<string, AssistantClient>();
|
||||
let sharedRuntime: AssistantRuntime | undefined;
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(SurfaceTestAssistant, {
|
||||
allowImages,
|
||||
onReady: (id, aui) => clients.set(id, aui),
|
||||
onRuntimeReady: (runtime) => {
|
||||
sharedRuntime = runtime;
|
||||
},
|
||||
}),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
const a = clients.get("a");
|
||||
const b = clients.get("b");
|
||||
if (!a || !b || !sharedRuntime) throw new Error("The assistant runtime and both surfaces must be registered");
|
||||
|
||||
return {
|
||||
container,
|
||||
root,
|
||||
a,
|
||||
b,
|
||||
sharedRuntime,
|
||||
};
|
||||
};
|
||||
|
||||
const getRequiredComposerInput = (container: HTMLElement, testId: string) => {
|
||||
const wrapper = container.querySelector<HTMLElement>(`[data-testid="${testId}"]`);
|
||||
const target = wrapper?.querySelector<HTMLElement>('[contenteditable="true"]');
|
||||
if (!target) throw new Error(`Missing composer input ${testId}`);
|
||||
return target;
|
||||
};
|
||||
|
||||
const getUserMessageText = (aui: AssistantClient) =>
|
||||
aui
|
||||
.thread()
|
||||
.getState()
|
||||
.messages.filter((message) => message.role === "user")
|
||||
.flatMap((message) => message.content)
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text);
|
||||
|
||||
const containers: HTMLDivElement[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
@@ -77,4 +221,293 @@ describe("AssistantRuntimeProviderWithTools", () => {
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("keeps drafts and attachments independent while both surfaces share one thread", async () => {
|
||||
const { container, root, a, b } = await renderSurfaceTestAssistant();
|
||||
|
||||
act(() => {
|
||||
a.composer().setText("Draft in the board widget");
|
||||
b.composer().setText("Draft in the floating panel");
|
||||
});
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
|
||||
await act(async () => {
|
||||
await a.composer().addAttachment({
|
||||
id: "board-file",
|
||||
name: "board.txt",
|
||||
contentType: "text/plain",
|
||||
content: [{ type: "text", text: "board attachment" }],
|
||||
});
|
||||
});
|
||||
|
||||
expect(a.composer().getState().text).toBe("Draft in the board widget");
|
||||
expect(b.composer().getState().text).toBe("Draft in the floating panel");
|
||||
expect(
|
||||
a
|
||||
.composer()
|
||||
.getState()
|
||||
.attachments.map((attachment) => attachment.name),
|
||||
).toEqual(["board.txt"]);
|
||||
expect(b.composer().getState().attachments).toHaveLength(0);
|
||||
expect(a.thread().getState().messages).toEqual(b.thread().getState().messages);
|
||||
expect(getRequiredComposerInput(container, "input-a").textContent).toBe("Draft in the board widget");
|
||||
expect(getRequiredComposerInput(container, "input-b").textContent).toBe("Draft in the floating panel");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("sends either local composer into the same shared conversation without clearing the other draft", async () => {
|
||||
const { root, a, b } = await renderSurfaceTestAssistant();
|
||||
|
||||
act(() => {
|
||||
a.composer().setText("Message from the widget");
|
||||
b.composer().setText("Message from the panel");
|
||||
a.composer().send();
|
||||
});
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
|
||||
|
||||
expect(getUserMessageText(a)).toContain("Message from the widget");
|
||||
expect(getUserMessageText(b)).toContain("Message from the widget");
|
||||
expect(a.composer().getState().text).toBe("");
|
||||
expect(b.composer().getState().text).toBe("Message from the panel");
|
||||
|
||||
act(() => b.composer().send());
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
|
||||
expect(getUserMessageText(a)).toContain("Message from the panel");
|
||||
expect(getUserMessageText(b)).toContain("Message from the panel");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("keeps each surface draft scoped to the selected conversation", async () => {
|
||||
const { root, a, b } = await renderSurfaceTestAssistant();
|
||||
act(() => {
|
||||
a.composer().setText("Initialize the first conversation");
|
||||
a.composer().send();
|
||||
});
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
|
||||
const firstThreadId = a.threads().getState().mainThreadId;
|
||||
|
||||
act(() => {
|
||||
a.composer().setText("First thread / widget");
|
||||
b.composer().setText("First thread / panel");
|
||||
});
|
||||
await act(async () => a.threads().switchToNewThread());
|
||||
|
||||
expect(a.threads().getState().mainThreadId).not.toBe(firstThreadId);
|
||||
expect(a.composer().getState().text).toBe("");
|
||||
expect(b.composer().getState().text).toBe("");
|
||||
act(() => a.composer().setText("Second thread / widget"));
|
||||
|
||||
await act(async () => a.threads().switchToThread(firstThreadId));
|
||||
expect(a.composer().getState().text).toBe("First thread / widget");
|
||||
expect(b.composer().getState().text).toBe("First thread / panel");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("restores focus after the Lexical run-start behavior tries to focus every surface", async () => {
|
||||
const outsideInput = document.createElement("input");
|
||||
document.body.append(outsideInput);
|
||||
const { root, a } = await renderSurfaceTestAssistant();
|
||||
outsideInput.focus();
|
||||
|
||||
act(() => {
|
||||
a.composer().setText("Run without stealing focus");
|
||||
a.composer().send();
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(outsideInput);
|
||||
outsideInput.remove();
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("adds pasted images only to the active surface composer", async () => {
|
||||
const { container, root, a, b } = await renderSurfaceTestAssistant();
|
||||
const target = getRequiredComposerInput(container, "input-a");
|
||||
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", {
|
||||
value: { files: [new File(["image"], "dashboard.png", { type: "image/png" })] },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
target.dispatchEvent(paste);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(paste.defaultPrevented).toBe(true);
|
||||
expect(
|
||||
a
|
||||
.composer()
|
||||
.getState()
|
||||
.attachments.map((attachment) => attachment.name),
|
||||
).toEqual(["dashboard.png"]);
|
||||
expect(b.composer().getState().attachments).toHaveLength(0);
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("rejects a pasted image with an actionable message when the selected model is text-only", async () => {
|
||||
vi.mocked(showErrorNotification).mockClear();
|
||||
const { container, root, a } = await renderSurfaceTestAssistant({ allowImages: false });
|
||||
const target = getRequiredComposerInput(container, "input-a");
|
||||
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", {
|
||||
value: { files: [new File(["image"], "dashboard.png", { type: "image/png" })] },
|
||||
});
|
||||
|
||||
await act(async () => target.dispatchEvent(paste));
|
||||
|
||||
expect(a.composer().getState().attachments).toHaveLength(0);
|
||||
expect(showErrorNotification).toHaveBeenCalledWith({
|
||||
title: "attachments.errorTitle",
|
||||
message: "attachments.imageUnsupported",
|
||||
});
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("does not capture file paste from a non-composer textarea", async () => {
|
||||
const { container, root, a } = await renderSurfaceTestAssistant();
|
||||
const textarea = document.createElement("textarea");
|
||||
container.querySelector('[data-testid="input-a"]')?.parentElement?.append(textarea);
|
||||
const paste = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", {
|
||||
value: { files: [new File(["image"], "dashboard.png", { type: "image/png" })] },
|
||||
});
|
||||
|
||||
await act(async () => textarea.dispatchEvent(paste));
|
||||
|
||||
expect(paste.defaultPrevented).toBe(false);
|
||||
expect(a.composer().getState().attachments).toHaveLength(0);
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("enforces the attachment limit independently for each surface", async () => {
|
||||
vi.mocked(showErrorNotification).mockClear();
|
||||
const { root, a, b } = await renderSurfaceTestAssistant();
|
||||
|
||||
await act(async () => {
|
||||
await Promise.all(
|
||||
Array.from({ length: 5 }, (_, index) =>
|
||||
a.composer().addAttachment(new File([`a-${index}`], `a-${index}.txt`, { type: "text/plain" })),
|
||||
),
|
||||
);
|
||||
await Promise.all(
|
||||
Array.from({ length: 5 }, (_, index) =>
|
||||
b.composer().addAttachment(new File([`b-${index}`], `b-${index}.txt`, { type: "text/plain" })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
expect(a.composer().getState().attachments).toHaveLength(5);
|
||||
expect(b.composer().getState().attachments).toHaveLength(5);
|
||||
await expect(a.composer().addAttachment(new File(["six"], "a-six.txt", { type: "text/plain" }))).rejects.toThrow(
|
||||
"A message can include up to 5 attachments.",
|
||||
);
|
||||
expect(b.composer().getState().attachments).toHaveLength(5);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("disposes scoped composer subscriptions when its surfaces unmount", async () => {
|
||||
const originalConnect = INTERNAL.DefaultThreadComposerRuntimeCore.prototype.connect;
|
||||
let activeConnections = 0;
|
||||
const connectSpy = vi
|
||||
.spyOn(INTERNAL.DefaultThreadComposerRuntimeCore.prototype, "connect")
|
||||
.mockImplementation(function (this: InstanceType<typeof INTERNAL.DefaultThreadComposerRuntimeCore>) {
|
||||
const unsubscribe = originalConnect.call(this);
|
||||
activeConnections += 1;
|
||||
let connected = true;
|
||||
return () => {
|
||||
if (!connected) return;
|
||||
connected = false;
|
||||
activeConnections -= 1;
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
const { root } = await renderSurfaceTestAssistant();
|
||||
const mountedConnections = activeConnections;
|
||||
expect(mountedConnections).toBeGreaterThanOrEqual(3);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
|
||||
expect(activeConnections).toBeLessThan(mountedConnections);
|
||||
expect(mountedConnections - activeConnections).toBe(2);
|
||||
connectSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("recreates scoped composers when the shared conversation core is replaced", async () => {
|
||||
const originalConnect = INTERNAL.DefaultThreadComposerRuntimeCore.prototype.connect;
|
||||
let connectCalls = 0;
|
||||
let disconnectCalls = 0;
|
||||
const connectSpy = vi
|
||||
.spyOn(INTERNAL.DefaultThreadComposerRuntimeCore.prototype, "connect")
|
||||
.mockImplementation(function (this: InstanceType<typeof INTERNAL.DefaultThreadComposerRuntimeCore>) {
|
||||
connectCalls += 1;
|
||||
const unsubscribe = originalConnect.call(this);
|
||||
let connected = true;
|
||||
return () => {
|
||||
if (!connected) return;
|
||||
connected = false;
|
||||
disconnectCalls += 1;
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
const { root, a, b, sharedRuntime } = await renderSurfaceTestAssistant();
|
||||
const mountedConnectCalls = connectCalls;
|
||||
act(() => {
|
||||
a.composer().setText("Draft on the first board core");
|
||||
b.composer().setText("Draft on the first panel core");
|
||||
});
|
||||
|
||||
const sharedThread = sharedRuntime.thread as InstanceType<typeof INTERNAL.ThreadRuntimeImpl>;
|
||||
// oxlint-disable-next-line no-underscore-dangle -- replacing the bound core reproduces assistant-ui's outer runtime swap.
|
||||
const binding = sharedThread.__internal_threadBinding;
|
||||
const originalGetState = binding.getState;
|
||||
const originalCore = originalGetState();
|
||||
const replacementCore = new Proxy(originalCore, {
|
||||
get(target, property) {
|
||||
const value = Reflect.get(target, property, target) as unknown;
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
});
|
||||
binding.getState = () => replacementCore;
|
||||
|
||||
act(() => originalCore.reset());
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
|
||||
|
||||
expect(connectCalls - mountedConnectCalls).toBeGreaterThanOrEqual(2);
|
||||
expect(disconnectCalls).toBeGreaterThanOrEqual(2);
|
||||
expect(a.composer().getState().text).toBe("Draft on the first board core");
|
||||
expect(b.composer().getState().text).toBe("Draft on the first panel core");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
binding.getState = originalGetState;
|
||||
connectSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("evicts the least recently used inactive conversation draft", async () => {
|
||||
const { root, a } = await renderSurfaceTestAssistant();
|
||||
act(() => {
|
||||
a.composer().setText("Initialize the oldest conversation");
|
||||
a.composer().send();
|
||||
});
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
|
||||
const oldestThreadId = a.threads().getState().mainThreadId;
|
||||
act(() => a.composer().setText("Draft that should eventually be evicted"));
|
||||
|
||||
for (let index = 0; index < assistantSurfaceComposerCacheLimit; index += 1) {
|
||||
await act(async () => a.threads().switchToNewThread());
|
||||
act(() => {
|
||||
a.composer().setText(`Initialize conversation ${index}`);
|
||||
a.composer().send();
|
||||
});
|
||||
await act(async () => new Promise((resolve) => setTimeout(resolve, 0)));
|
||||
}
|
||||
|
||||
await act(async () => a.threads().switchToThread(oldestThreadId));
|
||||
expect(a.composer().getState().text).toBe("");
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { AssistantRuntime, Toolkit } from "@assistant-ui/react";
|
||||
import { AssistantRuntimeProvider, Tools, useAui } from "@assistant-ui/react";
|
||||
import type { ClipboardEvent, PropsWithChildren } from "react";
|
||||
import { createContext, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import type { AssistantRuntime, AttachmentAdapter, ThreadListRuntime, Toolkit } from "@assistant-ui/react";
|
||||
import { AssistantRuntimeProvider, INTERNAL, Tools, useAui, useAuiEvent } from "@assistant-ui/react";
|
||||
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface AssistantRuntimeProviderWithToolsProps extends PropsWithChildren {
|
||||
runtime: AssistantRuntime;
|
||||
@@ -21,3 +25,501 @@ export const AssistantRuntimeProviderWithTools = ({
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
type ThreadCore = INTERNAL.ThreadRuntimeCore;
|
||||
type ThreadCoreWithAdapters = ConstructorParameters<typeof INTERNAL.DefaultThreadComposerRuntimeCore>[0];
|
||||
type ComposerConnectionDisposer = () => void;
|
||||
|
||||
export const assistantSurfaceComposerCacheLimit = 12;
|
||||
const assistantSurfaceAttachmentLimit = 5;
|
||||
const composerConnectionDisposers = new WeakMap<object, ComposerConnectionDisposer>();
|
||||
|
||||
class AssistantSurfaceComposerRuntimeCore extends INTERNAL.DefaultThreadComposerRuntimeCore {
|
||||
public override connect() {
|
||||
const dispose = super.connect();
|
||||
composerConnectionDisposers.set(this, dispose);
|
||||
return dispose;
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
composerConnectionDisposers.get(this)?.();
|
||||
composerConnectionDisposers.delete(this);
|
||||
}
|
||||
}
|
||||
|
||||
interface AssistantSurfaceRuntime extends AssistantRuntime {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
interface AssistantSurfaceComposerEntry {
|
||||
composer: AssistantSurfaceComposerRuntimeCore;
|
||||
source: ThreadCore;
|
||||
}
|
||||
|
||||
const getThreadAdapters = (threadCore: ThreadCore) => (threadCore as ThreadCoreWithAdapters).adapters;
|
||||
|
||||
const bindRuntimeValue = (target: object, property: PropertyKey, cache: WeakMap<object, Map<PropertyKey, unknown>>) => {
|
||||
const value = Reflect.get(target, property, target) as unknown;
|
||||
if (typeof value !== "function") return value;
|
||||
|
||||
let targetCache = cache.get(target);
|
||||
if (!targetCache) {
|
||||
targetCache = new Map();
|
||||
cache.set(target, targetCache);
|
||||
}
|
||||
const cached = targetCache.get(property);
|
||||
if (cached) return cached;
|
||||
const bound = value.bind(target) as unknown;
|
||||
targetCache.set(property, bound);
|
||||
return bound;
|
||||
};
|
||||
|
||||
const createSurfaceAttachmentAdapter = (threadCore: ThreadCore): AttachmentAdapter => {
|
||||
const pendingAttachmentIds = new Set<string>();
|
||||
let pendingAdds = 0;
|
||||
const getAdapter = () => {
|
||||
const adapter = getThreadAdapters(threadCore)?.attachments;
|
||||
if (!adapter) throw new Error("Attachments are not supported");
|
||||
return adapter;
|
||||
};
|
||||
|
||||
return {
|
||||
get accept() {
|
||||
return getThreadAdapters(threadCore)?.attachments?.accept ?? "";
|
||||
},
|
||||
add({ file }) {
|
||||
if (pendingAttachmentIds.size + pendingAdds >= assistantSurfaceAttachmentLimit) {
|
||||
throw new Error(`A message can include up to ${assistantSurfaceAttachmentLimit} attachments.`);
|
||||
}
|
||||
|
||||
pendingAdds += 1;
|
||||
let result: ReturnType<AttachmentAdapter["add"]>;
|
||||
try {
|
||||
result = getAdapter().add({ file });
|
||||
} catch (error) {
|
||||
pendingAdds -= 1;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (Symbol.asyncIterator in result) {
|
||||
return (async function* () {
|
||||
let reservationPending = true;
|
||||
try {
|
||||
for await (const attachment of result) {
|
||||
if (reservationPending) {
|
||||
reservationPending = false;
|
||||
pendingAdds -= 1;
|
||||
}
|
||||
pendingAttachmentIds.add(attachment.id);
|
||||
yield attachment;
|
||||
}
|
||||
} finally {
|
||||
if (reservationPending) pendingAdds -= 1;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return result.then(
|
||||
(attachment) => {
|
||||
pendingAdds -= 1;
|
||||
pendingAttachmentIds.add(attachment.id);
|
||||
return attachment;
|
||||
},
|
||||
(error: unknown) => {
|
||||
pendingAdds -= 1;
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
},
|
||||
async remove(attachment) {
|
||||
try {
|
||||
await getAdapter().remove(attachment);
|
||||
} finally {
|
||||
pendingAttachmentIds.delete(attachment.id);
|
||||
}
|
||||
},
|
||||
async send(attachment) {
|
||||
try {
|
||||
return await getAdapter().send(attachment);
|
||||
} finally {
|
||||
pendingAttachmentIds.delete(attachment.id);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createSurfaceComposerHost = (
|
||||
threadCore: ThreadCore,
|
||||
boundRuntimeValues: WeakMap<object, Map<PropertyKey, unknown>>,
|
||||
) => {
|
||||
const attachments = createSurfaceAttachmentAdapter(threadCore);
|
||||
const adapters = {
|
||||
get attachments() {
|
||||
return getThreadAdapters(threadCore)?.attachments ? attachments : undefined;
|
||||
},
|
||||
get dictation() {
|
||||
return getThreadAdapters(threadCore)?.dictation;
|
||||
},
|
||||
};
|
||||
|
||||
return new Proxy(threadCore as ThreadCoreWithAdapters, {
|
||||
get(target, property) {
|
||||
if (property === "adapters") return adapters;
|
||||
return bindRuntimeValue(target, property, boundRuntimeValues);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* assistant-ui intentionally stores the thread composer on the thread runtime. That is the right
|
||||
* default for a single chat surface, but Homarr can render the same conversation in the floating
|
||||
* panel and in one or more board widgets at the same time. A second runtime-bound Lexical input
|
||||
* would otherwise mirror the first input's draft, attachments and selection.
|
||||
*
|
||||
* This adapter keeps the thread runtime itself shared and replaces only its composer core. The
|
||||
* local composer still delegates send/cancel to the shared thread, so messages, runs, tools,
|
||||
* branches and history remain one conversation. Composers are cached per conversation id because
|
||||
* some assistant-ui runtimes reuse the same underlying core while switching conversations.
|
||||
*/
|
||||
export const createAssistantSurfaceRuntime = (runtime: AssistantRuntime): AssistantSurfaceRuntime => {
|
||||
const sharedThread = runtime.thread as InstanceType<typeof INTERNAL.ThreadRuntimeImpl>;
|
||||
// oxlint-disable-next-line no-underscore-dangle -- assistant-ui exposes the binding only through its internal adapter API.
|
||||
const sharedBinding = sharedThread.__internal_threadBinding;
|
||||
const composerByConversation = new Map<string, AssistantSurfaceComposerEntry>();
|
||||
const surfaceCoreByConversation = new Map<string, { source: ThreadCore; surface: ThreadCore }>();
|
||||
const activeConversationCounts = new Map<string, number>();
|
||||
const boundRuntimeValues = new WeakMap<object, Map<PropertyKey, unknown>>();
|
||||
|
||||
const disposeComposer = (conversationId: string, entry: AssistantSurfaceComposerEntry) => {
|
||||
const reset = entry.composer.reset();
|
||||
entry.composer.dispose();
|
||||
composerByConversation.delete(conversationId);
|
||||
surfaceCoreByConversation.delete(conversationId);
|
||||
void reset.catch(() => {});
|
||||
};
|
||||
|
||||
const evictInactiveComposers = () => {
|
||||
while (composerByConversation.size > assistantSurfaceComposerCacheLimit) {
|
||||
let candidate: [string, AssistantSurfaceComposerEntry] | undefined;
|
||||
for (const entry of composerByConversation) {
|
||||
if (!activeConversationCounts.has(entry[0])) {
|
||||
candidate = entry;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!candidate) return;
|
||||
disposeComposer(candidate[0], candidate[1]);
|
||||
}
|
||||
};
|
||||
|
||||
const retainConversation = (conversationId: string) => {
|
||||
activeConversationCounts.set(conversationId, (activeConversationCounts.get(conversationId) ?? 0) + 1);
|
||||
};
|
||||
|
||||
const releaseConversation = (conversationId: string) => {
|
||||
const count = activeConversationCounts.get(conversationId);
|
||||
if (count === undefined) return;
|
||||
if (count <= 1) activeConversationCounts.delete(conversationId);
|
||||
else activeConversationCounts.set(conversationId, count - 1);
|
||||
evictInactiveComposers();
|
||||
};
|
||||
|
||||
const getComposer = (conversationId: string, threadCore: ThreadCore) => {
|
||||
const existing = composerByConversation.get(conversationId);
|
||||
if (existing?.source === threadCore) {
|
||||
composerByConversation.delete(conversationId);
|
||||
composerByConversation.set(conversationId, existing);
|
||||
return existing.composer;
|
||||
}
|
||||
const draft = existing
|
||||
? {
|
||||
attachments: existing.composer.attachments,
|
||||
quote: existing.composer.quote,
|
||||
role: existing.composer.role,
|
||||
runConfig: existing.composer.runConfig,
|
||||
text: existing.composer.text,
|
||||
}
|
||||
: undefined;
|
||||
if (existing) disposeComposer(conversationId, existing);
|
||||
|
||||
const host = createSurfaceComposerHost(threadCore, boundRuntimeValues);
|
||||
const composer = new AssistantSurfaceComposerRuntimeCore(host);
|
||||
composerByConversation.set(conversationId, { composer, source: threadCore });
|
||||
if (draft) {
|
||||
composer.setText(draft.text);
|
||||
composer.setRole(draft.role);
|
||||
composer.setRunConfig(draft.runConfig);
|
||||
composer.setQuote(draft.quote);
|
||||
for (const attachment of draft.attachments) {
|
||||
if (attachment.status.type === "complete" && attachment.content) {
|
||||
void composer
|
||||
.addAttachment({
|
||||
id: attachment.id,
|
||||
type: attachment.type,
|
||||
name: attachment.name,
|
||||
contentType: attachment.contentType,
|
||||
content: attachment.content,
|
||||
})
|
||||
.catch(() => {});
|
||||
} else if (attachment.file) {
|
||||
void composer.addAttachment(attachment.file).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
evictInactiveComposers();
|
||||
return composer;
|
||||
};
|
||||
|
||||
const getSurfaceCore = (conversationId: string, threadCore: ThreadCore) => {
|
||||
const existing = surfaceCoreByConversation.get(conversationId);
|
||||
if (existing?.source === threadCore) return existing.surface;
|
||||
|
||||
const composer = getComposer(conversationId, threadCore);
|
||||
const surfaceCore = new Proxy(threadCore, {
|
||||
get(target, property) {
|
||||
if (property === "composer") return composer;
|
||||
return bindRuntimeValue(target, property, boundRuntimeValues);
|
||||
},
|
||||
});
|
||||
surfaceCoreByConversation.set(conversationId, { source: threadCore, surface: surfaceCore });
|
||||
return surfaceCore;
|
||||
};
|
||||
|
||||
const getConversationId = () => runtime.threads.mainItem.getState().id;
|
||||
|
||||
const surfaceBinding: INTERNAL.ThreadRuntimeCoreBinding = {
|
||||
path: sharedBinding.path,
|
||||
getState: () => getSurfaceCore(getConversationId(), sharedBinding.getState()),
|
||||
outerSubscribe: (callback) => sharedBinding.outerSubscribe(callback),
|
||||
subscribe(callback) {
|
||||
let activeCore = sharedBinding.getState();
|
||||
let activeConversationId = getConversationId();
|
||||
retainConversation(activeConversationId);
|
||||
let unsubscribeComposer = getComposer(activeConversationId, activeCore).subscribe(callback);
|
||||
const handleSharedUpdate = () => {
|
||||
const nextCore = sharedBinding.getState();
|
||||
const nextConversationId = getConversationId();
|
||||
if (nextCore !== activeCore || nextConversationId !== activeConversationId) {
|
||||
unsubscribeComposer();
|
||||
releaseConversation(activeConversationId);
|
||||
activeCore = nextCore;
|
||||
activeConversationId = nextConversationId;
|
||||
retainConversation(activeConversationId);
|
||||
unsubscribeComposer = getComposer(activeConversationId, activeCore).subscribe(callback);
|
||||
}
|
||||
callback();
|
||||
};
|
||||
const unsubscribeThread = sharedBinding.subscribe(handleSharedUpdate);
|
||||
const unsubscribeThreadItem = runtime.threads.mainItem.subscribe(handleSharedUpdate);
|
||||
return () => {
|
||||
unsubscribeComposer();
|
||||
releaseConversation(activeConversationId);
|
||||
unsubscribeThread();
|
||||
unsubscribeThreadItem();
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const sharedMainItem = runtime.threads.mainItem;
|
||||
const mainItemBinding: INTERNAL.ThreadListItemRuntimeBinding = {
|
||||
path: sharedMainItem.path,
|
||||
getState: () => sharedMainItem.getState(),
|
||||
subscribe: (callback) => sharedMainItem.subscribe(callback),
|
||||
};
|
||||
const surfaceThread = new INTERNAL.ThreadRuntimeImpl(surfaceBinding, mainItemBinding);
|
||||
const sharedThreads = runtime.threads;
|
||||
const getSurfaceThreadById = (threadId: string) => {
|
||||
const requestedItem = sharedThreads.getItemById(threadId).getState();
|
||||
if (requestedItem.id === sharedMainItem.getState().id) return surfaceThread;
|
||||
// Homarr renders only the selected conversation. Preserve the upstream behavior for inactive
|
||||
// thread inspection without manufacturing another composer outside this surface's LRU cache.
|
||||
return sharedThreads.getById(threadId);
|
||||
};
|
||||
const surfaceThreads: ThreadListRuntime = {
|
||||
main: surfaceThread,
|
||||
mainItem: sharedMainItem,
|
||||
getState: () => sharedThreads.getState(),
|
||||
subscribe: (callback) => sharedThreads.subscribe(callback),
|
||||
getById: getSurfaceThreadById,
|
||||
getItemById: (threadId) => sharedThreads.getItemById(threadId),
|
||||
getItemByIndex: (index) => sharedThreads.getItemByIndex(index),
|
||||
getArchivedItemByIndex: (index) => sharedThreads.getArchivedItemByIndex(index),
|
||||
switchToThread: (threadId, options) => sharedThreads.switchToThread(threadId, options),
|
||||
switchToNewThread: () => sharedThreads.switchToNewThread(),
|
||||
getLoadThreadsPromise: () => sharedThreads.getLoadThreadsPromise(),
|
||||
reload: () => sharedThreads.reload(),
|
||||
loadMore: () => sharedThreads.loadMore(),
|
||||
};
|
||||
|
||||
return {
|
||||
threads: surfaceThreads,
|
||||
thread: surfaceThread,
|
||||
// The root runtime already owns the inherited model-context client. Re-registering that same
|
||||
// provider for every surface would make one unmount remove it from the shared runtime's Set.
|
||||
registerModelContextProvider: () => () => {},
|
||||
dispose() {
|
||||
activeConversationCounts.clear();
|
||||
for (const [conversationId, entry] of composerByConversation) disposeComposer(conversationId, entry);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
interface AssistantComposerSurfaceValue {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const AssistantComposerSurfaceContext = createContext<AssistantComposerSurfaceValue | null>(null);
|
||||
|
||||
export const useAssistantComposerSurface = () => {
|
||||
const value = useContext(AssistantComposerSurfaceContext);
|
||||
if (!value) throw new Error("useAssistantComposerSurface must be used within AssistantComposerSurfaceProvider");
|
||||
return value;
|
||||
};
|
||||
|
||||
const surfaceElements = new Map<string, HTMLElement>();
|
||||
|
||||
const getSurfaceIdForElement = (element: Element | null) => {
|
||||
if (!element) return null;
|
||||
for (const [surfaceId, surfaceElement] of surfaceElements) {
|
||||
if (surfaceElement.contains(element)) return surfaceId;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getClipboardAttachmentName = (file: File, index: number) => {
|
||||
if (file.name.trim().length > 0) return file.name;
|
||||
const extension = file.type.split("/")[1]?.replace("jpeg", "jpg") ?? "bin";
|
||||
return `pasted-file-${index + 1}.${extension}`;
|
||||
};
|
||||
|
||||
const AssistantComposerSurfaceEvents = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
useAuiEvent("composer.attachmentAddError", ({ message }) => {
|
||||
showErrorNotification({ title: t("attachments.errorTitle"), message });
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const aui = useAui();
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const contextValue = useMemo(() => ({ id: surfaceId }), [surfaceId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = elementRef.current;
|
||||
if (!element) return undefined;
|
||||
surfaceElements.set(surfaceId, element);
|
||||
|
||||
return () => {
|
||||
if (surfaceElements.get(surfaceId) === element) surfaceElements.delete(surfaceId);
|
||||
};
|
||||
}, [surfaceId]);
|
||||
|
||||
const handlePasteCapture = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement) || !target.closest("[data-assistant-composer-input]")) return;
|
||||
const files = Array.from(event.clipboardData.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
const composer = aui.composer();
|
||||
const acceptedTypes = new Set(
|
||||
composer
|
||||
.getState()
|
||||
.attachmentAccept.split(",")
|
||||
.map((type) => type.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
files.forEach((file, index) => {
|
||||
if (
|
||||
file.type.startsWith("image/") &&
|
||||
!acceptedTypes.has("*") &&
|
||||
!acceptedTypes.has(file.type) &&
|
||||
!acceptedTypes.has("image/*")
|
||||
) {
|
||||
showErrorNotification({
|
||||
title: t("attachments.errorTitle"),
|
||||
message: t("attachments.imageUnsupported"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const attachment =
|
||||
file.name.trim().length > 0
|
||||
? file
|
||||
: new File([file], getClipboardAttachmentName(file, index), {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
});
|
||||
void composer.addAttachment(attachment).catch(() => {
|
||||
// The scoped composer event above presents the adapter's actionable error message.
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AssistantComposerSurfaceContext.Provider value={contextValue}>
|
||||
<div ref={elementRef} style={{ display: "contents" }} onPasteCapture={handlePasteCapture}>
|
||||
<AssistantComposerSurfaceEvents />
|
||||
{children}
|
||||
</div>
|
||||
</AssistantComposerSurfaceContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The current react-lexical FocusPlugin focuses every mounted composer when a run starts. Mount
|
||||
* this once above all surfaces so programmatic focus returns to the element/surface the user was
|
||||
* actually interacting with after those listeners have run.
|
||||
*/
|
||||
export const AssistantRunFocusPreserver = () => {
|
||||
useAuiEvent("thread.runStart", () => {
|
||||
const focusedBeforeRun = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const surfaceIdBeforeRun = getSurfaceIdForElement(focusedBeforeRun);
|
||||
|
||||
const restoreFocus = () => {
|
||||
const surfaceIdAfterRun = getSurfaceIdForElement(document.activeElement);
|
||||
if (!surfaceIdAfterRun || surfaceIdAfterRun === surfaceIdBeforeRun) return;
|
||||
|
||||
if (focusedBeforeRun?.isConnected) focusedBeforeRun.focus({ preventScroll: true });
|
||||
if (
|
||||
getSurfaceIdForElement(document.activeElement) !== surfaceIdBeforeRun &&
|
||||
document.activeElement instanceof HTMLElement
|
||||
) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
};
|
||||
|
||||
queueMicrotask(() => queueMicrotask(restoreFocus));
|
||||
requestAnimationFrame(restoreFocus);
|
||||
});
|
||||
return null;
|
||||
};
|
||||
|
||||
interface AssistantComposerSurfaceProviderProps extends PropsWithChildren {
|
||||
surfaceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives one rendered assistant surface a local composer while preserving the shared conversation.
|
||||
* Every component below this boundary can keep using the normal assistant-ui primitives and hooks.
|
||||
*/
|
||||
export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: AssistantComposerSurfaceProviderProps) => {
|
||||
const parentAui = useAui();
|
||||
const instanceId = useId();
|
||||
const resolvedSurfaceId = `${surfaceId}:${instanceId}`;
|
||||
// oxlint-disable-next-line no-underscore-dangle -- required to derive a scoped runtime without cloning the shared thread.
|
||||
const sharedRuntime = parentAui.threads().__internal_getAssistantRuntime?.();
|
||||
const surfaceRuntime = useMemo(() => {
|
||||
if (!sharedRuntime) throw new Error("Assistant runtime is not available for this composer surface");
|
||||
return createAssistantSurfaceRuntime(sharedRuntime);
|
||||
}, [sharedRuntime]);
|
||||
useEffect(() => () => surfaceRuntime.dispose(), [surfaceRuntime]);
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider aui={parentAui} runtime={surfaceRuntime}>
|
||||
<AssistantComposerSurfaceBoundary surfaceId={resolvedSurfaceId}>{children}</AssistantComposerSurfaceBoundary>
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getSafeAssistantHttpUrl } from "./assistant-url";
|
||||
|
||||
describe("getSafeAssistantHttpUrl", () => {
|
||||
test("allows ordinary HTTP and HTTPS source URLs", () => {
|
||||
expect(getSafeAssistantHttpUrl("https://example.com/source?q=homarr")).toBe("https://example.com/source?q=homarr");
|
||||
expect(getSafeAssistantHttpUrl("http://example.com/source")).toBe("http://example.com/source");
|
||||
});
|
||||
|
||||
test("rejects unsafe protocols, relative URLs, and embedded credentials", () => {
|
||||
expect(getSafeAssistantHttpUrl("javascript:alert(1)")).toBeNull();
|
||||
expect(getSafeAssistantHttpUrl("data:text/html;base64,PGgxPkJhZDwvaDE+")).toBeNull();
|
||||
expect(getSafeAssistantHttpUrl("/manage/custom-widgets")).toBeNull();
|
||||
expect(getSafeAssistantHttpUrl("https://user:secret@example.com/source")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export const getSafeAssistantHttpUrl = (source: unknown) => {
|
||||
if (typeof source !== "string" || !source) return null;
|
||||
|
||||
try {
|
||||
const url = new URL(source);
|
||||
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) return null;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import classes from "./assistant-panel.module.css";
|
||||
import { AssistantConversationSurface } from "./assistant-panel";
|
||||
import { getPendingAssistantAction } from "./assistant-pending-action";
|
||||
import { useAssistantPreferences, useHomarrAssistant } from "./assistant-context";
|
||||
import { AssistantComposerSurfaceProvider } from "./assistant-runtime-provider";
|
||||
|
||||
export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => {
|
||||
const assistant = useHomarrAssistant();
|
||||
@@ -21,7 +22,11 @@ export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) =
|
||||
return <UnavailableAssistantWidget description={assistant.unavailableDescription} />;
|
||||
}
|
||||
|
||||
return <EnabledAssistantBoardWidget {...props} />;
|
||||
return (
|
||||
<AssistantComposerSurfaceProvider surfaceId={`board-widget-${props.itemId ?? "preview"}`}>
|
||||
<EnabledAssistantBoardWidget {...props} />
|
||||
</AssistantComposerSurfaceProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const EnabledAssistantBoardWidget = ({ options, width, height, isEditMode }: WidgetComponentProps<"assistant">) => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Authoring inputs that use templateLines can only validate the joined JSX after the tRPC input
|
||||
* parser has run. Preserve those Zod issues as a client-safe BAD_REQUEST instead of letting tRPC
|
||||
* turn them into a generic internal error.
|
||||
*/
|
||||
export function parseCustomWidgetAuthoringInput<T>(parse: () => T): T {
|
||||
try {
|
||||
return parse();
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "The custom widget authoring input is invalid",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
customWidgetCreateSchema,
|
||||
customWidgetDefinitionSchema,
|
||||
customWidgetUpdateSchema,
|
||||
normalizeCustomWidgetAuthoringDefinition,
|
||||
normalizeCustomWidgetAuthoringUpdate,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../../trpc";
|
||||
import { parseCustomWidgetAuthoringInput } from "./authoring-validation";
|
||||
import { insertCustomWidgetDefinition } from "./definition-insert";
|
||||
import { managementQueryProcedures } from "./management-queries";
|
||||
import { metadataProcedures } from "./metadata-procedures";
|
||||
@@ -38,20 +41,43 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
|
||||
create: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Create one validated Custom JSX widget." } })
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Create a Custom JSX widget only after customWidget_validate, customWidget_previewCreate, and every returned preview query succeed. Prefer templateLines for multiline JSX. Returns a client-navigable edit link.",
|
||||
},
|
||||
})
|
||||
.input(customWidgetCreateSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { secrets, ...candidate } = input;
|
||||
const definition = customWidgetDefinitionSchema.parse(candidate);
|
||||
const definition = parseCustomWidgetAuthoringInput(() => normalizeCustomWidgetAuthoringDefinition(candidate));
|
||||
assertSecretSources(definition.sources, secrets);
|
||||
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
|
||||
logger.info("Created custom widget definition", { id, name: definition.name });
|
||||
return { id };
|
||||
return {
|
||||
id,
|
||||
managementPath: `/manage/custom-widgets/edit/${id}`,
|
||||
nextAction: {
|
||||
type: "place-custom-widget" as const,
|
||||
widgetKind: "customApi" as const,
|
||||
options: { definitionId: id },
|
||||
whenTargetIsKnown: "Call configure_widget now with the requested board and these exact widget options.",
|
||||
whenTargetIsUnknown:
|
||||
"Call ask_user now with 'Place on a board' and 'Leave unplaced'. Never ask this choice in prose.",
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
update: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Update one Custom JSX widget." } })
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Update one Custom JSX widget. Prefer templateLines for multiline JSX changes. Returns a client-navigable edit link.",
|
||||
},
|
||||
})
|
||||
.input(customWidgetUpdateSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await ctx.db.query.customWidgetDefinitions.findFirst({
|
||||
@@ -60,8 +86,11 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
|
||||
const current = parseStoredCustomWidgetDefinition(existing);
|
||||
const { id, secrets, ...changes } = input;
|
||||
const definition = customWidgetDefinitionSchema.parse({ ...current, ...changes });
|
||||
const { id, secrets, ...authoringChanges } = input;
|
||||
const definition = parseCustomWidgetAuthoringInput(() => {
|
||||
const changes = normalizeCustomWidgetAuthoringUpdate(authoringChanges);
|
||||
return customWidgetDefinitionSchema.parse({ ...current, ...changes });
|
||||
});
|
||||
if (secrets) assertSecretSources(definition.sources, secrets);
|
||||
const definitionChanges = { ...serializeCustomWidgetDefinition(definition), updatedAt: new Date() };
|
||||
const secretRows = secrets?.map((secret) => ({
|
||||
@@ -178,6 +207,7 @@ export const customWidgetRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
logger.info("Updated custom widget definition", { id });
|
||||
return { id, managementPath: `/manage/custom-widgets/edit/${id}` };
|
||||
}),
|
||||
|
||||
...secretProcedures,
|
||||
|
||||
@@ -65,6 +65,7 @@ export const managementQueryProcedures = {
|
||||
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Legacy custom widget not found" });
|
||||
return {
|
||||
id: definition.id,
|
||||
managementPath: "/manage/custom-widgets",
|
||||
version: 1 as const,
|
||||
prompt: buildLegacyCustomWidgetMigrationPrompt(
|
||||
definition,
|
||||
@@ -85,6 +86,7 @@ export const managementQueryProcedures = {
|
||||
if (!definition) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
return {
|
||||
id: definition.id,
|
||||
managementPath: `/manage/custom-widgets/edit/${definition.id}`,
|
||||
...parseStoredCustomWidgetDefinition(definition),
|
||||
enabled: definition.enabled,
|
||||
createdAt: definition.createdAt,
|
||||
|
||||
@@ -26,6 +26,7 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets
|
||||
});
|
||||
return {
|
||||
id: definition.id,
|
||||
managementPath: `/manage/custom-widgets/edit/${definition.id}`,
|
||||
name: definition.name,
|
||||
description: definition.description ?? undefined,
|
||||
iconUrl: definition.iconUrl ?? undefined,
|
||||
@@ -45,6 +46,7 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets
|
||||
const configuredSecrets = new Set(definition.secrets.map((secret) => `${secret.sourceId}:${secret.kind}`));
|
||||
return {
|
||||
id: definition.id,
|
||||
managementPath: `/manage/custom-widgets/edit/${definition.id}`,
|
||||
name: widget.name,
|
||||
description: widget.description,
|
||||
iconUrl: widget.iconUrl,
|
||||
@@ -71,6 +73,7 @@ export function mapCustomWidgetListItem(definition: StoredDefinition & { secrets
|
||||
export function mapLegacyCustomWidgetListItem(definition: LegacyDefinition) {
|
||||
return {
|
||||
id: definition.id,
|
||||
managementPath: "/manage/custom-widgets",
|
||||
name: definition.name,
|
||||
description: definition.description ?? undefined,
|
||||
iconUrl: definition.iconUrl ?? undefined,
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { CUSTOM_WIDGET_MCP_AUTHORING_PROMPT } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { getCustomWidgetSkill } from "@homarr/custom-widgets/authoring-resources";
|
||||
import { customWidgetImportSchema, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core";
|
||||
import {
|
||||
getCustomWidgetComponent,
|
||||
getCustomWidgetComponentCatalog,
|
||||
getCustomWidgetExample,
|
||||
getCustomWidgetSharedProps,
|
||||
getCustomWidgetSkill,
|
||||
} from "@homarr/custom-widgets/authoring-resources";
|
||||
import {
|
||||
customWidgetAuthoringDefinitionSchema,
|
||||
getCustomWidgetJsonSchema,
|
||||
normalizeCustomWidgetAuthoringDefinition,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
|
||||
@@ -28,25 +39,133 @@ export const metadataProcedures = {
|
||||
|
||||
getSkill: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Get the portable Homarr Custom Widget skill." } })
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Load the complete Homarr Custom Widget authoring skill in one response, including SKILL.md and every bundled reference. Call this before authoring, repairing, validating, previewing, or creating a custom widget.",
|
||||
},
|
||||
})
|
||||
.query(() => getCustomWidgetSkill()),
|
||||
|
||||
getComponentCatalog: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"List a compact index of supported Custom JSX component names, categories, shared prop names, safety policies, and example IDs. Fetch only the named details needed for the widget.",
|
||||
},
|
||||
})
|
||||
.query(() => getCustomWidgetComponentCatalog()),
|
||||
|
||||
getComponent: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get the installed Custom JSX documentation, allowed props, blocked props, and accessibility requirements for one named component.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ name: z.string().trim().min(1).max(128).describe("A component name from the catalog.") }))
|
||||
.query(({ input }) => {
|
||||
const component = getCustomWidgetComponent(input.name);
|
||||
if (!component) throw new TRPCError({ code: "NOT_FOUND", message: "Custom JSX component not found" });
|
||||
return component;
|
||||
}),
|
||||
|
||||
getSharedProps: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get documentation for only the named shared Custom JSX props. Collect the shared prop names needed by all selected components and fetch them together once.",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
names: z
|
||||
.array(z.string().trim().min(1).max(128))
|
||||
.min(1)
|
||||
.max(64)
|
||||
.describe("Shared prop names from customWidget_getComponentCatalog.sharedProps.names."),
|
||||
}),
|
||||
)
|
||||
.query(({ input }) => getCustomWidgetSharedProps(input.names)),
|
||||
|
||||
getExample: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get one installed Custom JSX example by its catalog ID. Fetch only an example relevant to the requested interaction pattern.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ name: z.string().trim().min(1).max(128).describe("An example ID from the catalog.") }))
|
||||
.query(({ input }) => {
|
||||
const example = getCustomWidgetExample(input.name);
|
||||
if (!example) throw new TRPCError({ code: "NOT_FOUND", message: "Custom JSX example not found" });
|
||||
return example;
|
||||
}),
|
||||
|
||||
validate: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Validate one Custom JSX widget without saving it." } })
|
||||
.input(z.object({ widget: z.unknown() }))
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Validate one complete Custom JSX widget without saving it. Multiline JSX may be supplied as templateLines; the result returns precise issue paths to repair before previewing.",
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
widget: z
|
||||
.unknown()
|
||||
.describe("A complete Homarr Custom Widget v2 definition using either template or templateLines."),
|
||||
}),
|
||||
)
|
||||
.query(({ input }) => {
|
||||
const result = customWidgetImportSchema.safeParse(input.widget);
|
||||
if (!result.success) {
|
||||
const authoringResult = customWidgetAuthoringDefinitionSchema.safeParse(input.widget);
|
||||
if (!authoringResult.success) {
|
||||
return {
|
||||
valid: false as const,
|
||||
issues: result.error.issues.map((issue) => ({
|
||||
issues: authoringResult.error.issues.map((issue) => ({
|
||||
path: issue.path.map(String).join("."),
|
||||
code: issue.code,
|
||||
message: issue.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return { valid: true as const, issues: [], widget: result.data };
|
||||
try {
|
||||
const widget = normalizeCustomWidgetAuthoringDefinition(authoringResult.data);
|
||||
return {
|
||||
valid: true as const,
|
||||
issues: [],
|
||||
summary: {
|
||||
name: widget.name,
|
||||
sourceIds: Object.keys(widget.sources),
|
||||
requestIds: Object.keys(widget.requests),
|
||||
optionIds: Object.keys(widget.options),
|
||||
templateLineCount: widget.template.split("\n").length,
|
||||
},
|
||||
nextStep:
|
||||
"Reuse the validated authoring definition with customWidget_previewCreate, then run every returned query.",
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
valid: false as const,
|
||||
issues: error.issues.map((issue) => ({
|
||||
path: issue.path.map(String).join("."),
|
||||
code: issue.code,
|
||||
message: issue.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
};
|
||||
@@ -3,22 +3,25 @@ import { z } from "zod/v4";
|
||||
|
||||
import { decryptSecret } from "@homarr/common/server";
|
||||
import {
|
||||
customWidgetDefinitionSchema,
|
||||
collectCustomWidgetRequestReferences,
|
||||
customWidgetAuthoringDefinitionSchema,
|
||||
customWidgetSecretsInputSchema,
|
||||
getCustomWidgetConfirmation,
|
||||
getCustomWidgetDefaultOptions,
|
||||
normalizeCustomWidgetAuthoringDefinition,
|
||||
validateCustomWidgetOptions,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
import { eq } from "@homarr/db";
|
||||
import { customWidgetDefinitions } from "@homarr/db/schema";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { parseCustomWidgetAuthoringInput } from "./authoring-validation";
|
||||
import { createPreviewSession, getPreviewSession } from "./preview-sessions";
|
||||
import { hasSameSecretBinding, requiredSecretKinds } from "./secret-policy";
|
||||
import { parseStoredCustomWidgetDefinition } from "./stored-definition";
|
||||
|
||||
const previewCreateInputSchema = z.object({
|
||||
definition: customWidgetDefinitionSchema,
|
||||
definition: customWidgetAuthoringDefinitionSchema,
|
||||
secrets: customWidgetSecretsInputSchema.default([]),
|
||||
definitionId: z.string().optional(),
|
||||
options: z.record(z.string(), z.unknown()).optional(),
|
||||
@@ -26,11 +29,20 @@ const previewCreateInputSchema = z.object({
|
||||
|
||||
const previewCreateProcedure = permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ mcp: { enabled: true, description: "Create a short-lived preview session for one unsaved custom widget." } })
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Create a short-lived preview for an unsaved custom widget after validation. Prefer templateLines for multiline JSX. The result lists every query that must be tested with customWidget_previewQuery before saving.",
|
||||
},
|
||||
})
|
||||
.input(previewCreateInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const options = input.options ?? getCustomWidgetDefaultOptions(input.definition.options);
|
||||
const optionIssues = validateCustomWidgetOptions(input.definition.options, options);
|
||||
const definition = parseCustomWidgetAuthoringInput(() =>
|
||||
normalizeCustomWidgetAuthoringDefinition(input.definition),
|
||||
);
|
||||
const options = input.options ?? getCustomWidgetDefaultOptions(definition.options);
|
||||
const optionIssues = validateCustomWidgetOptions(definition.options, options);
|
||||
if (optionIssues.length > 0) {
|
||||
const issue = optionIssues[0];
|
||||
throw new TRPCError({
|
||||
@@ -48,7 +60,7 @@ const previewCreateProcedure = permissionRequiredProcedure
|
||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Custom widget definition not found" });
|
||||
const existingDefinition = parseStoredCustomWidgetDefinition(existing);
|
||||
for (const [sourceId, existingSource] of Object.entries(existingDefinition.sources)) {
|
||||
const submittedSource = input.definition.sources[sourceId];
|
||||
const submittedSource = definition.sources[sourceId];
|
||||
const hasStoredSecrets = existing.secrets.some((secret) => secret.sourceId === sourceId);
|
||||
if (!submittedSource || !hasStoredSecrets || hasSameSecretBinding(existingSource, submittedSource)) continue;
|
||||
|
||||
@@ -65,7 +77,7 @@ const previewCreateProcedure = permissionRequiredProcedure
|
||||
}
|
||||
for (const secret of existing.secrets) {
|
||||
const existingSource = existingDefinition.sources[secret.sourceId];
|
||||
const submittedSource = input.definition.sources[secret.sourceId];
|
||||
const submittedSource = definition.sources[secret.sourceId];
|
||||
if (
|
||||
existingSource &&
|
||||
submittedSource &&
|
||||
@@ -78,7 +90,7 @@ const previewCreateProcedure = permissionRequiredProcedure
|
||||
}
|
||||
|
||||
const invalid = secrets.find((secret) => {
|
||||
const source = input.definition.sources[secret.sourceId];
|
||||
const source = definition.sources[secret.sourceId];
|
||||
const authType = typeof source?.auth === "string" ? source.auth : source?.auth.type;
|
||||
const kinds =
|
||||
authType === "basic"
|
||||
@@ -94,26 +106,32 @@ const previewCreateProcedure = permissionRequiredProcedure
|
||||
|
||||
const previewSession = await createPreviewSession({
|
||||
userId: ctx.session.user.id,
|
||||
sources: input.definition.sources,
|
||||
requests: input.definition.requests,
|
||||
name: input.definition.name,
|
||||
template: input.definition.template,
|
||||
optionDefinitions: input.definition.options,
|
||||
sources: definition.sources,
|
||||
requests: definition.requests,
|
||||
name: definition.name,
|
||||
template: definition.template,
|
||||
optionDefinitions: definition.options,
|
||||
options,
|
||||
secrets,
|
||||
definitionId: input.definitionId,
|
||||
});
|
||||
const previewPath = `/manage/custom-widgets/preview/${previewSession.id}`;
|
||||
return {
|
||||
success: true as const,
|
||||
previewSession,
|
||||
previewUrl: new URL(
|
||||
`/manage/custom-widgets/preview/${previewSession.id}`,
|
||||
ctx.baseUrl ?? "http://localhost",
|
||||
).toString(),
|
||||
definition: {
|
||||
template: input.definition.template,
|
||||
defaultOptions: getCustomWidgetDefaultOptions(input.definition.options),
|
||||
},
|
||||
previewPath,
|
||||
previewUrl: new URL(previewPath, ctx.baseUrl ?? "http://localhost").toString(),
|
||||
queries: Object.entries(definition.requests).flatMap(([requestId, request]) => {
|
||||
if (request.kind !== "query") return [];
|
||||
return [
|
||||
{
|
||||
requestId,
|
||||
trigger: request.trigger,
|
||||
parameterNames: [...collectCustomWidgetRequestReferences(request).params],
|
||||
nextStep: `Call customWidget_previewQuery with sessionId '${previewSession.id}' and requestId '${requestId}'.`,
|
||||
},
|
||||
];
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -10,9 +10,16 @@ import { resolveCustomWidgetRequestValues } from "./request-manifest";
|
||||
const logger = createLogger({ module: "custom-widget-preview" });
|
||||
|
||||
export const previewSessionRequestSchema = z.object({
|
||||
sessionId: z.string().min(1),
|
||||
requestId: z.string().min(1).max(64),
|
||||
params: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).default({}),
|
||||
sessionId: z.string().min(1).describe("The previewSession.id returned by customWidget_previewCreate."),
|
||||
requestId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.describe("One query requestId from customWidget_previewCreate.queries. Test every returned query."),
|
||||
params: z
|
||||
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
|
||||
.default({})
|
||||
.describe("Invocation parameters required by a manual query. Load queries use an empty object."),
|
||||
});
|
||||
|
||||
export const recordPreviewJournal = async (...args: Parameters<typeof appendPreviewJournal>) => {
|
||||
|
||||
@@ -19,7 +19,8 @@ export const previewQueryProcedures = {
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description: "Run one named query from a short-lived custom widget preview session.",
|
||||
description:
|
||||
"Execute one real API query from customWidget_previewCreate.queries and return its HTTP status plus parsed data so request paths and template bindings can be verified. Call once for every returned query before customWidget_create.",
|
||||
},
|
||||
})
|
||||
.input(previewSessionRequestSchema)
|
||||
|
||||
@@ -100,6 +100,86 @@ async function insertLegacyJellyfin(db: ReturnType<typeof createDb>) {
|
||||
const secret = { sourceId: "default", kind: "apiKey" as const, value: "test-secret" };
|
||||
|
||||
describe("custom widget definition persistence", () => {
|
||||
test("creates and previews MCP-friendly multiline templateLines without losing the JSX", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const { template, ...definition } = jellyfin;
|
||||
const templateLines = template.split("\n");
|
||||
|
||||
const validation = await caller.validate({ widget: { ...definition, templateLines } });
|
||||
expect(validation).toMatchObject({
|
||||
valid: true,
|
||||
summary: {
|
||||
name: jellyfin.name,
|
||||
sourceIds: Object.keys(jellyfin.sources),
|
||||
requestIds: Object.keys(jellyfin.requests),
|
||||
templateLineCount: templateLines.length,
|
||||
},
|
||||
});
|
||||
expect(validation).not.toHaveProperty("widget");
|
||||
expect(validation).not.toHaveProperty("templateLines");
|
||||
expect(Buffer.byteLength(JSON.stringify(validation), "utf8")).toBeLessThan(2_000);
|
||||
const preview = await caller.previewCreate({ definition: { ...definition, templateLines }, secrets: [] });
|
||||
expect(preview).toMatchObject({
|
||||
success: true,
|
||||
previewPath: `/manage/custom-widgets/preview/${preview.previewSession.id}`,
|
||||
});
|
||||
expect(preview).not.toHaveProperty("definition");
|
||||
expect(Buffer.byteLength(JSON.stringify(preview), "utf8")).toBeLessThan(4_000);
|
||||
expect(preview.queries.map(({ requestId }) => requestId)).toEqual(Object.keys(jellyfin.requests));
|
||||
expect(preview.queries.every(({ nextStep }) => nextStep.includes("customWidget_previewQuery"))).toBe(true);
|
||||
|
||||
const created = await caller.create({ ...definition, templateLines, secrets: [] });
|
||||
expect(created.managementPath).toBe(`/manage/custom-widgets/edit/${created.id}`);
|
||||
expect(created.nextAction).toEqual({
|
||||
type: "place-custom-widget",
|
||||
widgetKind: "customApi",
|
||||
options: { definitionId: created.id },
|
||||
whenTargetIsKnown: "Call configure_widget now with the requested board and these exact widget options.",
|
||||
whenTargetIsUnknown:
|
||||
"Call ask_user now with 'Place on a board' and 'Leave unplaced'. Never ask this choice in prose.",
|
||||
});
|
||||
expect((await caller.get({ id: created.id })).template).toBe(template);
|
||||
});
|
||||
|
||||
test("updates a saved widget with MCP-friendly multiline templateLines", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const created = await caller.create({ ...jellyfin, secrets: [] });
|
||||
const updatedTemplate = jellyfin.template.replace("Jellyfin library", "Updated Jellyfin library");
|
||||
|
||||
const updated = await caller.update({ id: created.id, templateLines: updatedTemplate.split("\n") });
|
||||
|
||||
expect(updated.managementPath).toBe(`/manage/custom-widgets/edit/${created.id}`);
|
||||
expect((await caller.get({ id: created.id })).template).toBe(updatedTemplate);
|
||||
});
|
||||
|
||||
test("reports joined templateLines validation issues as BAD_REQUEST for create and update", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const { template: _template, ...definition } = jellyfin;
|
||||
const invalidTemplateLines = ["<Stack>", ' <img src="https://example.com/logo.png" />', "</Stack>"];
|
||||
|
||||
const createError = await caller
|
||||
.create({ ...definition, templateLines: invalidTemplateLines, secrets: [] })
|
||||
.catch((error: unknown) => error);
|
||||
expect(createError).toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
cause: { issues: [expect.objectContaining({ path: ["template"] })] },
|
||||
});
|
||||
expect(await db.query.customWidgetDefinitions.findMany()).toHaveLength(0);
|
||||
|
||||
const created = await caller.create({ ...jellyfin, secrets: [] });
|
||||
const updateError = await caller
|
||||
.update({ id: created.id, templateLines: invalidTemplateLines })
|
||||
.catch((error: unknown) => error);
|
||||
expect(updateError).toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
cause: { issues: [expect.objectContaining({ path: ["template"] })] },
|
||||
});
|
||||
expect((await caller.get({ id: created.id })).template).toBe(jellyfin.template);
|
||||
});
|
||||
|
||||
test("atomically creates a v2 replacement while preserving the legacy widget and its stable ID", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const legacy = await insertLegacyJellyfin(db);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import { users } from "@homarr/db/schema";
|
||||
import { CUSTOM_WIDGET_STARTER } from "@homarr/custom-widgets/core";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
executeRequest: vi.fn(async (_input: unknown) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
data: { fixtures: [{ id: "match-1", home_team: { name: "PSG" }, away_team: { name: "Lyon" } }] },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../../custom-widget/request-executor", () => ({
|
||||
executeCustomWidgetRequest: mocks.executeRequest,
|
||||
invalidateCustomWidgetResponseCache: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../custom-widget/request-limits", () => ({
|
||||
acquireCustomWidgetRequestLimit: vi.fn(async () => async () => undefined),
|
||||
}));
|
||||
|
||||
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
|
||||
|
||||
describe("custom widget agent preview workflow", () => {
|
||||
beforeEach(() => mocks.executeRequest.mockClear());
|
||||
|
||||
test("runs a named preview query and returns the real response shape to the agent", async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
const session = {
|
||||
user: { id: userId, permissions: ["admin"], colorScheme: "light" },
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
} satisfies Session;
|
||||
const caller = customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const { template, ...definition } = CUSTOM_WIDGET_STARTER;
|
||||
const preview = await caller.previewCreate({
|
||||
definition: {
|
||||
...definition,
|
||||
requests: { fixtures: { path: "/fixtures", query: { season: 2026 } } },
|
||||
templateLines: template.split("\n"),
|
||||
},
|
||||
secrets: [],
|
||||
});
|
||||
|
||||
expect(preview.queries).toEqual([
|
||||
expect.objectContaining({ requestId: "fixtures", trigger: "load", parameterNames: [] }),
|
||||
]);
|
||||
await expect(
|
||||
caller.previewQuery({ sessionId: preview.previewSession.id, requestId: "fixtures", params: {} }),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { fixtures: [expect.objectContaining({ id: "match-1" })] },
|
||||
}),
|
||||
);
|
||||
const request = mocks.executeRequest.mock.calls[0]?.[0] as { targetUrl: string | URL; kind: string } | undefined;
|
||||
expect(String(request?.targetUrl)).toBe("https://example.com/fixtures?season=2026");
|
||||
expect(request?.kind).toBe("query");
|
||||
});
|
||||
|
||||
test("reports joined preview templateLines validation issues as BAD_REQUEST", async () => {
|
||||
const db = createDb();
|
||||
const userId = createId();
|
||||
await db.insert(users).values({ id: userId });
|
||||
const session = {
|
||||
user: { id: userId, permissions: ["admin"], colorScheme: "light" },
|
||||
expires: new Date(Date.now() + 60_000).toISOString(),
|
||||
} satisfies Session;
|
||||
const caller = customWidgetRouter.createCaller({ db, deviceType: undefined, session });
|
||||
const { template: _template, ...definition } = CUSTOM_WIDGET_STARTER;
|
||||
|
||||
const error = await caller
|
||||
.previewCreate({
|
||||
definition: {
|
||||
...definition,
|
||||
templateLines: ["<Stack>", ' <img src="https://example.com/logo.png" />', "</Stack>"],
|
||||
},
|
||||
secrets: [],
|
||||
})
|
||||
.catch((cause: unknown) => cause);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
cause: { issues: [expect.objectContaining({ path: ["template"] })] },
|
||||
});
|
||||
expect(mocks.executeRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,10 @@ const MCP_TOOL_ALLOWLIST = {
|
||||
"calendar_findAllEvents",
|
||||
"customWidget_get",
|
||||
"customWidget_getAuthoringPrompt",
|
||||
"customWidget_getComponent",
|
||||
"customWidget_getComponentCatalog",
|
||||
"customWidget_getExample",
|
||||
"customWidget_getSharedProps",
|
||||
"customWidget_getSkill",
|
||||
"customWidget_legacyMigrationPrompt",
|
||||
"customWidget_list",
|
||||
@@ -186,6 +190,10 @@ describe("custom widget authoring procedure access", () => {
|
||||
const calls = [
|
||||
unauthenticatedCaller.customWidget.schema(),
|
||||
unauthenticatedCaller.customWidget.getAuthoringPrompt(),
|
||||
unauthenticatedCaller.customWidget.getComponentCatalog(),
|
||||
unauthenticatedCaller.customWidget.getComponent({ name: "Stack" }),
|
||||
unauthenticatedCaller.customWidget.getExample({ name: "service-dashboard" }),
|
||||
unauthenticatedCaller.customWidget.getSharedProps({ names: ["p"] }),
|
||||
unauthenticatedCaller.customWidget.getSkill(),
|
||||
unauthenticatedCaller.customWidget.list(),
|
||||
unauthenticatedCaller.customWidget.validate({ widget: {} }),
|
||||
@@ -208,6 +216,10 @@ describe("custom widget authoring procedure access", () => {
|
||||
const calls = [
|
||||
nonAdminCaller.customWidget.schema(),
|
||||
nonAdminCaller.customWidget.getAuthoringPrompt(),
|
||||
nonAdminCaller.customWidget.getComponentCatalog(),
|
||||
nonAdminCaller.customWidget.getComponent({ name: "Stack" }),
|
||||
nonAdminCaller.customWidget.getExample({ name: "service-dashboard" }),
|
||||
nonAdminCaller.customWidget.getSharedProps({ names: ["p"] }),
|
||||
nonAdminCaller.customWidget.getSkill(),
|
||||
nonAdminCaller.customWidget.secretSet({
|
||||
definitionId: "widget",
|
||||
@@ -233,5 +245,27 @@ describe("custom widget authoring procedure access", () => {
|
||||
await expect(adminCaller.customWidget.getSkill()).resolves.toMatchObject({
|
||||
name: expect.any(String),
|
||||
});
|
||||
await expect(adminCaller.customWidget.getComponentCatalog()).resolves.toMatchObject({
|
||||
components: expect.arrayContaining([expect.objectContaining({ name: "Stack" })]),
|
||||
examples: expect.arrayContaining([expect.objectContaining({ id: "service-dashboard" })]),
|
||||
});
|
||||
await expect(adminCaller.customWidget.getComponent({ name: "Stack" })).resolves.toMatchObject({
|
||||
name: "Stack",
|
||||
props: expect.anything(),
|
||||
});
|
||||
await expect(adminCaller.customWidget.getExample({ name: "service-dashboard" })).resolves.toMatchObject({
|
||||
id: "service-dashboard",
|
||||
widget: expect.objectContaining({ $schema: "homarr-custom-widget-v2" }),
|
||||
});
|
||||
await expect(adminCaller.customWidget.getSharedProps({ names: ["p", "m"] })).resolves.toMatchObject({
|
||||
props: [expect.objectContaining({ name: "p" }), expect.objectContaining({ name: "m" })],
|
||||
notFound: [],
|
||||
});
|
||||
await expect(adminCaller.customWidget.getComponent({ name: "NotAComponent" })).rejects.toEqual(
|
||||
expect.objectContaining<Partial<TRPCError>>({ code: "NOT_FOUND" }),
|
||||
);
|
||||
await expect(adminCaller.customWidget.getExample({ name: "not-an-example" })).rejects.toEqual(
|
||||
expect.objectContaining<Partial<TRPCError>>({ code: "NOT_FOUND" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -127,7 +127,11 @@ export const CUSTOM_WIDGET_AUTHORING_PROMPT = AUTHORING_PROMPT;
|
||||
|
||||
export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX v2 widget at a time.
|
||||
|
||||
Read the live schema and only the component resources needed for the design. Construct a credential-free widget, validate it, create a preview, test queries and simulated actions, visually inspect it, then create it. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. The live resources are authoritative for this Homarr release.
|
||||
First call customWidget_getSkill. Its single lazy response contains the complete installed SKILL.md and every bundled reference; follow all of it. Read the live schema and compact component catalog. Fetch only the named component and example resources needed for the design. Component resources contain component-specific props; if shared props are needed, collect their catalog names and fetch those docs together once with customWidget_getSharedProps. The live resources are authoritative for this Homarr release.
|
||||
|
||||
Follow this lifecycle without skipping steps: construct one credential-free definition; call customWidget_validate; repair every reported issue; call customWidget_previewCreate; call customWidget_previewQuery once for every query returned by that preview (supplying required manual parameters); inspect the real response shape and HTTP status; adjust paths and template bindings; validate and preview again after changes; open or provide the returned previewPath for visual review; only then call customWidget_create. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate, preview, and create tool inputs.
|
||||
|
||||
Do not claim a query works when it has not returned a successful preview response. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. Use simulated preview actions unless the user explicitly enables live preview actions.
|
||||
|
||||
Use the lean keyed sources, requests, and options contract. Bind saved values directly with $option and invocation values with $param. Load queries cannot use $param. Keep the design responsive, accessible, theme-safe, loading-aware, empty-aware, and error-aware.
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { customJsxAuthoringCatalog, getCustomJsxComponentProps } from "./component-catalog";
|
||||
import {
|
||||
customJsxAuthoringCatalog,
|
||||
customJsxCatalogGlobalPropByName,
|
||||
resolveCustomJsxPropDescriptor,
|
||||
} from "./component-catalog";
|
||||
import { customJsxExamples } from "./examples";
|
||||
import { customJsxTablerIconNames } from "./tabler-icons";
|
||||
|
||||
@@ -7,7 +11,58 @@ export const CUSTOM_WIDGET_SKILL_SOURCE_URL =
|
||||
"https://github.com/homarr-labs/homarr/tree/HEAD/.agents/skills/homarr-custom-widget";
|
||||
export const CUSTOM_WIDGET_SKILL_INSTALL_COMMAND =
|
||||
"npx skills add https://github.com/homarr-labs/homarr --skill homarr-custom-widget";
|
||||
export const CUSTOM_WIDGET_SKILL_VERSION = "2.1.0";
|
||||
export const CUSTOM_WIDGET_SKILL_VERSION = "2.2.0";
|
||||
|
||||
export const CUSTOM_WIDGET_SKILL_REFERENCES = {
|
||||
"references/schema.md": `# Schema
|
||||
|
||||
\`\`\`ts
|
||||
interface HomarrCustomWidgetV2 {
|
||||
$schema: "homarr-custom-widget-v2";
|
||||
name: string;
|
||||
description?: string;
|
||||
iconUrl?: string;
|
||||
sources: Record<string, CustomWidgetSource>;
|
||||
requests: Record<string, CustomWidgetRequest>;
|
||||
options?: Record<string, CustomWidgetOption>;
|
||||
template: string;
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Sources require \`default\`. A request defaults to the default source, query, GET, load, inherited auth, and view permission. An action defaults to manual and modify permission. DELETE uses full permission and confirmation.
|
||||
|
||||
Use stable real URLs for public APIs and clear suggested URLs for self-hosted services. Homarr collects the installer's server URL, network scope, and credentials as source setup; credentials remain outside the manifest.
|
||||
|
||||
Paths use \`{option:name}\` and \`{param:name}\`. Query/body data uses \`{ "$option": "name" }\` and \`{ "$param": "name" }\`. Load queries cannot use params. Runtime parameter names and primitive values are inferred from references.
|
||||
|
||||
Every option has \`label\`, \`control\`, and \`default\`. Optional fields are \`description\`, \`choices\`, \`choicesFrom\`, \`min\`, \`max\`, \`step\`, \`advanced\`, and \`group\`.
|
||||
`,
|
||||
"references/runtime.md": `# Runtime
|
||||
|
||||
Templates read \`data.requestId\`, \`status.requestId\`, \`options.name\`, and temporary \`inputs.name\`. Status contains \`loading\`, \`ok\`, \`status\`, \`statusText\`, and \`error\`.
|
||||
|
||||
\`bind="search"\` creates an in-memory input. It is never persisted. Supply invocation values only through \`params\`, for example:
|
||||
|
||||
\`\`\`jsx
|
||||
<TextInput bind="search" label="Search" />
|
||||
<SubFetch requestId="search" params={{ query: inputs.search }}>
|
||||
{(items) => <Stack>{(items ?? []).map(item => <Text key={item.id}>{item.name}</Text>)}</Stack>}
|
||||
</SubFetch>
|
||||
\`\`\`
|
||||
|
||||
\`SubFetch\` with \`trigger="manual"\` renders its own load button. To make a card or image launch the request, pass that node through \`triggerContent\` and provide \`triggerAriaLabel\`; Homarr supplies the click and keyboard behavior. Its optional second callback argument contains \`{ ok, status, statusText, loading: false }\`; loading and request failures are rendered by \`SubFetch\` before the callback runs. Do not author \`onClick\` or a fetch callback.
|
||||
|
||||
Use expression callbacks for supported collection methods and trusted slots. Do not use callback blocks, IIFEs, authored recursion, or raw events. Bounded regex literals work only with safe string matching/replacement operations.
|
||||
`,
|
||||
"references/security.md": `# Security
|
||||
|
||||
All requests use Homarr's protected server executor. Source origin, network scope, DNS, redirects, SSRF, rate limits, permissions, size limits, timeouts, and encrypted credential injection remain enforced.
|
||||
|
||||
The JSX interpreter blocks imports, hooks, refs, raw event callbacks, browser requests, eval, arbitrary functions, prototype access, unsafe URLs, global CSS escape, arbitrary portals, bigint, statement blocks, IIFEs, and recursion. Regex literals must be bounded and reject backreferences, lookbehind, nested quantifiers, excessive length, and unsupported flags.
|
||||
|
||||
Credentials are stored separately and never exported or returned to an agent. A published self-hosted source URL is only a suggestion: installers must confirm or replace private and loopback URLs for their own Homarr deployment. Source origins cannot be controlled through widget options.
|
||||
`,
|
||||
} as const;
|
||||
|
||||
export const CUSTOM_WIDGET_SKILL_MD = `---
|
||||
name: homarr-custom-widget
|
||||
@@ -39,11 +94,19 @@ Preview-scoped configuration expires with the preview. To persist user-entered v
|
||||
When this skill is installed from the repository, optional offline details are in \`references/schema.md\`, \`references/runtime.md\`, and \`references/security.md\`. MCP-only clients should use the live Homarr resources instead.
|
||||
`;
|
||||
|
||||
const CUSTOM_WIDGET_SKILL_BUNDLE_MD = [
|
||||
CUSTOM_WIDGET_SKILL_MD.trimEnd(),
|
||||
...Object.entries(CUSTOM_WIDGET_SKILL_REFERENCES).map(
|
||||
([file, content]) => `\n\n---\n\n# Bundled file: ${file}\n\n${content.trimEnd()}`,
|
||||
),
|
||||
].join("");
|
||||
|
||||
export function getCustomWidgetSkill() {
|
||||
return {
|
||||
name: "homarr-custom-widget",
|
||||
version: CUSTOM_WIDGET_SKILL_VERSION,
|
||||
skillMd: CUSTOM_WIDGET_SKILL_MD,
|
||||
references: CUSTOM_WIDGET_SKILL_REFERENCES,
|
||||
skillsShUrl: CUSTOM_WIDGET_SKILLS_SH_URL,
|
||||
sourceUrl: CUSTOM_WIDGET_SKILL_SOURCE_URL,
|
||||
installCommand: CUSTOM_WIDGET_SKILL_INSTALL_COMMAND,
|
||||
@@ -51,11 +114,28 @@ export function getCustomWidgetSkill() {
|
||||
}
|
||||
|
||||
export function getCustomWidgetSkillContent() {
|
||||
return CUSTOM_WIDGET_SKILL_MD;
|
||||
return CUSTOM_WIDGET_SKILL_BUNDLE_MD;
|
||||
}
|
||||
|
||||
export function getCustomWidgetComponentCatalog() {
|
||||
return customJsxAuthoringCatalog;
|
||||
return {
|
||||
schemaVersion: customJsxAuthoringCatalog.schemaVersion,
|
||||
mantineVersion: customJsxAuthoringCatalog.mantineVersion,
|
||||
customWidgetVersion: customJsxAuthoringCatalog.customWidgetVersion,
|
||||
components: customJsxAuthoringCatalog.components.map(({ name, category, safety }) => ({ name, category, safety })),
|
||||
sharedProps: {
|
||||
count: customJsxAuthoringCatalog.globalProps.length,
|
||||
names: customJsxAuthoringCatalog.globalProps.map(({ name }) => name),
|
||||
fetchTool: "customWidget_getSharedProps" as const,
|
||||
maxPerRequest: 64,
|
||||
},
|
||||
blockedCapabilities: customJsxAuthoringCatalog.blockedCapabilities,
|
||||
examples: getCustomWidgetExampleCatalog(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getCustomWidgetExampleCatalog() {
|
||||
return customJsxExamples.map(({ id, title, description }) => ({ id, title, description }));
|
||||
}
|
||||
|
||||
export function getCustomWidgetComponent(name: string) {
|
||||
@@ -71,17 +151,37 @@ export function getCustomWidgetComponent(name: string) {
|
||||
category: component.category,
|
||||
safety: component.safety,
|
||||
description: component.description,
|
||||
props: getCustomJsxComponentProps(canonicalName),
|
||||
props: component.props.map((prop) => resolveCustomJsxPropDescriptor(prop)),
|
||||
blockedProps: component.blockedProps,
|
||||
bind: component.bind,
|
||||
subcomponents: component.subcomponents,
|
||||
accessibilityRequirements: component.accessibilityRequirements,
|
||||
documentationUrl: component.documentationUrl,
|
||||
sharedProps:
|
||||
component.safety === "denied"
|
||||
? undefined
|
||||
: {
|
||||
catalogField: "sharedProps.names" as const,
|
||||
fetchTool: "customWidget_getSharedProps" as const,
|
||||
maxPerRequest: 64,
|
||||
appliesExceptBlockedProps: true as const,
|
||||
},
|
||||
knownValues: component.name === "TablerIcon" ? { name: customJsxTablerIconNames } : undefined,
|
||||
deniedReason: component.deniedReason,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCustomWidgetSharedProps(names: readonly string[]) {
|
||||
const props: ReturnType<typeof resolveCustomJsxPropDescriptor>[] = [];
|
||||
const notFound: string[] = [];
|
||||
for (const name of new Set(names)) {
|
||||
const prop = customJsxCatalogGlobalPropByName.get(name);
|
||||
if (prop) props.push(resolveCustomJsxPropDescriptor(prop));
|
||||
else notFound.push(name);
|
||||
}
|
||||
return { props, notFound };
|
||||
}
|
||||
|
||||
export function getCustomWidgetExample(name: string) {
|
||||
return customJsxExamples.find((example) => example.id === name) ?? null;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { getCustomWidgetHttpUrlIssue } from "./url-policy";
|
||||
export * from "./options-schema";
|
||||
export * from "./request-schema";
|
||||
export const CUSTOM_WIDGET_SCHEMA = "homarr-custom-widget-v2";
|
||||
const CUSTOM_WIDGET_TEMPLATE_MAX_LENGTH = 50_000;
|
||||
|
||||
export const customWidgetBindingIdentifierSchema = z
|
||||
.string()
|
||||
@@ -30,7 +31,7 @@ export const customWidgetBindingIdentifierSchema = z
|
||||
export const customJsxTemplateSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(50_000)
|
||||
.max(CUSTOM_WIDGET_TEMPLATE_MAX_LENGTH)
|
||||
.superRefine((template, ctx) => {
|
||||
for (const diagnostic of validateCustomJsxTemplate(template)) {
|
||||
if (diagnostic.severity === "error") {
|
||||
@@ -193,13 +194,84 @@ function validateTemplateRequests(template: string, requests: Record<string, Cus
|
||||
|
||||
export type HomarrCustomWidgetV2 = z.infer<typeof customWidgetDefinitionSchema>;
|
||||
export type HomarrCustomWidgetV2Input = z.input<typeof customWidgetDefinitionSchema>;
|
||||
export const customWidgetCreateSchema = customWidgetDefinitionSchema.safeExtend({
|
||||
|
||||
const customWidgetTemplateLinesSchema = z
|
||||
.array(z.string().max(10_000))
|
||||
.min(1)
|
||||
.max(2_000)
|
||||
.refine(
|
||||
(lines) => lines.reduce((length, line) => length + line.length, Math.max(0, lines.length - 1)) <= 50_000,
|
||||
`The complete JSX template must contain at most ${CUSTOM_WIDGET_TEMPLATE_MAX_LENGTH} characters`,
|
||||
)
|
||||
.describe(
|
||||
"The complete JSX template split into lines. Prefer this over template for generated multiline JSX. Do not send both fields.",
|
||||
);
|
||||
|
||||
export const customWidgetAuthoringDefinitionSchema = z
|
||||
.strictObject({
|
||||
...customWidgetDefinitionSchema.shape,
|
||||
template: customJsxTemplateSchema
|
||||
.optional()
|
||||
.describe("The complete JSX template as one string. For multiline JSX, prefer templateLines instead."),
|
||||
templateLines: customWidgetTemplateLinesSchema.optional(),
|
||||
})
|
||||
.superRefine((definition, ctx) => {
|
||||
if (definition.template === undefined && definition.templateLines === undefined) {
|
||||
ctx.addIssue({ code: "custom", path: ["template"], message: "Provide template or templateLines" });
|
||||
}
|
||||
if (definition.template !== undefined && definition.templateLines !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["templateLines"],
|
||||
message: "Provide template or templateLines, not both",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type CustomWidgetAuthoringDefinitionInput = z.input<typeof customWidgetAuthoringDefinitionSchema>;
|
||||
|
||||
export function normalizeCustomWidgetAuthoringDefinition(input: CustomWidgetAuthoringDefinitionInput) {
|
||||
const { templateLines, ...definition } = input;
|
||||
return customWidgetDefinitionSchema.parse({
|
||||
...definition,
|
||||
template: definition.template ?? templateLines?.join("\n"),
|
||||
});
|
||||
}
|
||||
|
||||
const customWidgetAuthoringUpdateSchema = z
|
||||
.strictObject({
|
||||
...customWidgetDefinitionSchema.shape,
|
||||
options: customWidgetOptionsSchema.removeDefault(),
|
||||
templateLines: customWidgetTemplateLinesSchema.optional(),
|
||||
})
|
||||
.partial()
|
||||
.superRefine((definition, ctx) => {
|
||||
if (definition.template !== undefined && definition.templateLines !== undefined) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["templateLines"],
|
||||
message: "Provide template or templateLines, not both",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type CustomWidgetAuthoringUpdateInput = z.input<typeof customWidgetAuthoringUpdateSchema>;
|
||||
|
||||
export function normalizeCustomWidgetAuthoringUpdate(input: CustomWidgetAuthoringUpdateInput) {
|
||||
const { templateLines, ...definition } = input;
|
||||
return {
|
||||
...definition,
|
||||
...(templateLines === undefined ? {} : { template: templateLines.join("\n") }),
|
||||
};
|
||||
}
|
||||
|
||||
export const customWidgetCreateSchema = customWidgetAuthoringDefinitionSchema.safeExtend({
|
||||
secrets: customWidgetSecretsInputSchema.default([]),
|
||||
});
|
||||
export const customWidgetUpdateSchema = z
|
||||
.strictObject(customWidgetDefinitionSchema.shape)
|
||||
.partial()
|
||||
.extend({ id: z.string().min(1), secrets: customWidgetSecretsInputSchema.optional() });
|
||||
export const customWidgetUpdateSchema = customWidgetAuthoringUpdateSchema.safeExtend({
|
||||
id: z.string().min(1),
|
||||
secrets: customWidgetSecretsInputSchema.optional(),
|
||||
});
|
||||
export const customWidgetImportSchema = customWidgetDefinitionSchema;
|
||||
export type CustomWidgetCreateInput = z.infer<typeof customWidgetCreateSchema>;
|
||||
export type CustomWidgetUpdateInput = z.infer<typeof customWidgetUpdateSchema>;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { buildCustomWidgetAiPrompt, CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION } from "../core/ai-prompt";
|
||||
import {
|
||||
buildCustomWidgetAiPrompt,
|
||||
CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION,
|
||||
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT,
|
||||
} from "../core/ai-prompt";
|
||||
|
||||
describe("AI prompt", () => {
|
||||
it("is self-contained, request-first, and compact", () => {
|
||||
@@ -36,6 +40,18 @@ describe("AI prompt", () => {
|
||||
expect(prompt.match(/Recommended components:/gu)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("makes the connected MCP workflow load the complete skill and test every preview query", () => {
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("First call customWidget_getSkill");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_validate");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_previewCreate");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_previewQuery once for every query");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("customWidget_getSharedProps");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Prefer templateLines");
|
||||
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_previewQuery")).toBeLessThan(
|
||||
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_create"),
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts secrets and preserves the final instruction when optional context is large", () => {
|
||||
const prompt = buildCustomWidgetAiPrompt(
|
||||
undefined,
|
||||
|
||||
@@ -5,28 +5,73 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getCustomWidgetComponent,
|
||||
getCustomWidgetComponentCatalog,
|
||||
getCustomWidgetSharedProps,
|
||||
getCustomWidgetSkill,
|
||||
getCustomWidgetSkillContent,
|
||||
} from "../core/authoring-resources";
|
||||
|
||||
const skillSourceDirectory = path.resolve(import.meta.dirname, "../../../../.agents/skills/homarr-custom-widget");
|
||||
|
||||
describe("authoring resources", () => {
|
||||
it("returns the concise skills.sh discovery shape", () => {
|
||||
it("returns the complete portable skill and keeps every bundled source file in sync", () => {
|
||||
const skill = getCustomWidgetSkill();
|
||||
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.1.0" });
|
||||
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.2.0" });
|
||||
expect(skill.skillsShUrl).toContain("skills.sh/homarr-labs/homarr");
|
||||
expect(skill.sourceUrl).toContain("/tree/HEAD/.agents/skills/homarr-custom-widget");
|
||||
expect(skill.installCommand).toContain("--skill homarr-custom-widget");
|
||||
expect(skill).not.toHaveProperty("files");
|
||||
expect(skill).not.toHaveProperty("componentCatalog");
|
||||
expect(getCustomWidgetSkillContent()).toBe(skill.skillMd);
|
||||
expect(skill.skillMd.trim()).toBe(
|
||||
readFileSync(path.join(process.cwd(), ".agents/skills/homarr-custom-widget/SKILL.md"), "utf8").trim(),
|
||||
);
|
||||
expect(skill).not.toHaveProperty("entrypointMd");
|
||||
expect(skill).not.toHaveProperty("bundledMarkdown");
|
||||
expect(skill.skillMd.trim()).toBe(readFileSync(path.join(skillSourceDirectory, "SKILL.md"), "utf8").trim());
|
||||
const bundledContent = getCustomWidgetSkillContent();
|
||||
for (const [file, content] of Object.entries(skill.references)) {
|
||||
expect(content.trim()).toBe(readFileSync(path.join(skillSourceDirectory, file), "utf8").trim());
|
||||
expect(bundledContent).toContain(`# Bundled file: ${file}`);
|
||||
expect(bundledContent).toContain(content.trim());
|
||||
}
|
||||
expect(bundledContent).toContain(skill.skillMd.trim());
|
||||
expect(Buffer.byteLength(JSON.stringify(skill), "utf8")).toBeLessThan(12_000);
|
||||
});
|
||||
|
||||
it("keeps the full catalog retrieval-based", () => {
|
||||
expect(getCustomWidgetComponentCatalog().components.length).toBeGreaterThan(100);
|
||||
expect(getCustomWidgetComponent("Stack")?.name).toBe("Stack");
|
||||
it("keeps catalog discovery compact and component details lazy", () => {
|
||||
const catalog = getCustomWidgetComponentCatalog();
|
||||
expect(catalog.components.length).toBeGreaterThan(100);
|
||||
expect(catalog.components).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ name: "Stack", category: expect.any(String) })]),
|
||||
);
|
||||
expect(catalog.examples).toEqual(expect.arrayContaining([expect.objectContaining({ id: "service-dashboard" })]));
|
||||
expect(catalog.sharedProps).toMatchObject({
|
||||
count: expect.any(Number),
|
||||
fetchTool: "customWidget_getSharedProps",
|
||||
maxPerRequest: 64,
|
||||
});
|
||||
expect(catalog.sharedProps.names).toContain("p");
|
||||
expect(Buffer.byteLength(JSON.stringify(catalog), "utf8")).toBeLessThan(45_000);
|
||||
|
||||
const stack = getCustomWidgetComponent("Stack");
|
||||
expect(stack).toMatchObject({
|
||||
name: "Stack",
|
||||
sharedProps: { fetchTool: "customWidget_getSharedProps" },
|
||||
});
|
||||
expect(stack?.props.some(({ source }) => source === "global")).toBe(false);
|
||||
expect(Buffer.byteLength(JSON.stringify(stack), "utf8")).toBeLessThan(20_000);
|
||||
expect(getCustomWidgetComponent("Icon")?.knownValues?.name.length).toBeGreaterThan(100);
|
||||
|
||||
const largestComponentPayload = Math.max(
|
||||
...catalog.components.map(({ name }) =>
|
||||
Buffer.byteLength(JSON.stringify(getCustomWidgetComponent(name)), "utf8"),
|
||||
),
|
||||
);
|
||||
expect(largestComponentPayload).toBeLessThan(20_000);
|
||||
});
|
||||
|
||||
it("returns only requested shared prop documentation in one bounded payload", () => {
|
||||
const result = getCustomWidgetSharedProps(["p", "m", "not-a-shared-prop", "p"]);
|
||||
expect(result.props.map(({ name }) => name)).toEqual(["p", "m"]);
|
||||
expect(result.notFound).toEqual(["not-a-shared-prop"]);
|
||||
expect(Buffer.byteLength(JSON.stringify(result), "utf8")).toBeLessThan(4_000);
|
||||
|
||||
const boundedBatch = getCustomWidgetSharedProps(getCustomWidgetComponentCatalog().sharedProps.names.slice(0, 64));
|
||||
expect(Buffer.byteLength(JSON.stringify(boundedBatch), "utf8")).toBeLessThan(15_000);
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,16 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CUSTOM_WIDGET_STARTER,
|
||||
customWidgetAuthoringDefinitionSchema,
|
||||
customWidgetDefinitionSchema,
|
||||
customWidgetUpdateSchema,
|
||||
customJsxRequestSchema,
|
||||
getCustomWidgetDefaultOptions,
|
||||
getCustomWidgetSecretRequirements,
|
||||
getCustomWidgetSourceSetups,
|
||||
hasSameCustomWidgetSourceAuthentication,
|
||||
applyCustomWidgetSourceSetup,
|
||||
normalizeCustomWidgetAuthoringDefinition,
|
||||
} from "../core";
|
||||
|
||||
describe("lean Custom Widget schema", () => {
|
||||
@@ -61,6 +64,56 @@ describe("lean Custom Widget schema", () => {
|
||||
expect(customWidgetDefinitionSchema.parse(withoutOptions).options).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts and normalizes realistic multiline JSX without JSON string escaping", () => {
|
||||
const { template: _template, ...definition } = CUSTOM_WIDGET_STARTER;
|
||||
const templateLines = [
|
||||
'<Stack gap="sm">',
|
||||
' <Group justify="space-between">',
|
||||
" <Text fw={700}>Upcoming fixtures</Text>",
|
||||
' <Badge color="blue">{(data.fixtures ?? []).length} matches</Badge>',
|
||||
" </Group>",
|
||||
" {(data.fixtures ?? []).map(fixture => (",
|
||||
' <Paper key={fixture.id} p="sm" withBorder>',
|
||||
" <Text>{fixture.home_team.name} vs {fixture.away_team.name}</Text>",
|
||||
" </Paper>",
|
||||
" ))}",
|
||||
"</Stack>",
|
||||
];
|
||||
const candidate = customWidgetAuthoringDefinitionSchema.parse({
|
||||
...definition,
|
||||
requests: { fixtures: { path: "/fixtures" } },
|
||||
templateLines,
|
||||
});
|
||||
|
||||
expect(normalizeCustomWidgetAuthoringDefinition(candidate).template).toBe(templateLines.join("\n"));
|
||||
});
|
||||
|
||||
it("requires exactly one authoring template representation", () => {
|
||||
expect(
|
||||
customWidgetAuthoringDefinitionSchema.safeParse({ ...CUSTOM_WIDGET_STARTER, templateLines: ["<Text />"] })
|
||||
.success,
|
||||
).toBe(false);
|
||||
const { template: _template, ...withoutTemplate } = CUSTOM_WIDGET_STARTER;
|
||||
expect(customWidgetAuthoringDefinitionSchema.safeParse(withoutTemplate).success).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps omitted update fields absent when using templateLines", () => {
|
||||
expect(customWidgetUpdateSchema.parse({ id: "widget", templateLines: ["<Text />"] })).toEqual({
|
||||
id: "widget",
|
||||
templateLines: ["<Text />"],
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the template size limit before joining templateLines", () => {
|
||||
const { template: _template, ...definition } = CUSTOM_WIDGET_STARTER;
|
||||
expect(
|
||||
customWidgetAuthoringDefinitionSchema.safeParse({
|
||||
...definition,
|
||||
templateLines: Array.from({ length: 6 }, () => "x".repeat(10_000)),
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects source IDs that collide case-insensitively", () => {
|
||||
const result = customWidgetDefinitionSchema.safeParse({
|
||||
...CUSTOM_WIDGET_STARTER,
|
||||
|
||||
@@ -2100,7 +2100,8 @@
|
||||
"dismissQuote": "Remove quote",
|
||||
"attachments": {
|
||||
"add": "Attach an image or document",
|
||||
"errorTitle": "Attachment could not be added"
|
||||
"errorTitle": "Attachment could not be added",
|
||||
"imageUnsupported": "The selected model does not support image input."
|
||||
},
|
||||
"mentions": {
|
||||
"menu": "Add Homarr context",
|
||||
@@ -2170,6 +2171,10 @@
|
||||
},
|
||||
"toolInput": "Input",
|
||||
"toolOutput": "Output",
|
||||
"toolNavigation": {
|
||||
"openPreview": "Open preview",
|
||||
"openWidget": "Open widget"
|
||||
},
|
||||
"webSearch": {
|
||||
"title": "Web search",
|
||||
"completed": "OpenRouter searched the live web for this response.",
|
||||
|
||||
Generated
+160
@@ -561,6 +561,9 @@ catalogs:
|
||||
react-resizable-panels:
|
||||
specifier: ^4.12.2
|
||||
version: 4.12.2
|
||||
react-shiki:
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0
|
||||
react-simple-code-editor:
|
||||
specifier: ^0.14.1
|
||||
version: 0.14.1
|
||||
@@ -1218,6 +1221,9 @@ importers:
|
||||
react-error-boundary:
|
||||
specifier: 'catalog:'
|
||||
version: 6.1.2(react@19.2.7)
|
||||
react-shiki:
|
||||
specifier: 'catalog:'
|
||||
version: 0.11.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2)
|
||||
react-simple-code-editor:
|
||||
specifier: 'catalog:'
|
||||
version: 0.14.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -8174,6 +8180,37 @@ packages:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
'@shikijs/core@4.4.2':
|
||||
resolution: {integrity: sha512-StyzbAyxg2/tBGf78gwbBkGyeQ73lf8UiJArFaQhTQIDqQOCKPCQFanvrs4/Yv3Yfyc+ONInJM6K+FMIf+P+kA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-javascript@4.4.2':
|
||||
resolution: {integrity: sha512-MnIkeqWdVPUWsxlx8gKLVCJFTsqrQJgpTPBPpQwaFeJ56lOnJxj5aN2LUFnfxUEcvOQuNocmbaMnVrCEln6rkw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/engine-oniguruma@4.4.2':
|
||||
resolution: {integrity: sha512-GLhowz1+jixjz+wiZ3wMnOn1jTxiFCGl2PkXufivbnwPHKuyw1AYqu5/hbWhZZ2oAb0NP05WUJhYeigY14drnw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/langs@4.4.2':
|
||||
resolution: {integrity: sha512-8DfeusD+Zdv/eYIDdXyJTUnSMHt+aAWjAOCXV20HNGAHRlInXpG8wh421v6B91WOm9TFwRLN+b/LG5F2NAIojg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/primitive@4.4.2':
|
||||
resolution: {integrity: sha512-l6fQQKsOMlz72n38fztmSgZ76MO6KSWuw8o+GJ+FhmqrpC9pIOJNQNXGgbb5yX2AwpzlEHwsaLPnk/8o4Fm+rA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/themes@4.4.2':
|
||||
resolution: {integrity: sha512-H0CFoL07ddDC2Dd6EdrPYNkRhUR6YCkJlnuYFceYYUJJA5TIm2b5B33qqiDYryBExgbKMndFJPb2u1gTuqO37g==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/types@4.4.2':
|
||||
resolution: {integrity: sha512-PFYitV4vpDr/iPCIhnHp+Q4ftic5N5VeNJ3KQ1O8gn3h2ar8qgwMAXF7tq4m1CWaMS60fV4VqF6vfnWH4F7vqQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2':
|
||||
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
|
||||
|
||||
'@sideway/address@4.1.5':
|
||||
resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==}
|
||||
|
||||
@@ -9205,6 +9242,9 @@ packages:
|
||||
'@types/hast@3.0.4':
|
||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||
|
||||
'@types/hast@3.0.5':
|
||||
resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==}
|
||||
|
||||
'@types/history@4.7.11':
|
||||
resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==}
|
||||
|
||||
@@ -13715,6 +13755,12 @@ packages:
|
||||
resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
oniguruma-parser@0.12.2:
|
||||
resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
|
||||
|
||||
oniguruma-to-es@4.3.6:
|
||||
resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
|
||||
|
||||
open@10.2.0:
|
||||
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -14856,6 +14902,19 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=15'
|
||||
|
||||
react-shiki@0.11.0:
|
||||
resolution: {integrity: sha512-rqQtAvR8igCA9TVPfRhG6BSNeV+pLJZIkySms/K0/jYIoehG4/EsXD3cEhC94RXEpUVpmCrNkYhKR7RBO1YUGw==}
|
||||
peerDependencies:
|
||||
'@types/react': '>=16.14.0'
|
||||
'@types/react-dom': '>=16.14.0'
|
||||
react: '>=16.14.0'
|
||||
react-dom: '>=16.14.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
react-simple-code-editor@0.14.1:
|
||||
resolution: {integrity: sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==}
|
||||
peerDependencies:
|
||||
@@ -14992,6 +15051,15 @@ packages:
|
||||
regenerate@1.4.2:
|
||||
resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
|
||||
|
||||
regex-utilities@2.3.0:
|
||||
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
|
||||
|
||||
regex@6.1.0:
|
||||
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
|
||||
|
||||
regexpu-core@6.4.0:
|
||||
resolution: {integrity: sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -15343,6 +15411,10 @@ packages:
|
||||
resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
shiki@4.4.2:
|
||||
resolution: {integrity: sha512-P8F/dFhRevaw2uSdeIYlq/5SXZNY85DPtmXQ947gD1Zj2JqO5AkNvVVBar0Me9JkFx3uzVud/qOtP5ek9NEQGA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
side-channel-list@1.0.0:
|
||||
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -23191,6 +23263,46 @@ snapshots:
|
||||
'@types/react': 19.2.17
|
||||
react: 19.2.7
|
||||
|
||||
'@shikijs/core@4.4.2':
|
||||
dependencies:
|
||||
'@shikijs/primitive': 4.4.2
|
||||
'@shikijs/types': 4.4.2
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.5
|
||||
hast-util-to-html: 9.0.5
|
||||
|
||||
'@shikijs/engine-javascript@4.4.2':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.4.2
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
oniguruma-to-es: 4.3.6
|
||||
|
||||
'@shikijs/engine-oniguruma@4.4.2':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.4.2
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
|
||||
'@shikijs/langs@4.4.2':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.4.2
|
||||
|
||||
'@shikijs/primitive@4.4.2':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.4.2
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
'@shikijs/themes@4.4.2':
|
||||
dependencies:
|
||||
'@shikijs/types': 4.4.2
|
||||
|
||||
'@shikijs/types@4.4.2':
|
||||
dependencies:
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
'@shikijs/vscode-textmate@10.0.2': {}
|
||||
|
||||
'@sideway/address@4.1.5':
|
||||
dependencies:
|
||||
'@hapi/hoek': 9.3.0
|
||||
@@ -24193,6 +24305,10 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/hast@3.0.5':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/history@4.7.11': {}
|
||||
|
||||
'@types/html-minifier-terser@6.1.0': {}
|
||||
@@ -29244,6 +29360,14 @@ snapshots:
|
||||
dependencies:
|
||||
mimic-fn: 4.0.0
|
||||
|
||||
oniguruma-parser@0.12.2: {}
|
||||
|
||||
oniguruma-to-es@4.3.6:
|
||||
dependencies:
|
||||
oniguruma-parser: 0.12.2
|
||||
regex: 6.1.0
|
||||
regex-recursion: 6.0.2
|
||||
|
||||
open@10.2.0:
|
||||
dependencies:
|
||||
default-browser: 5.5.0
|
||||
@@ -30594,6 +30718,21 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
tiny-warning: 1.0.3
|
||||
|
||||
react-shiki@0.11.0(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(supports-color@10.2.2):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
dequal: 2.0.3
|
||||
hast-util-to-jsx-runtime: 2.3.6(supports-color@10.2.2)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
shiki: 4.4.2
|
||||
unist-util-visit: 5.1.0
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.17)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
react-simple-code-editor@0.14.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
@@ -30785,6 +30924,16 @@ snapshots:
|
||||
|
||||
regenerate@1.4.2: {}
|
||||
|
||||
regex-recursion@6.0.2:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
regex-utilities@2.3.0: {}
|
||||
|
||||
regex@6.1.0:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
regexpu-core@6.4.0:
|
||||
dependencies:
|
||||
regenerate: 1.4.2
|
||||
@@ -31278,6 +31427,17 @@ snapshots:
|
||||
|
||||
shell-quote@1.8.4: {}
|
||||
|
||||
shiki@4.4.2:
|
||||
dependencies:
|
||||
'@shikijs/core': 4.4.2
|
||||
'@shikijs/engine-javascript': 4.4.2
|
||||
'@shikijs/engine-oniguruma': 4.4.2
|
||||
'@shikijs/langs': 4.4.2
|
||||
'@shikijs/themes': 4.4.2
|
||||
'@shikijs/types': 4.4.2
|
||||
'@shikijs/vscode-textmate': 10.0.2
|
||||
'@types/hast': 3.0.5
|
||||
|
||||
side-channel-list@1.0.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
||||
@@ -224,6 +224,7 @@ catalog:
|
||||
remark-breaks: ^4.0.0
|
||||
remark-directive: ^3.0.1
|
||||
remark-gfm: ^4.0.1
|
||||
react-shiki: ^0.11.0
|
||||
|
||||
catalogMode: strict
|
||||
nodeLinker: hoisted
|
||||
|
||||
Reference in new issue
Block a user