refactor: simplify assistant approvals

This commit is contained in:
ajnart committed 2026-08-20 15:36:34 +02:00
1 parent ef6d350b4d
commit 2f7d150d01
3 files changed
+27 -91

No files matched your search

+3 -17
View File
@@ -41,32 +41,18 @@ host gateway address.
- Ask from `Cmd/Ctrl + K`.
- Add the [Assistant widget](../widgets/assistant) to a board.
On desktop, the full conversation opens as a side panel so the current dashboard remains visible. On mobile, it uses
the full screen. Homarr keeps one active conversation surface: while the side panel is open, the board widget becomes
a compact return control, and a visible board widget replaces the floating activity bar. If a board contains multiple
Assistant widgets, use a compact widget's action to move the active conversation there. Minimize returns the
conversation to its widget or activity state; Close also dismisses completed activity.
The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to
add Homarr items as context, `/` for shortcuts, and the paperclip for supported files.
In supported browsers, use the response action menu to read an answer aloud.
Suggested prompts fill the composer first so you can review or edit them before sending. Questions and approval requests
stay next to the composer without hiding the transcript.
Use **Conversations** to search and switch threads. Open a conversation's action menu to rename it, export it as
Markdown, or delete it after confirmation.
Use **Conversations** to search, switch, rename, export, or delete threads.
Each request includes the user's name, current page and board, home board, server time, browser time zone, and a small
summary of accessible Homarr resources. Live status is fetched only when needed.
## Permissions and privacy
- Read-only actions can run automatically. Changes require approval by default.
- **Automatic approvals** can prepare proposed form details and approve protected changes for the current conversation.
It turns off when you switch conversations and still respects Homarr permissions.
- Questions always wait for your answer, even when automatic approvals are enabled.
- Read-only actions can run automatically. Changes require approval by default. Automatic approvals apply only to the
current conversation, turn off when you switch conversations, and never answer questions for you.
- Homarr permissions apply to every tool call and mentioned item.
- API keys and custom headers are encrypted and never returned to the browser.
- Conversations are stored per user and can be exported or deleted.
@@ -36,70 +36,41 @@ export const createAssistantAutoApprovalTracker = (maximumAttempts = Number.POSI
interface AssistantAutoApprovalContextValue {
enabled: boolean;
setEnabled: (enabled: boolean) => void;
preparationEnabled: boolean;
approvalEnabled: boolean;
setPreparationEnabled: (enabled: boolean) => void;
setApprovalEnabled: (enabled: boolean) => void;
requestAction: (toolCallId: string, confirm: () => void, mode?: AssistantAutomaticActionMode) => boolean;
requestAction: (toolCallId: string, confirm: () => void) => boolean;
completeAction: (toolCallId: string) => void;
retryRevision: number;
}
export type AssistantAutomaticActionMode = "preparation" | "approval";
const AssistantAutoApprovalContext = createContext<AssistantAutoApprovalContextValue | null>(null);
export const AssistantAutoApprovalProvider = ({
children,
conversationId,
}: PropsWithChildren<{ conversationId: string }>) => {
const [preparationEnabled, setPreparationEnabledState] = useState(false);
const [approvalEnabled, setApprovalEnabledState] = useState(false);
const [enabled, setEnabledState] = useState(false);
const [retryRevision, setRetryRevision] = useState(0);
const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS));
const retryTimersRef = useRef(new Map<string, { timer: number; mode: AssistantAutomaticActionMode }>());
const retryTimersRef = useRef(new Map<string, number>());
const previousConversationIdRef = useRef(conversationId);
const conversationMatches = previousConversationIdRef.current === conversationId;
const effectivePreparationEnabled = conversationMatches && preparationEnabled;
const effectiveApprovalEnabled = conversationMatches && approvalEnabled;
const effectiveEnabled = conversationMatches && enabled;
const clearRetryTimer = useCallback((toolCallId: string) => {
const retry = retryTimersRef.current.get(toolCallId);
if (retry !== undefined) window.clearTimeout(retry.timer);
const timer = retryTimersRef.current.get(toolCallId);
if (timer !== undefined) window.clearTimeout(timer);
retryTimersRef.current.delete(toolCallId);
}, []);
const clearAutomaticActions = useCallback((mode?: AssistantAutomaticActionMode) => {
for (const [toolCallId, retry] of retryTimersRef.current) {
if (mode !== undefined && retry.mode !== mode) continue;
window.clearTimeout(retry.timer);
retryTimersRef.current.delete(toolCallId);
trackerRef.current.complete(toolCallId);
}
if (mode === undefined) trackerRef.current.clear();
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();
setPreparationEnabledState(nextEnabled);
setApprovalEnabledState(nextEnabled);
},
[clearAutomaticActions],
);
const setPreparationEnabled = useCallback(
(nextEnabled: boolean) => {
if (!nextEnabled) clearAutomaticActions("preparation");
setPreparationEnabledState(nextEnabled);
},
[clearAutomaticActions],
);
const setApprovalEnabled = useCallback(
(nextEnabled: boolean) => {
if (!nextEnabled) clearAutomaticActions("approval");
setApprovalEnabledState(nextEnabled);
setEnabledState(nextEnabled);
},
[clearAutomaticActions],
);
@@ -110,35 +81,33 @@ export const AssistantAutoApprovalProvider = ({
if (previousConversationId === conversationId) return;
clearAutomaticActions();
setPreparationEnabledState(false);
setApprovalEnabledState(false);
setEnabledState(false);
}, [clearAutomaticActions, conversationId]);
useEffect(() => clearAutomaticActions, [clearAutomaticActions]);
const requestAction = useCallback(
(toolCallId: string, confirm: () => void, mode: AssistantAutomaticActionMode = "preparation") => {
const modeEnabled = mode === "approval" ? effectiveApprovalEnabled : effectivePreparationEnabled;
if (!modeEnabled || !trackerRef.current.claim(toolCallId)) return false;
(toolCallId: string, confirm: () => void) => {
if (!effectiveEnabled || !trackerRef.current.claim(toolCallId)) return false;
try {
confirm();
clearRetryTimer(toolCallId);
retryTimersRef.current.set(toolCallId, {
mode,
timer: window.setTimeout(() => {
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;
}
},
[clearRetryTimer, effectiveApprovalEnabled, effectivePreparationEnabled],
[clearRetryTimer, effectiveEnabled],
);
const completeAction = useCallback(
@@ -150,28 +119,14 @@ export const AssistantAutoApprovalProvider = ({
);
const value = useMemo(() => {
const enabled = effectivePreparationEnabled || effectiveApprovalEnabled;
return {
enabled,
enabled: effectiveEnabled,
setEnabled,
preparationEnabled: effectivePreparationEnabled,
approvalEnabled: effectiveApprovalEnabled,
setPreparationEnabled,
setApprovalEnabled,
requestAction,
completeAction,
retryRevision,
};
}, [
completeAction,
effectiveApprovalEnabled,
effectivePreparationEnabled,
requestAction,
retryRevision,
setApprovalEnabled,
setEnabled,
setPreparationEnabled,
]);
}, [completeAction, effectiveEnabled, requestAction, retryRevision, setEnabled]);
return <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>;
};
@@ -187,20 +142,16 @@ export const useAssistantAutomaticAction = ({
ready,
completed,
confirm,
mode = "preparation",
}: {
toolCallId: string;
ready: boolean;
completed: boolean;
confirm: () => void;
mode?: AssistantAutomaticActionMode;
}) => {
const { approvalEnabled, completeAction, preparationEnabled, requestAction, retryRevision } =
useAssistantAutoApproval();
const { completeAction, enabled, requestAction, retryRevision } = useAssistantAutoApproval();
const [inProgress, setInProgress] = useState(false);
const confirmRef = useRef(confirm);
confirmRef.current = confirm;
const enabled = mode === "approval" ? approvalEnabled : preparationEnabled;
useEffect(() => {
if (!ready || completed || !enabled) {
@@ -209,9 +160,9 @@ export const useAssistantAutomaticAction = ({
return;
}
const requested = requestAction(toolCallId, () => confirmRef.current(), mode);
const requested = requestAction(toolCallId, () => confirmRef.current());
setInProgress(requested);
}, [completeAction, completed, enabled, mode, ready, requestAction, retryRevision, toolCallId]);
}, [completeAction, completed, enabled, ready, requestAction, retryRevision, toolCallId]);
return inProgress;
};
@@ -897,7 +897,6 @@ const ToolPart = ({
toolCallId,
ready: awaitingApproval,
completed: !awaitingApproval,
mode: "approval",
confirm: () => {
respondToApproval({ approved: true, reason: "Approved automatically by the user." });
},