Merge commit '3ea931d45' into feat/propagate-workshop-admin-release
This commit is contained in:
44 files changed
+727
-233
No files matched your search
@@ -11,7 +11,7 @@ Author one widget at a time. When connected to Homarr, retrieve `homarr://custom
|
||||
2. Create one credential-free widget with keyed `sources`, keyed `requests`, optional keyed `options`, and safe JSX `template`.
|
||||
3. Validate, preview, test queries and simulated actions, visually inspect, and iterate.
|
||||
4. Configure deployment-specific source URLs and request credentials through Homarr; never repeat plaintext.
|
||||
5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work.
|
||||
5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work. When connected through MCP, persist the exact final tested preview with `customWidget_createFromPreview`; do not resend a large template through `customWidget_create` unless no preview session is available.
|
||||
|
||||
Use `{option:name}` or `$option` for saved options. Use `{param:name}` or `$param` for values supplied by `SubFetch`, `ActionButton`, or `ToggleSwitch`. Load queries cannot use invocation parameters. Templates read `data`, `status`, `options`, and temporary `inputs`.
|
||||
|
||||
|
||||
@@ -240,8 +240,13 @@ runtime and security references, and the current JSON Schema to the trusted auth
|
||||
extra provider round trip for the large `customWidget_getSkill` and `customWidget_schema` results. If the model
|
||||
discovers the Custom Widget workflow mid-response, Homarr promotes the same resources before its next step and
|
||||
removes the now-redundant resource calls from provider history. Multiline JSX uses `templateLines` to avoid fragile
|
||||
streamed JSON escaping. 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
|
||||
streamed JSON escaping. Custom Widget authoring tools are mandatory: the assistant validates the complete candidate,
|
||||
creates a preview from that exact version, executes every returned preview query, checks the actual HTTP status and
|
||||
response shape, and repeats the entire validation and preview sequence after any material JSX, request, source, option,
|
||||
or binding change. It only reports a widget as validated, working, created, or placed when the corresponding tool result
|
||||
proves that checkpoint. Large widgets are saved from the exact final tested preview session, so the approved create step
|
||||
does not stream the complete JSX a second time. When the request already names a board, that board is carried into the
|
||||
create result and placement continues without asking the user to select it again. Preview and saved-definition results
|
||||
include safe Homarr paths that open in the assistant without a page reload. When placement is still a choice, the
|
||||
assistant uses the structured question UI instead of ending with a prose question.
|
||||
|
||||
|
||||
@@ -25,10 +25,10 @@ Every option has a default. Supported controls include text, textarea, number, s
|
||||
|
||||
## ChatGPT or Claude
|
||||
|
||||
Enter the desired widget and optional API documentation URL, then select **Copy AI prompt**. The single self-contained prompt is below 12,000 characters, starts with your request, includes the lean contract and two polished examples, and never claims that an offline chat has MCP tools.
|
||||
Open **Create or fix with any AI**, enter the desired widget and optional API documentation URL, then select the copy action beside the accordion title. The single self-contained prompt is below 12,000 characters, starts with your request, includes the lean contract and two polished examples, and never claims that an offline chat has MCP tools.
|
||||
|
||||
The model must return one fenced `json` block containing the complete widget, including the JSX in its `template`
|
||||
field. Copy that single block and select **Paste AI response**. Homarr fills the visual builders and the JSX editor
|
||||
field. Copy that single block and select **Paste**. Homarr fills the visual builders and the JSX editor
|
||||
without any placeholder replacement or second paste.
|
||||
|
||||
Homarr shows all actionable schema or JSX errors inline. Enter credentials separately. The prompt deliberately
|
||||
|
||||
@@ -35,7 +35,8 @@ Back up your instance before installing untrusted content.
|
||||
- Sign in with **GitHub** to upvote/downvote, submit your own creations, or report a submission.
|
||||
- Each submission shows its title, description, linked GitHub author, revision, optional screenshots, comments, and full content. Widget pages also summarize API sources, actions, options, network access, and required credentials before installation.
|
||||
- A new submission receives one real upvote owned by its author. Votes remain unique per user and submission.
|
||||
- Report details and reporter identities are visible only to Workshop moderators. Reported submissions are highlighted in the moderation page, where each open report can be dismissed or the submission can be deleted. Reports do not automatically remove a submission.
|
||||
- Open report counts are public on submission cards so visitors can make an informed installation decision. Report explanations and reporter identities remain visible only to Workshop moderators.
|
||||
- Signed-in moderators see a **Moderation** button in the Workshop header. The moderation queue is sorted by open report count and lets moderators inspect every report, dismiss it, or delete the submission. Reports do not automatically remove a submission.
|
||||
- The manual **Outdated** badge warns visitors that a submission may need an update.
|
||||
|
||||
## Comments
|
||||
@@ -52,6 +53,9 @@ Comments are publicly readable and appear in chronological conversation bubbles
|
||||
3. Review the widget JSON or CSS in the syntax-aware editor, then add a title, description, optional screenshots, and changelog.
|
||||
4. Submit the entry.
|
||||
|
||||
Community members can report submissions as outdated or no longer working, malicious, spam, copyright-infringing,
|
||||
inappropriate, or another concern. An outdated report does not automatically mark or remove the submission.
|
||||
|
||||
Submissions are published immediately. Reported entries are reviewed and may be removed.
|
||||
Authors can edit a submission later to publish a new revision. The edit form can keep or remove existing screenshots and add new PNG, JPG, or WebP images without changing the Workshop URL.
|
||||
|
||||
|
||||
@@ -402,6 +402,7 @@ html[data-workshop-detail-loading] #workshop-detail-route-fallback {
|
||||
path: "/workshop/:id",
|
||||
component: "@site/src/components/workshop/WorkshopDetailRoutePage",
|
||||
exact: true,
|
||||
priority: -1,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -451,7 +451,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
try {
|
||||
await backend.report(
|
||||
submission.id,
|
||||
reportCategory as "malicious" | "spam" | "copyright" | "inappropriate" | "other",
|
||||
reportCategory as "outdated" | "malicious" | "spam" | "copyright" | "inappropriate" | "other",
|
||||
reportExplanation,
|
||||
);
|
||||
setReportOpen(false);
|
||||
@@ -881,6 +881,7 @@ const MarketplaceDetail = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
<SelectValue>{(value) => String(value).replace(/^./u, (letter) => letter.toUpperCase())}</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start">
|
||||
<SelectItem value="outdated">Outdated or no longer working</SelectItem>
|
||||
<SelectItem value="malicious">Malicious</SelectItem>
|
||||
<SelectItem value="spam">Spam</SelectItem>
|
||||
<SelectItem value="copyright">Copyright</SelectItem>
|
||||
|
||||
@@ -109,7 +109,11 @@ export function WorkshopAdmin({ workshopUrl }: { workshopUrl?: string }) {
|
||||
submission,
|
||||
reports: reports.filter((report) => report.submission === submission.id),
|
||||
}))
|
||||
.sort((left, right) => Number(right.reports.length > 0) - Number(left.reports.length > 0));
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.reports.length - left.reports.length ||
|
||||
Date.parse(right.submission.updated) - Date.parse(left.submission.updated),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="marketplace mx-auto max-w-6xl px-4 pb-20 text-foreground sm:px-6">
|
||||
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
IconChevronLeft,
|
||||
IconChevronRight,
|
||||
IconEye,
|
||||
IconFlag,
|
||||
IconMessage,
|
||||
IconPackage,
|
||||
IconPlus,
|
||||
IconPuzzle,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconShield,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import type { WorkshopSubmission } from "@site/src/lib/pocketbase";
|
||||
@@ -116,6 +118,7 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
const availableTypeFilters = workshop.user
|
||||
? [...typeFilters, { value: "yours" as const, label: "Yours" }]
|
||||
: typeFilters;
|
||||
const openReportCount = workshop.submissions.reduce((total, submission) => total + submission.reportCount, 0);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 pb-16">
|
||||
@@ -126,9 +129,19 @@ export const WorkshopApp = ({ workshopUrl }: { workshopUrl: string }) => {
|
||||
Discover community-made widgets and CSS. Review the source, then import it into Homarr.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 self-start sm:self-auto">
|
||||
<div className="flex flex-wrap items-center gap-2 self-start sm:self-auto">
|
||||
{workshop.user ? (
|
||||
<>
|
||||
{workshop.user.isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-10 sm:h-8"
|
||||
nativeButton={false}
|
||||
render={<a href="/workshop/admin" aria-label={`Open moderation, ${openReportCount} open reports`} />}
|
||||
>
|
||||
<IconShield size={14} /> Moderation{openReportCount > 0 ? ` (${openReportCount})` : ""}
|
||||
</Button>
|
||||
)}
|
||||
<Button className="h-10 sm:h-8" onClick={() => setShowSubmit(true)}>
|
||||
<IconPlus size={14} /> Share yours
|
||||
</Button>
|
||||
@@ -406,9 +419,14 @@ const SubmissionCard = ({ submission, backend, userVote, onVote }: SubmissionCar
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="min-h-[4.5rem] flex-1 overflow-hidden">
|
||||
{submission.outdated && (
|
||||
{(submission.outdated || submission.reportCount > 0) && (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
<Badge variant="secondary">Outdated</Badge>
|
||||
{submission.outdated && <Badge variant="secondary">Outdated</Badge>}
|
||||
{submission.reportCount > 0 && (
|
||||
<Badge variant="destructive" className="gap-1">
|
||||
<IconFlag size={12} /> {submission.reportCount} {submission.reportCount === 1 ? "report" : "reports"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{submission.description && (
|
||||
|
||||
@@ -162,7 +162,7 @@ export const useWorkshop = (workshopUrl: string) => {
|
||||
const report = useCallback(
|
||||
async (
|
||||
submissionId: string,
|
||||
category: "malicious" | "spam" | "copyright" | "inappropriate" | "other",
|
||||
category: "outdated" | "malicious" | "spam" | "copyright" | "inappropriate" | "other",
|
||||
explanation: string,
|
||||
) => {
|
||||
try {
|
||||
|
||||
@@ -30,7 +30,13 @@ describe("Copy AI prompt", () => {
|
||||
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.module.css`,
|
||||
"utf8",
|
||||
);
|
||||
expect(card).toContain("<SimpleGrid cols={{ base: 1, sm: 2 }}>");
|
||||
const actions = readFileSync(
|
||||
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_copy-ai-prompt-button.tsx`,
|
||||
"utf8",
|
||||
);
|
||||
expect(card).toContain('<Accordion variant="contained">');
|
||||
expect(card).not.toContain("defaultValue");
|
||||
expect(actions).toContain("<ActionIcon");
|
||||
expect(styles).toContain(".sectionNav > a");
|
||||
expect(styles).toContain("flex: 0 0 auto");
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Group } from "@mantine/core";
|
||||
import { IconCopy, IconRobot, IconSparkles } from "@tabler/icons-react";
|
||||
import { ActionIcon, Group, Tooltip } from "@mantine/core";
|
||||
import { IconCopy, IconRobot } from "@tabler/icons-react";
|
||||
|
||||
import { buildCustomWidgetAiPrompt } from "@homarr/custom-widgets/authoring-prompt";
|
||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||
@@ -46,40 +46,45 @@ export const CopyAiPromptButton = ({
|
||||
|
||||
if (!assistant?.enabled) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconSparkles size={16} />}
|
||||
onClick={() => void handleCopy()}
|
||||
fullWidth
|
||||
size="sm"
|
||||
>
|
||||
{t("action.copyAiPrompt")}
|
||||
</Button>
|
||||
<Tooltip label={t("action.copyAiPrompt")}>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("action.copyAiPrompt")}
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
<IconCopy size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group grow gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
type="button"
|
||||
variant="light"
|
||||
leftSection={<IconRobot size={16} />}
|
||||
onClick={handleAssistant}
|
||||
disabled={!request?.trim()}
|
||||
size="sm"
|
||||
>
|
||||
{t("action.aiPrompt")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={() => void handleCopy()}
|
||||
size="sm"
|
||||
>
|
||||
{t("notification.aiPromptCopy")}
|
||||
</Button>
|
||||
<Group gap={2} wrap="nowrap">
|
||||
<Tooltip label={t("action.aiPrompt")}>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("action.aiPrompt")}
|
||||
onClick={handleAssistant}
|
||||
disabled={!request?.trim()}
|
||||
>
|
||||
<IconRobot size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("action.copyAiPrompt")}>
|
||||
<ActionIcon
|
||||
type="button"
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={t("action.copyAiPrompt")}
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
<IconCopy size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Card, Group, SimpleGrid, Stack, Text, TextInput, Textarea } from "@mantine/core";
|
||||
import { IconSparkles } from "@tabler/icons-react";
|
||||
import { Accordion, Button, Group, Stack, Text, TextInput, Textarea } from "@mantine/core";
|
||||
import { IconClipboard, IconSparkles } from "@tabler/icons-react";
|
||||
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
@@ -20,38 +20,41 @@ interface AiCardProps {
|
||||
export function CustomWidgetAiCard(props: AiCardProps) {
|
||||
const t = useScopedI18n("customWidget.workbench.ai");
|
||||
return (
|
||||
<Card withBorder p="md">
|
||||
<Stack gap="sm">
|
||||
<Group gap="xs">
|
||||
<IconSparkles size={18} />
|
||||
<Text fw={600}>{t("title")}</Text>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("description")}
|
||||
</Text>
|
||||
<Textarea
|
||||
label={t("request")}
|
||||
value={props.request}
|
||||
onChange={(event) => props.onRequestChange(event.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("documentationUrl")}
|
||||
value={props.documentationUrl}
|
||||
onChange={(event) => props.onDocumentationUrlChange(event.currentTarget.value)}
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
<Accordion variant="contained">
|
||||
<Accordion.Item value="ai">
|
||||
<Group gap={0} wrap="nowrap">
|
||||
<Accordion.Control icon={<IconSparkles size={18} />} style={{ flex: 1 }}>
|
||||
{t("title")}
|
||||
</Accordion.Control>
|
||||
<CopyAiPromptButton
|
||||
currentConfig={props.candidate}
|
||||
request={props.request}
|
||||
documentationUrl={props.documentationUrl}
|
||||
/>
|
||||
<Button type="button" variant="light" leftSection={<IconSparkles size={16} />} onClick={props.onPaste}>
|
||||
{t("paste")}
|
||||
</Button>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Group>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("description")}
|
||||
</Text>
|
||||
<Textarea
|
||||
label={t("request")}
|
||||
value={props.request}
|
||||
onChange={(event) => props.onRequestChange(event.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("documentationUrl")}
|
||||
value={props.documentationUrl}
|
||||
onChange={(event) => props.onDocumentationUrlChange(event.currentTarget.value)}
|
||||
/>
|
||||
<Button type="button" variant="light" leftSection={<IconClipboard size={16} />} onClick={props.onPaste}>
|
||||
{t("paste")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
);
|
||||
}
|
||||
@@ -66,7 +66,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
const [documentationUrl, setDocumentationUrl] = useState("");
|
||||
const [preview, setPreview] = useState<PreviewState>({ data: {}, status: {}, session: null, outcome: "idle" });
|
||||
const [previewSize, setPreviewSize] = useState("standard");
|
||||
const [previewTheme, setPreviewTheme] = useState<"light" | "dark">("dark");
|
||||
const [optionsSnapshot, setOptionsSnapshot] = useState<Record<string, unknown>>(() => {
|
||||
const initialDefinition = buildDefinition(formInitialValues);
|
||||
return initialDefinition.success ? getDefinitionDefaults(initialDefinition.data) : {};
|
||||
@@ -266,8 +265,6 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
|
||||
preview={preview}
|
||||
size={previewSize}
|
||||
onSizeChange={setPreviewSize}
|
||||
theme={previewTheme}
|
||||
onThemeChange={setPreviewTheme}
|
||||
optionsSnapshot={optionsSnapshot}
|
||||
onOptionsChange={setOptionsSnapshot}
|
||||
onLiveActionsChange={(enabled) =>
|
||||
|
||||
+13
-8
@@ -1,5 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { Root } from "react-dom/client";
|
||||
@@ -69,6 +70,16 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("Custom Widget preview panel", () => {
|
||||
test("inherits the application theme instead of forcing a reload-sensitive preview theme", () => {
|
||||
const source = readFileSync(
|
||||
`${process.cwd()}/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview-panel.tsx`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(source).not.toContain("forceColorScheme");
|
||||
expect(source).not.toContain("onThemeChange");
|
||||
});
|
||||
|
||||
test("contains invalid definitions inside the widget canvas while keeping every inspection tab", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
@@ -79,8 +90,6 @@ describe("Custom Widget preview panel", () => {
|
||||
preview={{ data: { previous: true }, status: {}, session: null, outcome: "idle" }}
|
||||
size="standard"
|
||||
onSizeChange={vi.fn()}
|
||||
theme="dark"
|
||||
onThemeChange={vi.fn()}
|
||||
optionsSnapshot={{}}
|
||||
onOptionsChange={vi.fn()}
|
||||
onLiveActionsChange={vi.fn()}
|
||||
@@ -124,6 +133,8 @@ describe("Custom Widget preview panel", () => {
|
||||
|
||||
expect(host.textContent).toContain("sources.0.baseUrl");
|
||||
expect(host.textContent).toContain("Enter a complete URL.");
|
||||
expect(host.textContent).not.toContain("sessionActive");
|
||||
expect(host.textContent).not.toContain("simulationPending");
|
||||
});
|
||||
|
||||
test("contains renderer failures inside the widget tab and keeps the inspection tabs usable", async () => {
|
||||
@@ -149,8 +160,6 @@ describe("Custom Widget preview panel", () => {
|
||||
preview={preview}
|
||||
size="standard"
|
||||
onSizeChange={vi.fn()}
|
||||
theme="dark"
|
||||
onThemeChange={vi.fn()}
|
||||
optionsSnapshot={{ ...failingOptions }}
|
||||
onOptionsChange={vi.fn()}
|
||||
onLiveActionsChange={vi.fn()}
|
||||
@@ -175,8 +184,6 @@ describe("Custom Widget preview panel", () => {
|
||||
preview={preview}
|
||||
size="standard"
|
||||
onSizeChange={vi.fn()}
|
||||
theme="dark"
|
||||
onThemeChange={vi.fn()}
|
||||
optionsSnapshot={failingOptions}
|
||||
onOptionsChange={vi.fn()}
|
||||
onLiveActionsChange={vi.fn()}
|
||||
@@ -211,8 +218,6 @@ describe("Custom Widget preview panel", () => {
|
||||
preview={preview}
|
||||
size="standard"
|
||||
onSizeChange={vi.fn()}
|
||||
theme="dark"
|
||||
onThemeChange={vi.fn()}
|
||||
optionsSnapshot={{}}
|
||||
onOptionsChange={vi.fn()}
|
||||
onLiveActionsChange={vi.fn()}
|
||||
|
||||
+47
-57
@@ -1,20 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Card,
|
||||
Group,
|
||||
MantineProvider,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Switch,
|
||||
Tabs,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Alert, Box, Card, Group, Paper, SegmentedControl, Stack, Switch, Tabs, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { HomarrCustomWidgetV2 } from "@homarr/custom-widgets/core";
|
||||
@@ -44,8 +31,6 @@ interface PreviewPanelProps {
|
||||
preview: PreviewState;
|
||||
size: string;
|
||||
onSizeChange(value: string): void;
|
||||
theme: "light" | "dark";
|
||||
onThemeChange(value: "light" | "dark"): void;
|
||||
optionsSnapshot: Record<string, unknown>;
|
||||
onOptionsChange(value: Record<string, unknown>): void;
|
||||
onLiveActionsChange(enabled: boolean): void;
|
||||
@@ -98,39 +83,51 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
return (
|
||||
<Card withBorder p="md">
|
||||
<Stack gap="md">
|
||||
<Group grow>
|
||||
<Group gap="xs" justify="space-between">
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={props.size}
|
||||
onChange={props.onSizeChange}
|
||||
data={[
|
||||
{ value: "compact", label: t("size.compact") },
|
||||
{ value: "standard", label: t("size.standard") },
|
||||
{ value: "wide", label: t("size.wide") },
|
||||
{
|
||||
value: "compact",
|
||||
label: (
|
||||
<span aria-label={t("size.compact")} title={t("size.compact")}>
|
||||
S
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "standard",
|
||||
label: (
|
||||
<span aria-label={t("size.standard")} title={t("size.standard")}>
|
||||
M
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "wide",
|
||||
label: (
|
||||
<span aria-label={t("size.wide")} title={t("size.wide")}>
|
||||
L
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={props.theme}
|
||||
onChange={(value) => props.onThemeChange(value as "light" | "dark")}
|
||||
value={fixture}
|
||||
disabled={!candidate}
|
||||
onChange={(value) => setFixture(value as typeof fixture)}
|
||||
data={[
|
||||
{ value: "light", label: t("theme.light") },
|
||||
{ value: "dark", label: t("theme.dark") },
|
||||
{ value: "live", label: t("fixture.live") },
|
||||
{ value: "loading", label: t("fixture.loading") },
|
||||
{ value: "empty", label: t("fixture.empty") },
|
||||
{ value: "error", label: t("fixture.error") },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={fixture}
|
||||
disabled={!candidate}
|
||||
onChange={(value) => setFixture(value as typeof fixture)}
|
||||
data={[
|
||||
{ value: "live", label: t("fixture.live") },
|
||||
{ value: "loading", label: t("fixture.loading") },
|
||||
{ value: "empty", label: t("fixture.empty") },
|
||||
{ value: "error", label: t("fixture.error") },
|
||||
]}
|
||||
/>
|
||||
{candidate && (
|
||||
<PreviewResult
|
||||
outcome={props.preview.outcome}
|
||||
@@ -153,22 +150,20 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
</Tabs.List>
|
||||
<Tabs.Panel value="widget" pt="sm">
|
||||
<Box className={classes.previewCanvas}>
|
||||
<MantineProvider forceColorScheme={props.theme}>
|
||||
<Paper withBorder p="sm" h={360} w={widths[props.size] ?? 480} maw="100%" style={{ overflow: "auto" }}>
|
||||
{displayData ? (
|
||||
<PreviewErrorBoundary
|
||||
title={errorBoundaryMessages("title")}
|
||||
description={t("containedFailures")}
|
||||
retryLabel={errorBoundaryMessages("retry")}
|
||||
resetKeys={[rendererResetKey]}
|
||||
>
|
||||
<CustomJsxDisplay data={displayData} />
|
||||
</PreviewErrorBoundary>
|
||||
) : (
|
||||
<Alert color="yellow">{t("invalid")}</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
</MantineProvider>
|
||||
<Paper withBorder p="sm" h={360} w={widths[props.size] ?? 480} maw="100%" style={{ overflow: "auto" }}>
|
||||
{displayData ? (
|
||||
<PreviewErrorBoundary
|
||||
title={errorBoundaryMessages("title")}
|
||||
description={t("containedFailures")}
|
||||
retryLabel={errorBoundaryMessages("retry")}
|
||||
resetKeys={[rendererResetKey]}
|
||||
>
|
||||
<CustomJsxDisplay data={displayData} />
|
||||
</PreviewErrorBoundary>
|
||||
) : (
|
||||
<Alert color="yellow">{t("invalid")}</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="data" pt="sm">
|
||||
@@ -221,11 +216,6 @@ export function CustomWidgetPreviewPanel(props: PreviewPanelProps) {
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="diagnostics" pt="sm">
|
||||
<Stack gap="xs">
|
||||
{props.preview.session ? (
|
||||
<Badge color="green">{t("sessionActive")}</Badge>
|
||||
) : (
|
||||
<Badge color="gray">{t("simulationPending")}</Badge>
|
||||
)}
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("containedFailures")}
|
||||
</Text>
|
||||
|
||||
@@ -30,8 +30,13 @@ describe("withAssistantToolPolicy", () => {
|
||||
|
||||
describe("customWidgetAssistantInstructions", () => {
|
||||
test("requires the complete skill and real preview-query data before creation", () => {
|
||||
expect(customWidgetAssistantInstructions).toContain("Custom Widget tools are mandatory");
|
||||
expect(customWidgetAssistantInstructions).toContain("only a manifest, JSX, instructions");
|
||||
expect(customWidgetAssistantInstructions).toContain("when they are preloaded below");
|
||||
expect(customWidgetAssistantInstructions).toContain("do not call customWidget_getSkill or customWidget_schema");
|
||||
expect(customWidgetAssistantInstructions).toContain(
|
||||
"already loaded their complete contents into this system prompt",
|
||||
);
|
||||
expect(customWidgetAssistantInstructions).toContain("first call customWidget_getSkill");
|
||||
expect(customWidgetAssistantInstructions).toContain("customWidget_schema");
|
||||
expect(customWidgetAssistantInstructions).toContain("customWidget_getComponentCatalog");
|
||||
@@ -39,8 +44,15 @@ describe("customWidgetAssistantInstructions", () => {
|
||||
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("installed `pokedex` example");
|
||||
expect(customWidgetAssistantInstructions).toContain("Honor an explicit iteration count");
|
||||
expect(customWidgetAssistantInstructions).toContain("customWidget_validate with the complete definition");
|
||||
expect(customWidgetAssistantInstructions).toContain("customWidget_previewQuery for every query returned");
|
||||
expect(customWidgetAssistantInstructions).toContain("the previous validation and preview evidence is stale");
|
||||
expect(customWidgetAssistantInstructions).toContain("Never substitute a later unvalidated version");
|
||||
expect(customWidgetAssistantInstructions).toContain("customWidget_createFromPreview");
|
||||
expect(customWidgetAssistantInstructions).toContain("Never say the widget is created, updated, placed");
|
||||
expect(customWidgetAssistantInstructions).toContain("corresponding customWidget_create, customWidget_update");
|
||||
expect(customWidgetAssistantInstructions).toContain("managementPath");
|
||||
});
|
||||
|
||||
@@ -191,6 +203,28 @@ describe("getRequiredAssistantToolNames", () => {
|
||||
toolCallId: "create-widget-1",
|
||||
input: { name: "PSG fixtures" },
|
||||
state: "output-available",
|
||||
output: {
|
||||
id: "widget-1",
|
||||
managementPath: "/manage/custom-widgets/edit/widget-1",
|
||||
nextAction: {
|
||||
type: "place-custom-widget",
|
||||
targetBoardId: "board-hey",
|
||||
},
|
||||
},
|
||||
}),
|
||||
]),
|
||||
).toEqual(["configure_widget"]);
|
||||
});
|
||||
|
||||
test("requires placement after persisting the final tested preview", () => {
|
||||
expect(
|
||||
getRequiredAssistantToolNames([
|
||||
assistantMessage({
|
||||
type: "dynamic-tool",
|
||||
toolName: "customWidget_createFromPreview",
|
||||
toolCallId: "create-widget-from-preview-1",
|
||||
input: { previewSessionId: "preview-3" },
|
||||
state: "output-available",
|
||||
output: {
|
||||
id: "widget-1",
|
||||
managementPath: "/manage/custom-widgets/edit/widget-1",
|
||||
|
||||
@@ -7,10 +7,19 @@ const mutationApprovalInstruction =
|
||||
export const customWidgetAssistantInstructions = `
|
||||
|
||||
Custom Widget authoring rules:
|
||||
- For every request to create, repair, validate, preview, or explain a Custom JSX widget, use the complete installed skill and JSON Schema from the trusted Custom Widget authoring context when they are preloaded below; do not call customWidget_getSkill or customWidget_schema in that case. When they are not preloaded, first call customWidget_getSkill, whose response contains the complete installed skill and all references, then call customWidget_schema. Follow those resources as authoritative. Next call 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.
|
||||
- Custom Widget tools are mandatory for Custom JSX work. Never answer a request to create, repair, or materially change a Custom Widget with only a manifest, JSX, instructions, or an unsupported claim that it should work.
|
||||
- For every request to create, repair, validate, preview, or explain a Custom JSX widget, use the complete installed skill and JSON Schema from the trusted Custom Widget authoring context when they are preloaded below; do not call customWidget_getSkill or customWidget_schema in that case because Homarr has already loaded their complete contents into this system prompt. When they are not preloaded, first call customWidget_getSkill, whose response contains the complete installed skill and all references, then call customWidget_schema. Do not merely say that you loaded them: use their rules for the subsequent tool inputs. Follow those resources as authoritative. Next call 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.
|
||||
- Keep each tool call within a reliable streamed size. For Pokédex or PokéAPI requests, start from the installed \`pokedex\` example returned by customWidget_getExample and improve it through validated iterations instead of generating one enormous untested JSX argument from scratch. Completeness comes from working interactions and states, not gratuitous line count.
|
||||
- Honor an explicit iteration count. Each requested iteration must make a meaningful improvement and complete its own validation, preview, and required preview-query checks. Persist only the last tested preview session.
|
||||
- Never skip or reorder the evidence checkpoints for the exact candidate that will be saved:
|
||||
1. Call customWidget_validate with the complete definition. Repair every reported issue and call it again until it succeeds.
|
||||
2. Call customWidget_previewCreate with that same validated definition.
|
||||
3. Call customWidget_previewQuery for every query returned by the preview. Inspect the real HTTP status and returned data shape. Test relevant simulated actions with customWidget_previewAction when the widget contains actions.
|
||||
4. If the JSX template, request definitions, sources, options, or bindings change materially at any point, the previous validation and preview evidence is stale. Start again at customWidget_validate with the complete revised definition, create a fresh preview, and retest every returned query.
|
||||
5. For a new widget, call customWidget_createFromPreview with the final tested preview session ID so the approved mutation persists that exact candidate without streaming the complete JSX again. If the user already named a target board, resolve its ID before creation and include targetBoardId; after creation call configure_widget directly and do not ask the user to choose again. Use customWidget_create only when no preview session can be reused. For an update, call customWidget_update only with the final definition whose latest validation, preview, and required query checks succeeded. Never substitute a later unvalidated version.
|
||||
- A successful syntax validation proves only that the definition is valid. A preview session proves only that it can be rendered. Never claim live data works until every customWidget_previewQuery succeeded with the expected response shape. Never say the widget is created, updated, placed, or fully working unless the corresponding customWidget_create, customWidget_update, or board placement tool returned success. If a tool fails, report the specific failed checkpoint and continue repairing it when possible instead of describing the widget as done.
|
||||
- Progress updates may say which checkpoint is running, but they must not claim success before the tool result exists. 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.`;
|
||||
|
||||
@@ -58,7 +67,16 @@ type AssistantToolResponseMessage = {
|
||||
content: unknown;
|
||||
};
|
||||
|
||||
const isSuccessfulCustomWidgetCreateOutput = (output: unknown) =>
|
||||
const customWidgetCreateToolNames = new Set(["customWidget_create", "customWidget_createFromPreview"]);
|
||||
|
||||
type SuccessfulCustomWidgetCreateOutput = {
|
||||
id: string;
|
||||
managementPath: string;
|
||||
nextAction?: unknown;
|
||||
error?: undefined;
|
||||
};
|
||||
|
||||
const isSuccessfulCustomWidgetCreateOutput = (output: unknown): output is SuccessfulCustomWidgetCreateOutput =>
|
||||
typeof output === "object" &&
|
||||
output !== null &&
|
||||
"id" in output &&
|
||||
@@ -69,6 +87,18 @@ const isSuccessfulCustomWidgetCreateOutput = (output: unknown) =>
|
||||
output.managementPath.length > 0 &&
|
||||
(!("error" in output) || output.error === undefined);
|
||||
|
||||
const getCustomWidgetCreateFollowup = (output: unknown) => {
|
||||
if (!isSuccessfulCustomWidgetCreateOutput(output)) return [];
|
||||
const nextAction = output.nextAction;
|
||||
const hasKnownTarget =
|
||||
typeof nextAction === "object" &&
|
||||
nextAction !== null &&
|
||||
"targetBoardId" in nextAction &&
|
||||
typeof nextAction.targetBoardId === "string" &&
|
||||
nextAction.targetBoardId.length > 0;
|
||||
return hasKnownTarget ? (["configure_widget"] as const) : (["configure_widget", "ask_user"] as const);
|
||||
};
|
||||
|
||||
const getCustomWidgetCreateOutputFromResponseMessages = (responseMessages: readonly AssistantToolResponseMessage[]) => {
|
||||
for (const message of responseMessages.toReversed()) {
|
||||
if (message.role !== "tool" || !Array.isArray(message.content)) continue;
|
||||
@@ -79,7 +109,7 @@ const getCustomWidgetCreateOutputFromResponseMessages = (responseMessages: reado
|
||||
!("type" in part) ||
|
||||
part.type !== "tool-result" ||
|
||||
!("toolName" in part) ||
|
||||
part.toolName !== "customWidget_create" ||
|
||||
!customWidgetCreateToolNames.has(String(part.toolName)) ||
|
||||
!("output" in part)
|
||||
) {
|
||||
continue;
|
||||
@@ -113,10 +143,8 @@ export const getRequiredAssistantToolNames = (
|
||||
const latestCreateResult = completedSteps
|
||||
.at(-1)
|
||||
?.toolResults.toReversed()
|
||||
.find((result) => result.toolName === "customWidget_create");
|
||||
return latestCreateResult && isSuccessfulCustomWidgetCreateOutput(latestCreateResult.output)
|
||||
? (["configure_widget", "ask_user"] as const)
|
||||
: [];
|
||||
.find((result) => customWidgetCreateToolNames.has(result.toolName));
|
||||
return latestCreateResult ? getCustomWidgetCreateFollowup(latestCreateResult.output) : [];
|
||||
}
|
||||
|
||||
// Approval-gated tools execute immediately before the first model step of the follow-up request.
|
||||
@@ -124,9 +152,7 @@ export const getRequiredAssistantToolNames = (
|
||||
// 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)
|
||||
: [];
|
||||
return getCustomWidgetCreateFollowup(responseCreateOutput);
|
||||
}
|
||||
|
||||
// Approval-gated tools finish in a later HTTP request. At step zero their result is already in
|
||||
@@ -137,14 +163,12 @@ export const getRequiredAssistantToolNames = (
|
||||
if (
|
||||
latestToolPart === undefined ||
|
||||
latestToolPart.state !== "output-available" ||
|
||||
getToolName(latestToolPart) !== "customWidget_create"
|
||||
!customWidgetCreateToolNames.has(getToolName(latestToolPart))
|
||||
) {
|
||||
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;
|
||||
return getCustomWidgetCreateFollowup(latestToolPart.output);
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { act, createElement, Fragment } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import {
|
||||
AssistantAutoApprovalProvider,
|
||||
@@ -67,6 +67,19 @@ describe("assistant automatic approval tracker", () => {
|
||||
expect(tracker.claim("app-create-1")).toBe(true);
|
||||
});
|
||||
|
||||
test("bounds automatic retries and permits a fresh claim after completion", () => {
|
||||
const tracker = createAssistantAutoApprovalTracker(2);
|
||||
|
||||
expect(tracker.claim("custom-widget-create-1")).toBe(true);
|
||||
tracker.release("custom-widget-create-1");
|
||||
expect(tracker.claim("custom-widget-create-1")).toBe(true);
|
||||
tracker.release("custom-widget-create-1");
|
||||
expect(tracker.claim("custom-widget-create-1")).toBe(false);
|
||||
|
||||
tracker.complete("custom-widget-create-1");
|
||||
expect(tracker.claim("custom-widget-create-1")).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps the setting for the same local conversation and resets it on a real switch", async () => {
|
||||
const container = document.createElement("div");
|
||||
containers.push(container);
|
||||
@@ -185,4 +198,48 @@ describe("assistant automatic approval tracker", () => {
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("retries an approval response that never settles and restores manual controls after two attempts", async () => {
|
||||
vi.useFakeTimers();
|
||||
const container = document.createElement("div");
|
||||
containers.push(container);
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
let confirmationCount = 0;
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
AssistantAutoApprovalProvider,
|
||||
{ conversationId: "local-thread-1" },
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(AutoApprovalProbe),
|
||||
createElement(AutomaticActionProbe, {
|
||||
ready: true,
|
||||
completed: false,
|
||||
confirm: () => {
|
||||
confirmationCount += 1;
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
act(() => currentAutoApproval?.setEnabled(true));
|
||||
expect(confirmationCount).toBe(1);
|
||||
expect(automaticActionInProgress).toBe(true);
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(5_000));
|
||||
expect(confirmationCount).toBe(2);
|
||||
expect(automaticActionInProgress).toBe(true);
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(5_000));
|
||||
expect(confirmationCount).toBe(2);
|
||||
expect(automaticActionInProgress).toBe(false);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -3,20 +3,32 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
export const createAssistantAutoApprovalTracker = () => {
|
||||
const AUTO_APPROVAL_RETRY_TIMEOUT_MS = 5_000;
|
||||
const AUTO_APPROVAL_MAX_ATTEMPTS = 2;
|
||||
|
||||
export const createAssistantAutoApprovalTracker = (maximumAttempts = Number.POSITIVE_INFINITY) => {
|
||||
const claimedToolCalls = new Set<string>();
|
||||
const attemptsByToolCall = new Map<string, number>();
|
||||
|
||||
return {
|
||||
claim(toolCallId: string) {
|
||||
if (claimedToolCalls.has(toolCallId)) return false;
|
||||
const attempts = attemptsByToolCall.get(toolCallId) ?? 0;
|
||||
if (attempts >= maximumAttempts) return false;
|
||||
claimedToolCalls.add(toolCallId);
|
||||
attemptsByToolCall.set(toolCallId, attempts + 1);
|
||||
return true;
|
||||
},
|
||||
release(toolCallId: string) {
|
||||
claimedToolCalls.delete(toolCallId);
|
||||
},
|
||||
complete(toolCallId: string) {
|
||||
claimedToolCalls.delete(toolCallId);
|
||||
attemptsByToolCall.delete(toolCallId);
|
||||
},
|
||||
clear() {
|
||||
claimedToolCalls.clear();
|
||||
attemptsByToolCall.clear();
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -25,6 +37,8 @@ interface AssistantAutoApprovalContextValue {
|
||||
enabled: boolean;
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
requestAction: (toolCallId: string, confirm: () => void) => boolean;
|
||||
completeAction: (toolCallId: string) => void;
|
||||
retryRevision: number;
|
||||
}
|
||||
|
||||
const AssistantAutoApprovalContext = createContext<AssistantAutoApprovalContextValue | null>(null);
|
||||
@@ -34,22 +48,41 @@ export const AssistantAutoApprovalProvider = ({
|
||||
conversationId,
|
||||
}: PropsWithChildren<{ conversationId: string }>) => {
|
||||
const [enabled, setEnabledState] = useState(false);
|
||||
const trackerRef = useRef(createAssistantAutoApprovalTracker());
|
||||
const [retryRevision, setRetryRevision] = useState(0);
|
||||
const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS));
|
||||
const retryTimersRef = useRef(new Map<string, number>());
|
||||
const previousConversationIdRef = useRef(conversationId);
|
||||
|
||||
const setEnabled = useCallback((nextEnabled: boolean) => {
|
||||
if (!nextEnabled) trackerRef.current.clear();
|
||||
setEnabledState(nextEnabled);
|
||||
const clearRetryTimer = useCallback((toolCallId: string) => {
|
||||
const timer = retryTimersRef.current.get(toolCallId);
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
retryTimersRef.current.delete(toolCallId);
|
||||
}, []);
|
||||
|
||||
const clearAutomaticActions = useCallback(() => {
|
||||
for (const timer of retryTimersRef.current.values()) window.clearTimeout(timer);
|
||||
retryTimersRef.current.clear();
|
||||
trackerRef.current.clear();
|
||||
}, []);
|
||||
|
||||
const setEnabled = useCallback(
|
||||
(nextEnabled: boolean) => {
|
||||
if (!nextEnabled) clearAutomaticActions();
|
||||
setEnabledState(nextEnabled);
|
||||
},
|
||||
[clearAutomaticActions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const previousConversationId = previousConversationIdRef.current;
|
||||
previousConversationIdRef.current = conversationId;
|
||||
|
||||
if (previousConversationId === conversationId) return;
|
||||
trackerRef.current.clear();
|
||||
clearAutomaticActions();
|
||||
setEnabledState(false);
|
||||
}, [conversationId]);
|
||||
}, [clearAutomaticActions, conversationId]);
|
||||
|
||||
useEffect(() => clearAutomaticActions, [clearAutomaticActions]);
|
||||
|
||||
const requestAction = useCallback(
|
||||
(toolCallId: string, confirm: () => void) => {
|
||||
@@ -57,16 +90,36 @@ export const AssistantAutoApprovalProvider = ({
|
||||
|
||||
try {
|
||||
confirm();
|
||||
clearRetryTimer(toolCallId);
|
||||
retryTimersRef.current.set(
|
||||
toolCallId,
|
||||
window.setTimeout(() => {
|
||||
retryTimersRef.current.delete(toolCallId);
|
||||
trackerRef.current.release(toolCallId);
|
||||
setRetryRevision((revision) => revision + 1);
|
||||
}, AUTO_APPROVAL_RETRY_TIMEOUT_MS),
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
trackerRef.current.release(toolCallId);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[enabled],
|
||||
[clearRetryTimer, enabled],
|
||||
);
|
||||
|
||||
const value = useMemo(() => ({ enabled, setEnabled, requestAction }), [enabled, requestAction, setEnabled]);
|
||||
const completeAction = useCallback(
|
||||
(toolCallId: string) => {
|
||||
clearRetryTimer(toolCallId);
|
||||
trackerRef.current.complete(toolCallId);
|
||||
},
|
||||
[clearRetryTimer],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ enabled, setEnabled, requestAction, completeAction, retryRevision }),
|
||||
[completeAction, enabled, requestAction, retryRevision, setEnabled],
|
||||
);
|
||||
|
||||
return <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>;
|
||||
};
|
||||
@@ -88,20 +141,21 @@ export const useAssistantAutomaticAction = ({
|
||||
completed: boolean;
|
||||
confirm: () => void;
|
||||
}) => {
|
||||
const { enabled, requestAction } = useAssistantAutoApproval();
|
||||
const { completeAction, enabled, requestAction, retryRevision } = useAssistantAutoApproval();
|
||||
const [inProgress, setInProgress] = useState(false);
|
||||
const confirmRef = useRef(confirm);
|
||||
confirmRef.current = confirm;
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || completed || !enabled) {
|
||||
if (completed) completeAction(toolCallId);
|
||||
setInProgress(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const requested = requestAction(toolCallId, () => confirmRef.current());
|
||||
setInProgress(requested);
|
||||
}, [completed, enabled, ready, requestAction, toolCallId]);
|
||||
}, [completeAction, completed, enabled, ready, requestAction, retryRevision, toolCallId]);
|
||||
|
||||
return inProgress;
|
||||
};
|
||||
@@ -42,6 +42,38 @@ describe("shouldAutomaticallyContinueAssistant", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("continues an approved mutation when the same step also reports a provider-executed server tool", () => {
|
||||
expect(
|
||||
shouldAutomaticallyContinueAssistant({
|
||||
messages: [
|
||||
{
|
||||
id: "assistant-message",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "step-start" },
|
||||
{
|
||||
type: "dynamic-tool",
|
||||
toolName: "openrouter_web_search",
|
||||
toolCallId: "search-1",
|
||||
input: { query: "PokéAPI" },
|
||||
state: "input-available",
|
||||
providerExecuted: true,
|
||||
},
|
||||
{
|
||||
type: "dynamic-tool",
|
||||
toolName: "customWidget_createFromPreview",
|
||||
toolCallId: "create-1",
|
||||
input: { previewSessionId: "preview-1" },
|
||||
state: "approval-responded",
|
||||
approval: { id: "approval-1", approved: true },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("waits while a human tool has no result", () => {
|
||||
expect(
|
||||
shouldAutomaticallyContinueAssistant({
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
import { lastAssistantMessageIsCompleteWithApprovalResponses, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
|
||||
import {
|
||||
isToolUIPart,
|
||||
lastAssistantMessageIsCompleteWithApprovalResponses,
|
||||
lastAssistantMessageIsCompleteWithToolCalls,
|
||||
} from "ai";
|
||||
import type { UIMessage } from "ai";
|
||||
|
||||
type AutoSubmitOptions = Parameters<typeof lastAssistantMessageIsCompleteWithToolCalls>[0];
|
||||
|
||||
const lastAssistantMessageHasCompleteLocalApprovalResponses = (messages: UIMessage[]) => {
|
||||
const message = messages.at(-1);
|
||||
if (message?.role !== "assistant") return false;
|
||||
|
||||
const lastStepStartIndex = message.parts.reduce(
|
||||
(lastIndex, part, index) => (part.type === "step-start" ? index : lastIndex),
|
||||
-1,
|
||||
);
|
||||
const localToolParts = message.parts
|
||||
.slice(lastStepStartIndex + 1)
|
||||
.filter(isToolUIPart)
|
||||
.filter((part) => !part.providerExecuted);
|
||||
|
||||
return (
|
||||
localToolParts.some((part) => part.state === "approval-responded") &&
|
||||
localToolParts.every(
|
||||
(part) =>
|
||||
part.state === "output-available" || part.state === "output-error" || part.state === "approval-responded",
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const shouldAutomaticallyContinueAssistant = (options: AutoSubmitOptions) =>
|
||||
lastAssistantMessageIsCompleteWithApprovalResponses(options) || lastAssistantMessageIsCompleteWithToolCalls(options);
|
||||
lastAssistantMessageIsCompleteWithApprovalResponses(options) ||
|
||||
lastAssistantMessageHasCompleteLocalApprovalResponses(options.messages) ||
|
||||
lastAssistantMessageIsCompleteWithToolCalls(options);
|
||||
@@ -83,7 +83,6 @@ export const AssistantAskUserTool = ({
|
||||
completed: result !== undefined,
|
||||
confirm: () => {
|
||||
if (!affirmativeResult) return;
|
||||
setSubmitting(true);
|
||||
addResult(affirmativeResult);
|
||||
},
|
||||
});
|
||||
@@ -299,7 +298,6 @@ export const AssistantConfigureAppTool = ({
|
||||
completed: result !== undefined,
|
||||
confirm: () => {
|
||||
if (!initialValues) return;
|
||||
setSubmitting(true);
|
||||
addResult(initialValues);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -868,7 +868,6 @@ const ToolPart = ({
|
||||
completed: !awaitingApproval,
|
||||
confirm: () => {
|
||||
respondToApproval({ approved: true, reason: "Approved automatically by the user." });
|
||||
setApprovalResponse("approve");
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -430,7 +430,7 @@ export function WorkshopBrowser({ type = "customWidget", onInstall, onUseCss }:
|
||||
label={t("reportReason")}
|
||||
value={reportCategory}
|
||||
onChange={(value) => setReportCategory((value as WorkshopReport["category"]) ?? "other")}
|
||||
data={["malicious", "spam", "copyright", "inappropriate", "other"]}
|
||||
data={["outdated", "malicious", "spam", "copyright", "inappropriate", "other"]}
|
||||
allowDeselect={false}
|
||||
/>
|
||||
<Textarea
|
||||
|
||||
@@ -8,7 +8,8 @@ The runtime base is PocketBase 0.39.9 pinned by its multi-platform OCI digest in
|
||||
version and digest together, then run the Workshop integration and production-image smoke suites.
|
||||
|
||||
The internal PocketBase field `users.isAdmin` means **Workshop moderator**. It does not grant Homarr administrator
|
||||
access. Moderators can review private reports, dismiss reports, and remove submissions or comments.
|
||||
access. Signed-in moderators can open `/workshop/admin` from the visible **Moderation** button, review private report
|
||||
details, dismiss reports, and remove submissions or comments. Public submission cards show only the open report count.
|
||||
|
||||
## Run locally
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ migrate(
|
||||
name: "category",
|
||||
required: true,
|
||||
maxSelect: 1,
|
||||
values: ["malicious", "spam", "copyright", "inappropriate", "other"],
|
||||
values: ["outdated", "malicious", "spam", "copyright", "inappropriate", "other"],
|
||||
},
|
||||
{ type: "text", name: "explanation", required: true, min: 3, max: 1000 },
|
||||
{ type: "select", name: "status", required: true, maxSelect: 1, values: ["open", "dismissed"] },
|
||||
|
||||
@@ -69,6 +69,10 @@ const docsConfig = await read("apps/docs/docusaurus.config.ts");
|
||||
if (!docsConfig.includes('scripts: [{ src: "/workshop-runtime-config.js" }]')) {
|
||||
throw new Error("Workshop must load the container runtime configuration before the documentation bundle");
|
||||
}
|
||||
if (!docsConfig.includes('path: "/workshop/:id"') || !docsConfig.includes("priority: -1")) {
|
||||
throw new Error("Workshop submission routes must not capture the moderation page");
|
||||
}
|
||||
await access(resolve("apps/docs/static/workshop-runtime-config.js"));
|
||||
await access(resolve("apps/docs/src/pages/workshop/admin/index.tsx"));
|
||||
|
||||
console.log("Workshop static contracts passed");
|
||||
@@ -268,11 +268,12 @@ const report = await request("/api/collections/reports/records", {
|
||||
body: JSON.stringify({
|
||||
submission: submission.id,
|
||||
reporter: visitor.id,
|
||||
category: "other",
|
||||
category: "outdated",
|
||||
explanation: "Runtime moderation test",
|
||||
status: "dismissed",
|
||||
}),
|
||||
});
|
||||
if (report.category !== "outdated") throw new Error("Outdated Workshop reports must be accepted");
|
||||
await expectStatus(`/api/collections/reports/records/${report.id}`, { headers: visitorSession.headers }, 404);
|
||||
await expectStatus("/api/collections/reports/records", { headers: visitorSession.headers }, 200);
|
||||
const publicReports = await request("/api/collections/reports/records", { headers: visitorSession.headers });
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import {
|
||||
customWidgetCreateSchema,
|
||||
customWidgetDefinitionSchema,
|
||||
normalizeCustomWidgetAuthoringDefinition,
|
||||
} from "@homarr/custom-widgets/core";
|
||||
|
||||
import { permissionRequiredProcedure } from "../../trpc";
|
||||
import { parseCustomWidgetAuthoringInput } from "./authoring-validation";
|
||||
import { insertCustomWidgetDefinition } from "./definition-insert";
|
||||
import { getPreviewJournal, getPreviewSession, getPreviewSessionSecrets } from "./preview-sessions";
|
||||
import { assertSecretSources } from "./secret-policy";
|
||||
|
||||
const logger = createLogger({ module: "custom-widget" });
|
||||
|
||||
const getCreatedCustomWidgetResult = (id: string, targetBoardId?: string) => ({
|
||||
id,
|
||||
managementPath: `/manage/custom-widgets/edit/${id}`,
|
||||
nextAction: {
|
||||
type: "place-custom-widget" as const,
|
||||
widgetKind: "customApi" as const,
|
||||
options: { definitionId: id },
|
||||
...(targetBoardId ? { targetBoardId } : {}),
|
||||
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.",
|
||||
},
|
||||
});
|
||||
|
||||
export const creationProcedures = {
|
||||
create: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.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 = 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 getCreatedCustomWidgetResult(id);
|
||||
}),
|
||||
|
||||
createFromPreview: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Persist the exact tested definition from a customWidget_previewCreate session. Prefer this over resending a large widget to customWidget_create. Every query in the final preview must have a successful customWidget_previewQuery journal entry.",
|
||||
},
|
||||
})
|
||||
.input(z.object({ previewSessionId: z.string().min(1), targetBoardId: z.string().min(1).optional() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const session = await getPreviewSession(input.previewSessionId, ctx.session.user.id);
|
||||
const journal = await getPreviewJournal(session.id, ctx.session.user.id);
|
||||
const unverifiedQueryIds = Object.entries(session.requests).flatMap(([requestId, request]) => {
|
||||
if (request.kind !== "query") return [];
|
||||
const verified = journal.some(
|
||||
(entry) =>
|
||||
entry.kind === "query" &&
|
||||
entry.requestId === requestId &&
|
||||
entry.sessionRevision === session.revision &&
|
||||
entry.status !== null &&
|
||||
entry.status >= 200 &&
|
||||
entry.status < 300,
|
||||
);
|
||||
return verified ? [] : [requestId];
|
||||
});
|
||||
if (unverifiedQueryIds.length > 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Test every final preview query successfully before creating the widget: ${unverifiedQueryIds.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const definition = parseCustomWidgetAuthoringInput(() =>
|
||||
customWidgetDefinitionSchema.parse({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: session.name,
|
||||
description: session.description,
|
||||
iconUrl: session.iconUrl,
|
||||
sources: session.sources,
|
||||
requests: session.requests,
|
||||
options: session.optionDefinitions,
|
||||
template: session.template,
|
||||
}),
|
||||
);
|
||||
const secrets = Object.keys(session.sources).flatMap((sourceId) =>
|
||||
getPreviewSessionSecrets(session, sourceId).map((secret) => ({ sourceId, ...secret })),
|
||||
);
|
||||
assertSecretSources(definition.sources, secrets);
|
||||
const id = await insertCustomWidgetDefinition(ctx.db, definition, ctx.session.user.id, secrets);
|
||||
logger.info("Created custom widget definition from tested preview", {
|
||||
id,
|
||||
name: definition.name,
|
||||
previewSessionId: session.id,
|
||||
});
|
||||
return getCreatedCustomWidgetResult(id, input.targetBoardId);
|
||||
}),
|
||||
};
|
||||
@@ -7,16 +7,14 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import { and, eq, handleTransactionsAsync, notInArray } from "@homarr/db";
|
||||
import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema";
|
||||
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 { creationProcedures } from "./creation-procedures";
|
||||
import { managementQueryProcedures } from "./management-queries";
|
||||
import { metadataProcedures } from "./metadata-procedures";
|
||||
import { previewActionProcedures } from "./preview-action-procedures";
|
||||
@@ -38,36 +36,7 @@ const logger = createLogger({ module: "custom-widget" });
|
||||
export const customWidgetRouter = createTRPCRouter({
|
||||
...metadataProcedures,
|
||||
...managementQueryProcedures,
|
||||
|
||||
create: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.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 = 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,
|
||||
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.",
|
||||
},
|
||||
};
|
||||
}),
|
||||
...creationProcedures,
|
||||
|
||||
update: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
|
||||
@@ -108,7 +108,8 @@ export const metadataProcedures = {
|
||||
.query(({ input }) => {
|
||||
const example = getCustomWidgetExample(input.name);
|
||||
if (!example) throw new TRPCError({ code: "NOT_FOUND", message: "Custom JSX example not found" });
|
||||
return example;
|
||||
const { template, ...widget } = example.widget;
|
||||
return { ...example, widget: { ...widget, templateLines: template.split("\n") } };
|
||||
}),
|
||||
|
||||
validate: permissionRequiredProcedure
|
||||
|
||||
@@ -109,6 +109,8 @@ const previewCreateProcedure = permissionRequiredProcedure
|
||||
sources: definition.sources,
|
||||
requests: definition.requests,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
iconUrl: definition.iconUrl,
|
||||
template: definition.template,
|
||||
optionDefinitions: definition.options,
|
||||
options,
|
||||
@@ -145,6 +147,8 @@ export const previewBaseProcedures = {
|
||||
return {
|
||||
id: session.id,
|
||||
name: session.name,
|
||||
description: session.description,
|
||||
iconUrl: session.iconUrl,
|
||||
expiresAt: session.expiresAt,
|
||||
template: session.template,
|
||||
optionDefinitions: session.optionDefinitions,
|
||||
|
||||
@@ -95,8 +95,8 @@ export const setPreviewSessionLiveActions = (id: string, userId: string, enabled
|
||||
call(() => getService().setLiveActions(id, userId, enabled));
|
||||
export const appendPreviewJournal = (
|
||||
session: CustomWidgetPreviewSession,
|
||||
entry: Omit<CustomWidgetPreviewJournalEntry, "id" | "timestamp">,
|
||||
) => call(() => getService().appendJournal(session, entry));
|
||||
entry: Omit<CustomWidgetPreviewJournalEntry, "id" | "sessionRevision" | "timestamp">,
|
||||
) => call(() => getService().appendJournal(session, { ...entry, sessionRevision: session.revision }));
|
||||
export const getPreviewJournal = (id: string, userId: string) => call(() => getService().getJournal(id, userId));
|
||||
export const getPreviewSessionSecrets = (session: CustomWidgetPreviewSession, sourceId: string) =>
|
||||
getService().getSecrets(session, sourceId);
|
||||
|
||||
@@ -15,6 +15,11 @@ import { describe, expect, test } from "vitest";
|
||||
import { customWidgetRouter } from "../../custom-widget/custom-widget-router";
|
||||
import { getCustomWidgetConfigurationRequestForUser } from "../../custom-widget/configuration-requests";
|
||||
import { configureCustomWidgetSourceFromRequest } from "../../custom-widget/secret-persistence";
|
||||
import {
|
||||
appendPreviewJournal,
|
||||
configurePreviewSessionSource,
|
||||
getPreviewSession,
|
||||
} from "../../custom-widget/preview-sessions";
|
||||
import { serializeCustomWidgetDefinition } from "../../custom-widget/stored-definition";
|
||||
|
||||
const userId = createId();
|
||||
@@ -33,6 +38,8 @@ const nonAdminSession = {
|
||||
|
||||
const jellyfin = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-jellyfin")?.widget;
|
||||
if (!jellyfin) throw new Error("Jellyfin bundled widget is missing");
|
||||
const pokedex = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex")?.widget;
|
||||
if (!pokedex) throw new Error("Pokédex bundled widget is missing");
|
||||
const jellyfinDefaultSource = jellyfin.sources.default;
|
||||
if (!jellyfinDefaultSource) throw new Error("Jellyfin default source is missing");
|
||||
|
||||
@@ -142,6 +149,69 @@ describe("custom widget definition persistence", () => {
|
||||
expect((await caller.get({ id: created.id })).template).toBe(template);
|
||||
});
|
||||
|
||||
test("persists the exact final tested Pokédex preview without resending its JSX", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
const { template, ...definition } = pokedex;
|
||||
const candidate = { ...definition, templateLines: template.split("\n") };
|
||||
|
||||
// A long-running authoring request can iterate several times. Only the final preview session
|
||||
// is eligible for persistence, and its compact ID replaces a second large streamed payload.
|
||||
await caller.previewCreate({ definition: candidate, secrets: [] });
|
||||
await caller.previewCreate({ definition: candidate, secrets: [] });
|
||||
const finalPreview = await caller.previewCreate({ definition: candidate, secrets: [] });
|
||||
const createInput = { previewSessionId: finalPreview.previewSession.id, targetBoardId: "board-hey" };
|
||||
expect(Buffer.byteLength(JSON.stringify(createInput), "utf8")).toBeLessThan(128);
|
||||
|
||||
await expect(caller.createFromPreview(createInput)).rejects.toThrow("Test every final preview query successfully");
|
||||
|
||||
const previewSession = await getPreviewSession(finalPreview.previewSession.id, userId);
|
||||
for (const [requestId, request] of Object.entries(previewSession.requests)) {
|
||||
if (request.kind !== "query") continue;
|
||||
await appendPreviewJournal(previewSession, {
|
||||
requestId,
|
||||
kind: "query",
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
status: 200,
|
||||
durationMs: 1,
|
||||
simulated: false,
|
||||
});
|
||||
}
|
||||
|
||||
const defaultSource = previewSession.sources.default;
|
||||
if (!defaultSource) throw new Error("Pokédex preview source is missing");
|
||||
await configurePreviewSessionSource(previewSession.id, userId, "default", { ...defaultSource }, []);
|
||||
await expect(caller.createFromPreview(createInput)).rejects.toThrow("Test every final preview query successfully");
|
||||
|
||||
const finalPreviewSession = await getPreviewSession(finalPreview.previewSession.id, userId);
|
||||
for (const [requestId, request] of Object.entries(finalPreviewSession.requests)) {
|
||||
if (request.kind !== "query") continue;
|
||||
await appendPreviewJournal(finalPreviewSession, {
|
||||
requestId,
|
||||
kind: "query",
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
status: 200,
|
||||
durationMs: 1,
|
||||
simulated: false,
|
||||
});
|
||||
}
|
||||
|
||||
const created = await caller.createFromPreview(createInput);
|
||||
expect(created.managementPath).toBe(`/manage/custom-widgets/edit/${created.id}`);
|
||||
expect(created.nextAction.options).toEqual({ definitionId: created.id });
|
||||
expect(created.nextAction.targetBoardId).toBe("board-hey");
|
||||
await expect(caller.get({ id: created.id })).resolves.toMatchObject({
|
||||
name: pokedex.name,
|
||||
description: pokedex.description,
|
||||
template,
|
||||
sources: pokedex.sources,
|
||||
requests: pokedex.requests,
|
||||
options: pokedex.options,
|
||||
});
|
||||
});
|
||||
|
||||
test("updates a saved widget with MCP-friendly multiline templateLines", async () => {
|
||||
const db = await prepareDatabase();
|
||||
const caller = createCaller(db);
|
||||
|
||||
@@ -103,6 +103,7 @@ const MCP_TOOL_ALLOWLIST = {
|
||||
"apiKeys_delete",
|
||||
"customWidget_configurationRequestUser",
|
||||
"customWidget_create",
|
||||
"customWidget_createFromPreview",
|
||||
"customWidget_migrateLegacy",
|
||||
"customWidget_previewCreate",
|
||||
"customWidget_secretSet",
|
||||
@@ -197,6 +198,7 @@ describe("custom widget authoring procedure access", () => {
|
||||
unauthenticatedCaller.customWidget.getSkill(),
|
||||
unauthenticatedCaller.customWidget.list(),
|
||||
unauthenticatedCaller.customWidget.validate({ widget: {} }),
|
||||
unauthenticatedCaller.customWidget.createFromPreview({ previewSessionId: "preview" }),
|
||||
unauthenticatedCaller.customWidget.secretSet({
|
||||
definitionId: "widget",
|
||||
secret: { sourceId: "default", kind: "apiKey", value: "secret" },
|
||||
@@ -221,6 +223,7 @@ describe("custom widget authoring procedure access", () => {
|
||||
nonAdminCaller.customWidget.getExample({ name: "service-dashboard" }),
|
||||
nonAdminCaller.customWidget.getSharedProps({ names: ["p"] }),
|
||||
nonAdminCaller.customWidget.getSkill(),
|
||||
nonAdminCaller.customWidget.createFromPreview({ previewSessionId: "preview" }),
|
||||
nonAdminCaller.customWidget.secretSet({
|
||||
definitionId: "widget",
|
||||
secret: { sourceId: "default", kind: "apiKey", value: "secret" },
|
||||
@@ -255,7 +258,17 @@ describe("custom widget authoring procedure access", () => {
|
||||
});
|
||||
await expect(adminCaller.customWidget.getExample({ name: "service-dashboard" })).resolves.toMatchObject({
|
||||
id: "service-dashboard",
|
||||
widget: expect.objectContaining({ $schema: "homarr-custom-widget-v2" }),
|
||||
widget: expect.objectContaining({
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
templateLines: expect.any(Array),
|
||||
}),
|
||||
});
|
||||
await expect(adminCaller.customWidget.getExample({ name: "pokedex" })).resolves.toMatchObject({
|
||||
id: "pokedex",
|
||||
widget: expect.objectContaining({
|
||||
name: "Pokédex",
|
||||
templateLines: expect.arrayContaining([expect.stringContaining("<Stack")]),
|
||||
}),
|
||||
});
|
||||
await expect(adminCaller.customWidget.getSharedProps({ names: ["p", "m"] })).resolves.toMatchObject({
|
||||
props: [expect.objectContaining({ name: "p" }), expect.objectContaining({ name: "m" })],
|
||||
|
||||
@@ -129,7 +129,7 @@ export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX
|
||||
|
||||
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.
|
||||
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_createFromPreview with the exact final tested preview session ID. Use customWidget_create only when no reusable preview session exists. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate and preview 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.
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
customJsxCatalogGlobalPropByName,
|
||||
resolveCustomJsxPropDescriptor,
|
||||
} from "./component-catalog";
|
||||
import { BUNDLED_CUSTOM_WIDGETS } from "./bundled-widgets";
|
||||
import { customJsxExamples } from "./examples";
|
||||
import { customJsxTablerIconNames } from "./tabler-icons";
|
||||
|
||||
@@ -11,7 +12,7 @@ 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.2.0";
|
||||
export const CUSTOM_WIDGET_SKILL_VERSION = "2.3.0";
|
||||
|
||||
export const CUSTOM_WIDGET_SKILL_REFERENCES = {
|
||||
"references/schema.md": `# Schema
|
||||
@@ -77,7 +78,7 @@ Author one widget at a time. When connected to Homarr, retrieve \`homarr://custo
|
||||
2. Create one credential-free widget with keyed \`sources\`, keyed \`requests\`, optional keyed \`options\`, and safe JSX \`template\`.
|
||||
3. Validate, preview, test queries and simulated actions, visually inspect, and iterate.
|
||||
4. Configure deployment-specific source URLs and request credentials through Homarr; never repeat plaintext.
|
||||
5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work.
|
||||
5. Save only after narrow/wide, light/dark, loading, empty, error, and success states work. When connected through MCP, persist the exact final tested preview with \`customWidget_createFromPreview\`; do not resend a large template through \`customWidget_create\` unless no preview session is available.
|
||||
|
||||
Use \`{option:name}\` or \`$option\` for saved options. Use \`{param:name}\` or \`$param\` for values supplied by \`SubFetch\`, \`ActionButton\`, or \`ToggleSwitch\`. Load queries cannot use invocation parameters. Templates read \`data\`, \`status\`, \`options\`, and temporary \`inputs\`.
|
||||
|
||||
@@ -101,6 +102,8 @@ const CUSTOM_WIDGET_SKILL_BUNDLE_MD = [
|
||||
),
|
||||
].join("");
|
||||
|
||||
const pokedexAuthoringExample = BUNDLED_CUSTOM_WIDGETS.find(({ id }) => id === "seed-pokedex");
|
||||
|
||||
export function getCustomWidgetSkill() {
|
||||
return {
|
||||
name: "homarr-custom-widget",
|
||||
@@ -135,7 +138,19 @@ export function getCustomWidgetComponentCatalog() {
|
||||
}
|
||||
|
||||
export function getCustomWidgetExampleCatalog() {
|
||||
return customJsxExamples.map(({ id, title, description }) => ({ id, title, description }));
|
||||
return [
|
||||
...customJsxExamples.map(({ id, title, description }) => ({ id, title, description })),
|
||||
...(pokedexAuthoringExample
|
||||
? [
|
||||
{
|
||||
id: "pokedex",
|
||||
title: "Complete Pokédex",
|
||||
description:
|
||||
"A production-safe PokéAPI browser with searchable species, artwork, types, abilities, base stats, loading states, and manual detail requests.",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
export function getCustomWidgetComponent(name: string) {
|
||||
@@ -183,5 +198,14 @@ export function getCustomWidgetSharedProps(names: readonly string[]) {
|
||||
}
|
||||
|
||||
export function getCustomWidgetExample(name: string) {
|
||||
if (name === "pokedex" && pokedexAuthoringExample) {
|
||||
return {
|
||||
id: "pokedex",
|
||||
title: "Complete Pokédex",
|
||||
description:
|
||||
"A production-safe PokéAPI browser with searchable species, artwork, types, abilities, base stats, loading states, and manual detail requests.",
|
||||
widget: pokedexAuthoringExample.widget,
|
||||
};
|
||||
}
|
||||
return customJsxExamples.find((example) => example.id === name) ?? null;
|
||||
}
|
||||
@@ -29,6 +29,8 @@ const sessionSchema = z.object({
|
||||
secrets: z.array(encryptedSecretSchema),
|
||||
requests: customWidgetRequestsSchema,
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
iconUrl: z.string().optional(),
|
||||
template: z.string(),
|
||||
optionDefinitions: customWidgetOptionsSchema,
|
||||
options: z.record(z.string(), z.unknown()),
|
||||
@@ -46,6 +48,7 @@ const journalEntrySchema = z.object({
|
||||
status: z.number().nullable(),
|
||||
durationMs: z.number().nonnegative(),
|
||||
simulated: z.boolean(),
|
||||
sessionRevision: z.number().int().nonnegative(),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
export type CustomWidgetPreviewJournalEntry = z.infer<typeof journalEntrySchema>;
|
||||
@@ -56,6 +59,8 @@ export interface CreatePreviewSessionInput {
|
||||
secrets: Array<{ sourceId: string; kind: (typeof customWidgetSecretKinds)[number]; value: string }>;
|
||||
requests: Record<string, CustomJsxRequest>;
|
||||
name: string;
|
||||
description?: string;
|
||||
iconUrl?: string;
|
||||
template: string;
|
||||
optionDefinitions: CustomWidgetOptions;
|
||||
options: Record<string, unknown>;
|
||||
@@ -105,6 +110,8 @@ export class CustomWidgetPreviewSessionService {
|
||||
secrets: input.secrets.map((secret) => ({ ...secret, value: this.options.encrypt(secret.value) })),
|
||||
requests: input.requests,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
iconUrl: input.iconUrl,
|
||||
template: input.template,
|
||||
optionDefinitions: input.optionDefinitions,
|
||||
options: input.options,
|
||||
|
||||
@@ -45,6 +45,7 @@ describe("AI prompt", () => {
|
||||
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_createFromPreview");
|
||||
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(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getCustomWidgetComponent,
|
||||
getCustomWidgetComponentCatalog,
|
||||
getCustomWidgetExample,
|
||||
getCustomWidgetSharedProps,
|
||||
getCustomWidgetSkill,
|
||||
getCustomWidgetSkillContent,
|
||||
@@ -15,7 +16,7 @@ const skillSourceDirectory = path.resolve(import.meta.dirname, "../../../../.age
|
||||
describe("authoring resources", () => {
|
||||
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.2.0" });
|
||||
expect(skill).toMatchObject({ name: "homarr-custom-widget", version: "2.3.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");
|
||||
@@ -40,6 +41,17 @@ describe("authoring resources", () => {
|
||||
expect.arrayContaining([expect.objectContaining({ name: "Stack", category: expect.any(String) })]),
|
||||
);
|
||||
expect(catalog.examples).toEqual(expect.arrayContaining([expect.objectContaining({ id: "service-dashboard" })]));
|
||||
expect(catalog.examples).toEqual(expect.arrayContaining([expect.objectContaining({ id: "pokedex" })]));
|
||||
const pokedex = getCustomWidgetExample("pokedex");
|
||||
expect(pokedex).toMatchObject({
|
||||
id: "pokedex",
|
||||
widget: {
|
||||
$schema: "homarr-custom-widget-v2",
|
||||
name: "Pokédex",
|
||||
sources: { default: { baseUrl: "https://pokeapi.co" } },
|
||||
},
|
||||
});
|
||||
expect(Buffer.byteLength(JSON.stringify(pokedex), "utf8")).toBeLessThan(16_000);
|
||||
expect(catalog.sharedProps).toMatchObject({
|
||||
count: expect.any(Number),
|
||||
fetchTool: "customWidget_getSharedProps",
|
||||
|
||||
@@ -64,8 +64,13 @@ describe("preview sessions", () => {
|
||||
status: 200,
|
||||
durationMs: 5,
|
||||
simulated: false,
|
||||
sessionRevision: session.revision,
|
||||
});
|
||||
expect((await service.getJournal(created.id, "user"))[0]).toMatchObject({
|
||||
path: "/data",
|
||||
status: 200,
|
||||
sessionRevision: session.revision,
|
||||
});
|
||||
expect((await service.getJournal(created.id, "user"))[0]).toMatchObject({ path: "/data", status: 200 });
|
||||
});
|
||||
|
||||
it("applies source deployment values without restoring stale source metadata", async () => {
|
||||
|
||||
@@ -1027,7 +1027,7 @@
|
||||
"description": "Copy one prompt, then paste the AI's single JSON block. Homarr loads its JSX into the editor automatically.",
|
||||
"request": "What should this widget do?",
|
||||
"documentationUrl": "API documentation URL (optional)",
|
||||
"paste": "Paste AI response",
|
||||
"paste": "Paste",
|
||||
"response": "AI response",
|
||||
"loaded": "Widget loaded into the editor",
|
||||
"invalidResponse": "Clipboard does not contain a valid v2 widget",
|
||||
@@ -1061,8 +1061,6 @@
|
||||
"liveActions": "Run actions against the configured API",
|
||||
"liveActionsDescription": "Off is safe simulation. Turning this on affects the real service.",
|
||||
"noActions": "No actions are defined.",
|
||||
"sessionActive": "Preview session active",
|
||||
"simulationPending": "Actions are simulated until you run preview",
|
||||
"containedFailures": "Runtime failures are contained inside this widget. Request journals never include secret values.",
|
||||
"validation": {
|
||||
"title": "Validation",
|
||||
@@ -1097,10 +1095,6 @@
|
||||
"standard": "Standard",
|
||||
"wide": "Wide"
|
||||
},
|
||||
"theme": {
|
||||
"light": "Light",
|
||||
"dark": "Dark"
|
||||
},
|
||||
"fixture": {
|
||||
"live": "Live",
|
||||
"loading": "Loading",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
normalizeHttpUrl,
|
||||
resolveHomarrUrlConfig,
|
||||
validateWorkshopWidget,
|
||||
workshopReportCategorySchema,
|
||||
workshopSubmissionInputSchema,
|
||||
workshopSubmissionSummarySchema,
|
||||
} from "./schema";
|
||||
@@ -40,6 +41,10 @@ describe("Workshop URL configuration", () => {
|
||||
});
|
||||
|
||||
describe("Workshop widget validation", () => {
|
||||
test("accepts outdated as a report category", () => {
|
||||
expect(workshopReportCategorySchema.parse("outdated")).toBe("outdated");
|
||||
});
|
||||
|
||||
test("accepts a canonical credential-free widget", () => {
|
||||
expect(validateWorkshopWidget(JSON.stringify(CUSTOM_WIDGET_STARTER)).success).toBe(true);
|
||||
expect(
|
||||
|
||||
@@ -73,7 +73,14 @@ export function resolveHomarrUrlConfig(input: HomarrUrlConfigInput = {}): Homarr
|
||||
return { homarrWebsiteUrl, workshopApiUrl, workshopWebUrl };
|
||||
}
|
||||
|
||||
export const workshopReportCategorySchema = z.enum(["malicious", "spam", "copyright", "inappropriate", "other"]);
|
||||
export const workshopReportCategorySchema = z.enum([
|
||||
"outdated",
|
||||
"malicious",
|
||||
"spam",
|
||||
"copyright",
|
||||
"inappropriate",
|
||||
"other",
|
||||
]);
|
||||
export const workshopSubmissionTypeSchema = z.enum(["customWidget", "customCss"]);
|
||||
export type WorkshopSubmissionType = z.infer<typeof workshopSubmissionTypeSchema>;
|
||||
|
||||
|
||||
Reference in new issue
Block a user