fix: restore assistant approval flows
This commit is contained in:
1 parent
d0e28a5fc9
commit
a5fa6d3afe
10 files changed
+398
-37
No files matched your search
@@ -124,7 +124,10 @@ structured result remains available in the tool card details.
|
||||
|
||||
When the assistant genuinely needs missing information or a choice, it can pause with a structured
|
||||
question. Users can select one of the proposed answers or choose **Other** and enter their own response.
|
||||
Submitting the answer resumes the same agent run automatically.
|
||||
Submitting the answer resumes the same agent run automatically. The minimized activity bar shows the
|
||||
question or approval that needs attention and opens directly back to the request. While an answer, app
|
||||
form, or mutation approval is pending, the composer preserves its draft but does not replace the pending
|
||||
tool call with a new message.
|
||||
|
||||
App creation uses Homarr's native app form inside the conversation. The form reuses the local icon picker,
|
||||
including automatic matching and searches through the `icon.findIcons` procedure, before the final
|
||||
|
||||
@@ -32,6 +32,7 @@ export const AssistantAskUserTool = ({
|
||||
const t = useScopedI18n("common.assistant.askUser");
|
||||
const [showOther, setShowOther] = useState(false);
|
||||
const [other, setOther] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const otherInputRef = useRef<HTMLInputElement>(null);
|
||||
const options = Array.isArray(args?.options) ? args.options : [];
|
||||
|
||||
@@ -69,10 +70,16 @@ export const AssistantAskUserTool = ({
|
||||
);
|
||||
}
|
||||
|
||||
const submitResult = (answer: AskUserResult) => {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
addResult(answer);
|
||||
};
|
||||
|
||||
const submitOther = () => {
|
||||
const answer = other.trim();
|
||||
if (!answer) return;
|
||||
addResult({ answer, source: "other" });
|
||||
submitResult({ answer, source: "other" });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -102,7 +109,8 @@ export const AssistantAskUserTool = ({
|
||||
size="md"
|
||||
fullWidth
|
||||
justify="space-between"
|
||||
onClick={() => addResult({ answer: option.label, optionId: option.id, source: "option" })}
|
||||
disabled={submitting}
|
||||
onClick={() => submitResult({ answer: option.label, optionId: option.id, source: "option" })}
|
||||
>
|
||||
<Box ta="start">
|
||||
<Text component="span" size="sm" fw={650}>
|
||||
@@ -134,7 +142,7 @@ export const AssistantAskUserTool = ({
|
||||
label={t("otherLabel")}
|
||||
placeholder={t("otherPlaceholder")}
|
||||
/>
|
||||
<Button size="md" onClick={submitOther} disabled={!other.trim()}>
|
||||
<Button size="md" onClick={submitOther} disabled={!other.trim() || submitting} loading={submitting}>
|
||||
{t("submit")}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -174,6 +182,7 @@ export const AssistantConfigureAppTool = ({
|
||||
addResult,
|
||||
}: ToolCallMessagePartProps<ConfigureAppArgs, AppValues>) => {
|
||||
const t = useScopedI18n("common.assistant.configureApp");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
if (result) {
|
||||
return (
|
||||
@@ -205,8 +214,12 @@ export const AssistantConfigureAppTool = ({
|
||||
initialValues={toAppValues(args)}
|
||||
buttonLabels={{ submit: t("continue") }}
|
||||
showBackToOverview={false}
|
||||
handleSubmit={(values) => addResult(values)}
|
||||
isPending={false}
|
||||
handleSubmit={(values) => {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
addResult(values);
|
||||
}}
|
||||
isPending={submitting}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -39,6 +39,17 @@
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.activityReviewAction {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
color: var(--mantine-color-yellow-light-color);
|
||||
background: var(--mantine-color-yellow-light);
|
||||
}
|
||||
|
||||
.activityDismiss {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
@@ -346,6 +357,13 @@
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
.pendingActionBanner {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-block: 1px solid var(--mantine-color-default-border);
|
||||
background: var(--mantine-color-yellow-light);
|
||||
}
|
||||
|
||||
.humanTool,
|
||||
.appTool {
|
||||
width: 100%;
|
||||
@@ -982,6 +1000,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 30em) {
|
||||
.activityReviewAction > span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.historyButton {
|
||||
width: 2.625rem;
|
||||
max-width: 2.625rem;
|
||||
|
||||
@@ -100,6 +100,7 @@ import { useScopedI18n } from "@homarr/translation/client";
|
||||
import classes from "./assistant-panel.module.css";
|
||||
import { normalizeAssistantMarkdown } from "./assistant-markdown";
|
||||
import { getAssistantTelemetry, getAssistantUsage } from "./assistant-message-metadata";
|
||||
import type { AssistantPendingAction } from "./assistant-pending-action";
|
||||
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
|
||||
import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll";
|
||||
import { getToolResultPresentation } from "./assistant-tool-result";
|
||||
@@ -115,6 +116,7 @@ interface AssistantPanelProps {
|
||||
latestAssistantText: string;
|
||||
latestUserText: string;
|
||||
latestStatus: MessageStatus | undefined;
|
||||
pendingAction: AssistantPendingAction | undefined;
|
||||
modelId: string | null;
|
||||
models: AssistantRuntimeModelOption[];
|
||||
modelOptionsLoading: boolean;
|
||||
@@ -346,6 +348,7 @@ const ToolPart = ({
|
||||
}: ToolCallMessagePartProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [approvalResponse, setApprovalResponse] = useState<"approve" | "deny" | null>(null);
|
||||
const completed = status?.type === "complete";
|
||||
const awaitingApproval = approval !== undefined && approval.approved === undefined && !approval.resolution;
|
||||
const denied = approval?.approved === false;
|
||||
@@ -413,7 +416,12 @@ const ToolPart = ({
|
||||
size="md"
|
||||
fullWidth
|
||||
leftSection={<IconCheck size={18} />}
|
||||
onClick={() => respondToApproval({ approved: true })}
|
||||
loading={approvalResponse === "approve"}
|
||||
disabled={approvalResponse !== null}
|
||||
onClick={() => {
|
||||
setApprovalResponse("approve");
|
||||
respondToApproval({ approved: true });
|
||||
}}
|
||||
>
|
||||
{t("approveAndRun")}
|
||||
</Button>
|
||||
@@ -422,7 +430,12 @@ const ToolPart = ({
|
||||
fullWidth
|
||||
variant="default"
|
||||
leftSection={<IconX size={18} />}
|
||||
onClick={() => respondToApproval({ approved: false })}
|
||||
loading={approvalResponse === "deny"}
|
||||
disabled={approvalResponse !== null}
|
||||
onClick={() => {
|
||||
setApprovalResponse("deny");
|
||||
respondToApproval({ approved: false });
|
||||
}}
|
||||
>
|
||||
{t("deny")}
|
||||
</Button>
|
||||
@@ -1239,7 +1252,7 @@ const ComposerTriggers = () => {
|
||||
type ComposerProps = Pick<
|
||||
AssistantPanelProps,
|
||||
"modelId" | "models" | "modelOptionsLoading" | "reasoning" | "onModelChange" | "onReasoningChange"
|
||||
>;
|
||||
> & { pendingAction: AssistantPendingAction | undefined };
|
||||
|
||||
const RuntimeControls = ({
|
||||
modelId,
|
||||
@@ -1374,11 +1387,18 @@ const RuntimeControls = ({
|
||||
const Composer = (props: ComposerProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const running = useAuiState((state) => state.thread.isRunning);
|
||||
const hasPendingAction = props.pendingAction !== undefined;
|
||||
return (
|
||||
<Box className={classes.composerWrap}>
|
||||
<ComposerPrimitive.Unstable_TriggerPopoverRoot>
|
||||
<ComposerPrimitive.AttachmentDropzone className={classes.composerDropzone}>
|
||||
<ComposerPrimitive.Root className={classes.composer}>
|
||||
<ComposerPrimitive.Root
|
||||
className={classes.composer}
|
||||
data-pending-action={hasPendingAction || undefined}
|
||||
onSubmit={(event) => {
|
||||
if (hasPendingAction) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<ComposerTriggers />
|
||||
<ComposerPrimitive.Quote className={classes.composerQuote}>
|
||||
<IconQuote size={15} />
|
||||
@@ -1414,7 +1434,13 @@ const Composer = (props: ComposerProps) => {
|
||||
</ComposerPrimitive.Cancel>
|
||||
) : (
|
||||
<ComposerPrimitive.Send asChild>
|
||||
<ActionIcon color="red" variant="filled" size="lg" aria-label={t("send")}>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="filled"
|
||||
size="lg"
|
||||
aria-label={hasPendingAction ? t("pendingAction.sendBlocked") : t("send")}
|
||||
disabled={hasPendingAction}
|
||||
>
|
||||
<IconArrowUp size={18} />
|
||||
</ActionIcon>
|
||||
</ComposerPrimitive.Send>
|
||||
@@ -1438,12 +1464,55 @@ const Composer = (props: ComposerProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const usePendingActionCopy = (action: AssistantPendingAction | undefined) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
if (!action) return undefined;
|
||||
if (action.kind === "question") {
|
||||
return { title: t("pendingAction.answerTitle"), detail: action.detail ?? t("pendingAction.answerFallback") };
|
||||
}
|
||||
if (action.kind === "form") {
|
||||
return { title: t("pendingAction.formTitle"), detail: action.detail ?? t("configureApp.title") };
|
||||
}
|
||||
const tool = action.toolName.replaceAll("_", " ");
|
||||
return { title: t("activity.approval"), detail: action.detail ? `${tool} · ${action.detail}` : tool };
|
||||
};
|
||||
|
||||
const PendingActionBanner = ({ pendingAction }: { pendingAction: AssistantPendingAction | undefined }) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const copy = usePendingActionCopy(pendingAction);
|
||||
if (!copy) return null;
|
||||
|
||||
return (
|
||||
<Box component="output" className={classes.pendingActionBanner}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="yellow" radius="xl">
|
||||
<IconMessage size={17} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} flex={1} miw={0}>
|
||||
<Text size="sm" fw={700}>
|
||||
{copy.title}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||
{copy.detail}
|
||||
</Text>
|
||||
</Stack>
|
||||
<ThreadPrimitive.ScrollToBottom asChild>
|
||||
<Button variant="light" color="yellow" size="compact-sm">
|
||||
{t("pendingAction.review")}
|
||||
</Button>
|
||||
</ThreadPrimitive.ScrollToBottom>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantActivityBar = ({
|
||||
isRunning,
|
||||
unreadCount,
|
||||
latestAssistantText,
|
||||
latestUserText,
|
||||
latestStatus,
|
||||
pendingAction,
|
||||
onOpen,
|
||||
onDismissActivity,
|
||||
activityDismissed,
|
||||
@@ -1454,12 +1523,14 @@ const AssistantActivityBar = ({
|
||||
| "latestAssistantText"
|
||||
| "latestUserText"
|
||||
| "latestStatus"
|
||||
| "pendingAction"
|
||||
| "onOpen"
|
||||
| "onDismissActivity"
|
||||
| "activityDismissed"
|
||||
>) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const needsApproval = latestStatus?.type === "requires-action";
|
||||
const pendingCopy = usePendingActionCopy(pendingAction);
|
||||
const needsApproval = pendingAction !== undefined || latestStatus?.type === "requires-action";
|
||||
const failed = latestStatus?.type === "incomplete" && latestStatus.reason !== "cancelled";
|
||||
const visible =
|
||||
!activityDismissed &&
|
||||
@@ -1469,14 +1540,16 @@ const AssistantActivityBar = ({
|
||||
|
||||
const title = isRunning
|
||||
? t("activity.thinking")
|
||||
: needsApproval
|
||||
? t("activity.approval")
|
||||
: failed
|
||||
? t("activity.failed")
|
||||
: t("activity.ready");
|
||||
: pendingCopy
|
||||
? pendingCopy.title
|
||||
: needsApproval
|
||||
? t("activity.approval")
|
||||
: failed
|
||||
? t("activity.failed")
|
||||
: t("activity.ready");
|
||||
const detail = isRunning
|
||||
? latestUserText || t("activity.working")
|
||||
: latestAssistantText || (failed ? t("responseError.description") : t("activity.completed"));
|
||||
: pendingCopy?.detail || latestAssistantText || (failed ? t("responseError.description") : t("activity.completed"));
|
||||
|
||||
return (
|
||||
<Box component="output" className={classes.activityBar} aria-live="polite">
|
||||
@@ -1512,7 +1585,16 @@ const AssistantActivityBar = ({
|
||||
{detail}
|
||||
</Text>
|
||||
</Stack>
|
||||
<IconChevronUp size={17} className={classes.activityExpandIcon} />
|
||||
{needsApproval ? (
|
||||
<Box className={classes.activityReviewAction}>
|
||||
<Text component="span" size="xs" fw={700}>
|
||||
{t("pendingAction.review")}
|
||||
</Text>
|
||||
<IconChevronUp size={15} />
|
||||
</Box>
|
||||
) : (
|
||||
<IconChevronUp size={17} className={classes.activityExpandIcon} />
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
{!isRunning && !needsApproval && (
|
||||
@@ -1541,6 +1623,7 @@ export const AssistantPanel = ({
|
||||
latestAssistantText,
|
||||
latestUserText,
|
||||
latestStatus,
|
||||
pendingAction,
|
||||
modelId,
|
||||
models,
|
||||
modelOptionsLoading,
|
||||
@@ -1584,6 +1667,7 @@ export const AssistantPanel = ({
|
||||
latestAssistantText={latestAssistantText}
|
||||
latestUserText={latestUserText}
|
||||
latestStatus={latestStatus}
|
||||
pendingAction={pendingAction}
|
||||
/>
|
||||
)}
|
||||
{opened && (
|
||||
@@ -1654,6 +1738,7 @@ export const AssistantPanel = ({
|
||||
</ActionIcon>
|
||||
</ThreadPrimitive.ScrollToBottom>
|
||||
</ThreadPrimitive.Viewport>
|
||||
<PendingActionBanner pendingAction={pendingAction} />
|
||||
<Composer
|
||||
modelId={modelId}
|
||||
models={models}
|
||||
@@ -1661,6 +1746,7 @@ export const AssistantPanel = ({
|
||||
reasoning={reasoning}
|
||||
onModelChange={onModelChange}
|
||||
onReasoningChange={onReasoningChange}
|
||||
pendingAction={pendingAction}
|
||||
/>
|
||||
</ThreadPrimitive.Root>
|
||||
</dialog>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { ThreadMessage } from "@assistant-ui/react";
|
||||
|
||||
import { getPendingAssistantAction } from "./assistant-pending-action";
|
||||
|
||||
const assistantMessage = (...content: ThreadMessage["content"]): ThreadMessage =>
|
||||
({
|
||||
id: "assistant-message",
|
||||
role: "assistant",
|
||||
content,
|
||||
status: { type: "requires-action", reason: "tool-calls" },
|
||||
createdAt: new Date(),
|
||||
metadata: { custom: {} },
|
||||
}) as ThreadMessage;
|
||||
|
||||
describe("getPendingAssistantAction", () => {
|
||||
test("exposes an unanswered structured question", () => {
|
||||
expect(
|
||||
getPendingAssistantAction(
|
||||
assistantMessage({
|
||||
type: "tool-call",
|
||||
toolCallId: "ask-1",
|
||||
toolName: "ask_user",
|
||||
args: { question: "Which board should receive the widget?" },
|
||||
argsText: "{}",
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
kind: "question",
|
||||
toolName: "ask_user",
|
||||
detail: "Which board should receive the widget?",
|
||||
});
|
||||
});
|
||||
|
||||
test("clears a human action after its result is supplied", () => {
|
||||
expect(
|
||||
getPendingAssistantAction(
|
||||
assistantMessage({
|
||||
type: "tool-call",
|
||||
toolCallId: "ask-1",
|
||||
toolName: "ask_user",
|
||||
args: { question: "Continue?" },
|
||||
argsText: "{}",
|
||||
result: { answer: "Yes" },
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("exposes the app form while its details still need review", () => {
|
||||
expect(
|
||||
getPendingAssistantAction(
|
||||
assistantMessage({
|
||||
type: "tool-call",
|
||||
toolCallId: "configure-1",
|
||||
toolName: "configure_app",
|
||||
args: { name: "Wikipedia" },
|
||||
argsText: "{}",
|
||||
}),
|
||||
),
|
||||
).toEqual({ kind: "form", toolName: "configure_app", detail: "Wikipedia" });
|
||||
});
|
||||
|
||||
test("exposes an unresolved mutation approval", () => {
|
||||
expect(
|
||||
getPendingAssistantAction(
|
||||
assistantMessage({
|
||||
type: "tool-call",
|
||||
toolCallId: "create-1",
|
||||
toolName: "app_create",
|
||||
args: { name: "Wikipedia" },
|
||||
argsText: "{}",
|
||||
approval: { id: "approval-1" },
|
||||
}),
|
||||
),
|
||||
).toEqual({ kind: "approval", toolName: "app_create", detail: "Wikipedia" });
|
||||
});
|
||||
|
||||
test("clears an approval after the user responds", () => {
|
||||
expect(
|
||||
getPendingAssistantAction(
|
||||
assistantMessage({
|
||||
type: "tool-call",
|
||||
toolCallId: "create-1",
|
||||
toolName: "app_create",
|
||||
args: { name: "Wikipedia" },
|
||||
argsText: "{}",
|
||||
approval: { id: "approval-1", approved: true },
|
||||
}),
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ThreadMessage } from "@assistant-ui/react";
|
||||
|
||||
export type AssistantPendingAction = {
|
||||
kind: "question" | "form" | "approval";
|
||||
toolName: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
const humanToolKinds = {
|
||||
ask_user: "question",
|
||||
configure_app: "form",
|
||||
} as const;
|
||||
|
||||
const getStringArg = (args: unknown, key: string) => {
|
||||
if (typeof args !== "object" || args === null || !(key in args)) return undefined;
|
||||
const value = (args as Record<string, unknown>)[key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
};
|
||||
|
||||
export const getPendingAssistantAction = (message: ThreadMessage | undefined): AssistantPendingAction | undefined => {
|
||||
if (message?.role !== "assistant") return undefined;
|
||||
|
||||
for (const part of message.content.toReversed()) {
|
||||
if (part.type !== "tool-call" || part.isError) continue;
|
||||
|
||||
if (part.approval && part.approval.approved === undefined && !part.approval.resolution) {
|
||||
return {
|
||||
kind: "approval",
|
||||
toolName: part.toolName,
|
||||
detail: getStringArg(part.args, "name"),
|
||||
};
|
||||
}
|
||||
|
||||
const kind = humanToolKinds[part.toolName as keyof typeof humanToolKinds];
|
||||
if (kind === undefined || part.result !== undefined) continue;
|
||||
|
||||
return {
|
||||
kind,
|
||||
toolName: part.toolName,
|
||||
detail: getStringArg(part.args, kind === "question" ? "question" : "name"),
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -11,15 +11,7 @@ import type {
|
||||
ThreadMessage,
|
||||
Toolkit,
|
||||
} from "@assistant-ui/react";
|
||||
import {
|
||||
AssistantRuntimeProvider,
|
||||
defineToolkit,
|
||||
Tools,
|
||||
useAui,
|
||||
useAuiEvent,
|
||||
useAuiState,
|
||||
useRemoteThreadListRuntime,
|
||||
} from "@assistant-ui/react";
|
||||
import { defineToolkit, useAui, useAuiEvent, useAuiState, useRemoteThreadListRuntime } from "@assistant-ui/react";
|
||||
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
|
||||
import { useHotkeys } from "@mantine/hooks";
|
||||
import { createAssistantStream } from "assistant-stream";
|
||||
@@ -35,7 +27,9 @@ import { openMediaRequestSearch, openSpotlight, useRegisterSpotlightContextResul
|
||||
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
|
||||
import { AssistantAskUserTool, AssistantConfigureAppTool } from "./assistant-human-tools";
|
||||
import { AssistantPanel } from "./assistant-panel";
|
||||
import { getPendingAssistantAction } from "./assistant-pending-action";
|
||||
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
|
||||
import { AssistantRuntimeProviderWithTools } from "./assistant-runtime-provider";
|
||||
import { sendAssistantPrompt as sendPromptThroughComposer } from "./assistant-send";
|
||||
import { createAssistantPromptInteraction } from "./assistant-spotlight";
|
||||
import { browserToolContracts } from "./assistant-tool-contracts";
|
||||
@@ -461,14 +455,12 @@ const AssistantRuntime = ({ children }: PropsWithChildren) => {
|
||||
}) as Toolkit,
|
||||
[router],
|
||||
);
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<AssistantTools toolkit={toolkit} />
|
||||
<AssistantRuntimeProviderWithTools runtime={runtime} toolkit={toolkit}>
|
||||
<AssistantRuntimeEvents />
|
||||
<AssistantPreferenceSync />
|
||||
{children}
|
||||
</AssistantRuntimeProvider>
|
||||
</AssistantRuntimeProviderWithTools>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -495,11 +487,6 @@ const AssistantPreferenceSync = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const AssistantTools = ({ toolkit }: { toolkit: Toolkit }) => {
|
||||
useAui({ tools: Tools({ toolkit }) });
|
||||
return null;
|
||||
};
|
||||
|
||||
const AssistantRuntimeEvents = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
useAuiEvent("composer.attachmentAddError", ({ message }) => {
|
||||
@@ -543,6 +530,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
const latestAssistantText = getMessageText(latestAssistantMessage);
|
||||
const latestUserText = getMessageText(latestUserMessage);
|
||||
const latestStatus = latestAssistantMessage?.role === "assistant" ? latestAssistantMessage.status : undefined;
|
||||
const pendingAction = getPendingAssistantAction(latestAssistantMessage);
|
||||
const assistantIsRunning = isRunning || queuedPrompt !== null;
|
||||
const notificationKey = getNotificationKey(latestAssistantMessage);
|
||||
const initializedRef = useRef(false);
|
||||
@@ -662,6 +650,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
latestAssistantText={latestAssistantText}
|
||||
latestUserText={queuedPrompt ?? latestUserText}
|
||||
latestStatus={latestStatus}
|
||||
pendingAction={pendingAction}
|
||||
modelId={preferences.modelId}
|
||||
models={preferences.models}
|
||||
modelOptionsLoading={preferences.isLoading}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { ChatModelAdapter, ThreadMessageLike } from "@assistant-ui/react";
|
||||
import { defineToolkit, MessagePrimitive, ThreadPrimitive, useLocalRuntime } from "@assistant-ui/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { AssistantRuntimeProviderWithTools } from "./assistant-runtime-provider";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const noOpAdapter: ChatModelAdapter = { async *run() {} };
|
||||
const toolkit = defineToolkit({
|
||||
ask_user: {
|
||||
type: "human",
|
||||
display: "standalone",
|
||||
parameters: z.object({ question: z.string() }),
|
||||
render: ({ args }) => createElement("div", { "data-testid": "ask-user-ui" }, `Question: ${args.question}`),
|
||||
},
|
||||
});
|
||||
const messages: ThreadMessageLike[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "ask-1",
|
||||
toolName: "ask_user",
|
||||
args: { question: "Which board?" },
|
||||
argsText: JSON.stringify({ question: "Which board?" }),
|
||||
},
|
||||
],
|
||||
status: { type: "requires-action", reason: "tool-calls" },
|
||||
},
|
||||
];
|
||||
|
||||
const Message = () =>
|
||||
createElement(MessagePrimitive.Parts, {
|
||||
components: {
|
||||
tools: {
|
||||
Fallback: () => createElement("div", { "data-testid": "fallback-tool-ui" }, "Fallback"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const TestAssistant = () => {
|
||||
const runtime = useLocalRuntime(noOpAdapter, { initialMessages: messages });
|
||||
return createElement(
|
||||
AssistantRuntimeProviderWithTools,
|
||||
{ runtime, toolkit },
|
||||
createElement(ThreadPrimitive.Messages, { components: { Message } }),
|
||||
);
|
||||
};
|
||||
|
||||
const containers: HTMLDivElement[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const container of containers.splice(0)) container.remove();
|
||||
});
|
||||
|
||||
describe("AssistantRuntimeProviderWithTools", () => {
|
||||
test("makes the registered human-tool renderer available to message parts", async () => {
|
||||
const container = document.createElement("div");
|
||||
containers.push(container);
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(TestAssistant));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="ask-user-ui"]')?.textContent).toBe("Question: Which board?");
|
||||
expect(container.querySelector('[data-testid="fallback-tool-ui"]')).toBeNull();
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import type { AssistantRuntime, Toolkit } from "@assistant-ui/react";
|
||||
import { AssistantRuntimeProvider, Tools, useAui } from "@assistant-ui/react";
|
||||
|
||||
interface AssistantRuntimeProviderWithToolsProps extends PropsWithChildren {
|
||||
runtime: AssistantRuntime;
|
||||
toolkit: Toolkit;
|
||||
}
|
||||
|
||||
export const AssistantRuntimeProviderWithTools = ({
|
||||
children,
|
||||
runtime,
|
||||
toolkit,
|
||||
}: AssistantRuntimeProviderWithToolsProps) => {
|
||||
const toolsAui = useAui({ tools: Tools({ toolkit }) });
|
||||
return (
|
||||
<AssistantRuntimeProvider aui={toolsAui} runtime={runtime}>
|
||||
{children}
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
};
|
||||
@@ -1637,6 +1637,13 @@
|
||||
"title": "The assistant is already working",
|
||||
"description": "Wait for the current response or open it to stop the run."
|
||||
},
|
||||
"pendingAction": {
|
||||
"answerTitle": "Answer needed",
|
||||
"answerFallback": "Choose an answer to continue",
|
||||
"formTitle": "Review app details",
|
||||
"review": "Review",
|
||||
"sendBlocked": "Resolve the pending request before sending"
|
||||
},
|
||||
"activity": {
|
||||
"thinking": "Assistant is thinking",
|
||||
"working": "Working on your prompt",
|
||||
|
||||
Reference in new issue
Block a user