feat(assistant): optimize MCP and custom widget authoring (#6743)
* feat(assistant): optimize MCP and custom widget authoring * fix(assistant): isolate server-safe JSX analyzer * refactor(assistant): generate MCP tool staging * fix(assistant): preserve widget placement follow-up * fix(ci): allow fast gate to finish * fix(custom-widgets): address review findings * fix(custom-widgets): split preview evidence module * style: format assistant custom widget files * fix(custom-widgets): sync formatted skill schema
This commit is contained in:
1 parent
38f4b0e1f8
commit
5e23659d99
95 files changed
+6799
-1778
No files matched your search
@@ -7,21 +7,11 @@ import { renderSafeJsx } from "../jsx/interpreter";
|
||||
import { CustomJsxInputsProvider } from "../jsx/runtime-components";
|
||||
import type { WidgetInputType, WidgetInputValue } from "../jsx/runtime-components";
|
||||
import { sameInputRecord, sameStringArray, sameTypeRecord } from "./input-state-utils";
|
||||
import type { CustomJsxRequestCapability } from "./types";
|
||||
|
||||
export { CUSTOM_JSX_METHOD_COLORS, parseRequestCapabilities } from "./request-capabilities";
|
||||
|
||||
const EMPTY_RECORD: Record<string, never> = {};
|
||||
|
||||
const methodColors: Readonly<Record<string, string>> = {
|
||||
GET: "blue",
|
||||
POST: "orange",
|
||||
PUT: "yellow",
|
||||
DELETE: "red",
|
||||
PATCH: "grape",
|
||||
};
|
||||
const methods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
|
||||
const kinds = new Set(["query", "action"]);
|
||||
const permissions = new Set(["view", "modify", "full"]);
|
||||
|
||||
export interface CustomJsxRendererMessages {
|
||||
noTemplate: string;
|
||||
templateWarnings(count: number): string;
|
||||
@@ -38,42 +28,6 @@ export interface CustomJsxRendererProps {
|
||||
messages: CustomJsxRendererMessages;
|
||||
}
|
||||
|
||||
export const CUSTOM_JSX_METHOD_COLORS = methodColors;
|
||||
|
||||
export function parseRequestCapabilities(value: unknown): CustomJsxRequestCapability[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const record = candidate as Record<string, unknown>;
|
||||
if (
|
||||
typeof record.id !== "string" ||
|
||||
typeof record.kind !== "string" ||
|
||||
!kinds.has(record.kind) ||
|
||||
typeof record.method !== "string" ||
|
||||
!methods.has(record.method) ||
|
||||
typeof record.minimumBoardPermission !== "string" ||
|
||||
!permissions.has(record.minimumBoardPermission)
|
||||
)
|
||||
return [];
|
||||
return [
|
||||
{
|
||||
id: record.id,
|
||||
kind: record.kind,
|
||||
method: record.method,
|
||||
trigger: record.trigger === "load" ? "load" : "manual",
|
||||
minimumBoardPermission: record.minimumBoardPermission,
|
||||
confirmation:
|
||||
record.confirmation && typeof record.confirmation === "object"
|
||||
? (record.confirmation as CustomJsxRequestCapability["confirmation"])
|
||||
: undefined,
|
||||
invalidates: Array.isArray(record.invalidates)
|
||||
? record.invalidates.filter((entry): entry is string => typeof entry === "string")
|
||||
: [],
|
||||
} as CustomJsxRequestCapability,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
class RendererErrorBoundary extends Component<
|
||||
{ children: ReactNode; resetKey: string; onError(error: Error): void },
|
||||
{ error: Error | null; resetKey: string }
|
||||
@@ -216,6 +170,21 @@ function CustomJsxRendererSession({
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
const resetInput = useCallback((name: string, type: WidgetInputType) => {
|
||||
let activeRegistration: InputRegistration | undefined;
|
||||
for (const registration of registrations.current.values()) {
|
||||
if (registration.name !== name) continue;
|
||||
activeRegistration = registration;
|
||||
break;
|
||||
}
|
||||
if (!activeRegistration || activeRegistration.type !== type) return;
|
||||
setInputState((current) => {
|
||||
if (current.types[name] !== type || Object.is(current.values[name], activeRegistration.initialValue)) {
|
||||
return current;
|
||||
}
|
||||
return { ...current, values: { ...current.values, [name]: activeRegistration.initialValue } };
|
||||
});
|
||||
}, []);
|
||||
const rendered = useMemo(() => {
|
||||
try {
|
||||
const bindings = { ...createBindings(data), status, options, inputs };
|
||||
@@ -259,6 +228,7 @@ function CustomJsxRendererSession({
|
||||
inputTypes={inputTypes}
|
||||
registerInput={registerInput}
|
||||
setInputValue={setInputValue}
|
||||
resetInput={resetInput}
|
||||
>
|
||||
<RendererErrorBoundary resetKey={rendered.boundaryKey} onError={handleError}>
|
||||
{rendered.node}
|
||||
|
||||
@@ -7,23 +7,32 @@ import { IconRefresh } from "@tabler/icons-react";
|
||||
import { useCustomWidgetRuntime } from "./context";
|
||||
|
||||
export interface RefreshButtonProps {
|
||||
requestId?: string;
|
||||
label?: string;
|
||||
color?: string;
|
||||
variant?: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
export function RefreshButton({ label, color = "gray", variant = "subtle", size = "sm" }: RefreshButtonProps) {
|
||||
export function RefreshButton({
|
||||
requestId,
|
||||
label,
|
||||
color = "gray",
|
||||
variant = "subtle",
|
||||
size = "sm",
|
||||
}: RefreshButtonProps) {
|
||||
const runtime = useCustomWidgetRuntime();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const refresh = async () => {
|
||||
if (!runtime.itemId && !runtime.previewSessionId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
let targets = ["parent", "*"];
|
||||
if (requestId) targets = [requestId];
|
||||
await runtime.port.invalidate({
|
||||
itemId: runtime.itemId,
|
||||
previewSessionId: runtime.previewSessionId,
|
||||
targets: ["parent", "*"],
|
||||
targets,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { CustomJsxRequestCapability } from "./types";
|
||||
|
||||
export const CUSTOM_JSX_METHOD_COLORS: Readonly<Record<string, string>> = {
|
||||
GET: "blue",
|
||||
POST: "orange",
|
||||
PUT: "yellow",
|
||||
DELETE: "red",
|
||||
PATCH: "grape",
|
||||
};
|
||||
|
||||
const methods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
|
||||
const kinds = new Set(["query", "action"]);
|
||||
const permissions = new Set(["view", "modify", "full"]);
|
||||
|
||||
const parseConfirmation = (value: unknown): CustomJsxRequestCapability["confirmation"] => {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
const record = value as Record<string, unknown>;
|
||||
if (typeof record.title !== "string" || typeof record.message !== "string") return undefined;
|
||||
if (record.confirmLabel !== undefined && typeof record.confirmLabel !== "string") return undefined;
|
||||
if (record.destructive !== undefined && typeof record.destructive !== "boolean") return undefined;
|
||||
|
||||
const confirmation: NonNullable<CustomJsxRequestCapability["confirmation"]> = {
|
||||
title: record.title,
|
||||
message: record.message,
|
||||
};
|
||||
if (typeof record.confirmLabel === "string") confirmation.confirmLabel = record.confirmLabel;
|
||||
if (typeof record.destructive === "boolean") confirmation.destructive = record.destructive;
|
||||
return confirmation;
|
||||
};
|
||||
|
||||
export function parseRequestCapabilities(value: unknown): CustomJsxRequestCapability[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.flatMap((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const record = candidate as Record<string, unknown>;
|
||||
if (
|
||||
typeof record.id !== "string" ||
|
||||
typeof record.kind !== "string" ||
|
||||
!kinds.has(record.kind) ||
|
||||
typeof record.method !== "string" ||
|
||||
!methods.has(record.method) ||
|
||||
typeof record.minimumBoardPermission !== "string" ||
|
||||
!permissions.has(record.minimumBoardPermission)
|
||||
)
|
||||
return [];
|
||||
return [
|
||||
{
|
||||
id: record.id,
|
||||
kind: record.kind,
|
||||
method: record.method,
|
||||
trigger: record.trigger === "load" ? "load" : "manual",
|
||||
minimumBoardPermission: record.minimumBoardPermission,
|
||||
confirmation: parseConfirmation(record.confirmation),
|
||||
invalidates: Array.isArray(record.invalidates)
|
||||
? record.invalidates.filter((entry): entry is string => typeof entry === "string")
|
||||
: [],
|
||||
} as CustomJsxRequestCapability,
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -42,16 +42,23 @@ export const MAX_REFRESH_INTERVAL_MS = MAX_REFRESH_INTERVAL_SECONDS * 1_000;
|
||||
export function SubFetch(props: SubFetchProps) {
|
||||
const { itemId, previewSessionId, queryCacheKey, queriesDisabled, port, messages, setQueryState } =
|
||||
useCustomWidgetRuntime();
|
||||
const [manualRun, setManualRun] = useState(false);
|
||||
const [manualRunKey, setManualRunKey] = useState<string | null>(null);
|
||||
const params = useMemo(() => normalizeParams(props.params), [props.params]);
|
||||
const paramsKey = useMemo(() => JSON.stringify(params), [params]);
|
||||
useEffect(() => setManualRun(false), [paramsKey, props.requestId, props.trigger]);
|
||||
const manualRequestKey = JSON.stringify([
|
||||
itemId,
|
||||
previewSessionId,
|
||||
queryCacheKey,
|
||||
props.requestId,
|
||||
props.trigger,
|
||||
paramsKey,
|
||||
]);
|
||||
const enabled = Boolean(
|
||||
!queriesDisabled &&
|
||||
(itemId || previewSessionId) &&
|
||||
props.requestId &&
|
||||
params &&
|
||||
(props.trigger !== "manual" || manualRun),
|
||||
(props.trigger !== "manual" || manualRunKey === manualRequestKey),
|
||||
);
|
||||
const refreshMs = normalizeRefreshInterval(props.refreshInterval);
|
||||
const scope = itemId ? "item" : "preview";
|
||||
@@ -101,14 +108,14 @@ export function SubFetch(props: SubFetchProps) {
|
||||
? props.triggerContent
|
||||
: (props.fallback ?? <Text c="dimmed">{messages.unsavedPreview}</Text>);
|
||||
if (!params) return <RequestAlert message={messages.invalidParams} />;
|
||||
if (props.trigger === "manual" && !manualRun) {
|
||||
if (props.trigger === "manual" && manualRunKey !== manualRequestKey) {
|
||||
if (props.triggerContent) {
|
||||
return (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
aria-label={props.triggerAriaLabel ?? messages.loadRequest}
|
||||
onClick={() => setManualRun(true)}
|
||||
onClick={() => setManualRunKey(manualRequestKey)}
|
||||
style={{
|
||||
appearance: "none",
|
||||
background: "transparent",
|
||||
@@ -126,7 +133,7 @@ export function SubFetch(props: SubFetchProps) {
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button size="compact-sm" variant="light" onClick={() => setManualRun(true)}>
|
||||
<Button size="compact-sm" variant="light" onClick={() => setManualRunKey(manualRequestKey)}>
|
||||
{messages.loadRequest}
|
||||
</Button>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user