diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 2ac43a19c..4f01109ea 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -41,15 +41,32 @@ 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 microphone to dictate a prompt and 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. + 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. +- 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. - 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. diff --git a/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx b/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx index 0b49efe90..7e37600f1 100644 --- a/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx +++ b/apps/nextjs/src/components/assistant/assistant-auto-approval.tsx @@ -36,39 +36,67 @@ export const createAssistantAutoApprovalTracker = (maximumAttempts = Number.POSI interface AssistantAutoApprovalContextValue { enabled: boolean; setEnabled: (enabled: boolean) => void; - requestAction: (toolCallId: string, confirm: () => void) => boolean; + preparationEnabled: boolean; + approvalEnabled: boolean; + setPreparationEnabled: (enabled: boolean) => void; + setApprovalEnabled: (enabled: boolean) => void; + requestAction: (toolCallId: string, confirm: () => void, mode?: AssistantAutomaticActionMode) => boolean; completeAction: (toolCallId: string) => void; retryRevision: number; } +export type AssistantAutomaticActionMode = "preparation" | "approval"; + const AssistantAutoApprovalContext = createContext(null); export const AssistantAutoApprovalProvider = ({ children, conversationId, }: PropsWithChildren<{ conversationId: string }>) => { - const [enabled, setEnabledState] = useState(false); + const [preparationEnabled, setPreparationEnabledState] = useState(false); + const [approvalEnabled, setApprovalEnabledState] = useState(false); const [retryRevision, setRetryRevision] = useState(0); const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS)); - const retryTimersRef = useRef(new Map()); + const retryTimersRef = useRef(new Map()); const previousConversationIdRef = useRef(conversationId); const clearRetryTimer = useCallback((toolCallId: string) => { - const timer = retryTimersRef.current.get(toolCallId); - if (timer !== undefined) window.clearTimeout(timer); + const retry = retryTimersRef.current.get(toolCallId); + if (retry !== undefined) window.clearTimeout(retry.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 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 setEnabled = useCallback( (nextEnabled: boolean) => { if (!nextEnabled) clearAutomaticActions(); - setEnabledState(nextEnabled); + 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); }, [clearAutomaticActions], ); @@ -79,33 +107,35 @@ export const AssistantAutoApprovalProvider = ({ if (previousConversationId === conversationId) return; clearAutomaticActions(); - setEnabledState(false); + setPreparationEnabledState(false); + setApprovalEnabledState(false); }, [clearAutomaticActions, conversationId]); useEffect(() => clearAutomaticActions, [clearAutomaticActions]); const requestAction = useCallback( - (toolCallId: string, confirm: () => void) => { - if (!enabled || !trackerRef.current.claim(toolCallId)) return false; + (toolCallId: string, confirm: () => void, mode: AssistantAutomaticActionMode = "preparation") => { + const modeEnabled = mode === "approval" ? approvalEnabled : preparationEnabled; + if (!modeEnabled || !trackerRef.current.claim(toolCallId)) return false; try { confirm(); clearRetryTimer(toolCallId); - retryTimersRef.current.set( - toolCallId, - window.setTimeout(() => { + retryTimersRef.current.set(toolCallId, { + mode, + timer: 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, enabled], + [approvalEnabled, clearRetryTimer, preparationEnabled], ); const completeAction = useCallback( @@ -116,10 +146,29 @@ export const AssistantAutoApprovalProvider = ({ [clearRetryTimer], ); - const value = useMemo( - () => ({ enabled, setEnabled, requestAction, completeAction, retryRevision }), - [completeAction, enabled, requestAction, retryRevision, setEnabled], - ); + const value = useMemo(() => { + const enabled = preparationEnabled || approvalEnabled; + return { + enabled, + setEnabled, + preparationEnabled, + approvalEnabled, + setPreparationEnabled, + setApprovalEnabled, + requestAction, + completeAction, + retryRevision, + }; + }, [ + approvalEnabled, + completeAction, + preparationEnabled, + requestAction, + retryRevision, + setApprovalEnabled, + setEnabled, + setPreparationEnabled, + ]); return {children}; }; @@ -135,16 +184,20 @@ export const useAssistantAutomaticAction = ({ ready, completed, confirm, + mode = "preparation", }: { toolCallId: string; ready: boolean; completed: boolean; confirm: () => void; + mode?: AssistantAutomaticActionMode; }) => { - const { completeAction, enabled, requestAction, retryRevision } = useAssistantAutoApproval(); + const { approvalEnabled, completeAction, preparationEnabled, 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) { @@ -153,9 +206,9 @@ export const useAssistantAutomaticAction = ({ return; } - const requested = requestAction(toolCallId, () => confirmRef.current()); + const requested = requestAction(toolCallId, () => confirmRef.current(), mode); setInProgress(requested); - }, [completeAction, completed, enabled, ready, requestAction, retryRevision, toolCallId]); + }, [completeAction, completed, enabled, mode, ready, requestAction, retryRevision, toolCallId]); return inProgress; }; diff --git a/apps/nextjs/src/components/assistant/assistant-context.tsx b/apps/nextjs/src/components/assistant/assistant-context.tsx index b5d2f0a51..bc87c87ad 100644 --- a/apps/nextjs/src/components/assistant/assistant-context.tsx +++ b/apps/nextjs/src/components/assistant/assistant-context.tsx @@ -20,11 +20,15 @@ export interface AssistantContextValue { isRunning: boolean; isRefreshing: boolean; unreadCount: number; + hasVisibleWidget: boolean; + activeWidgetId: string | null; open: () => void; close: () => void; toggle: () => void; sendPrompt: (prompt: string) => boolean; refreshCurrentView: () => Promise; + setWidgetVisible: (widgetId: string, visible: boolean) => void; + activateWidget: (widgetId: string) => void; } export const AssistantContext = createContext(null); diff --git a/apps/nextjs/src/components/assistant/assistant-gate.tsx b/apps/nextjs/src/components/assistant/assistant-gate.tsx index 2a3ddbbb3..162e5c99f 100644 --- a/apps/nextjs/src/components/assistant/assistant-gate.tsx +++ b/apps/nextjs/src/components/assistant/assistant-gate.tsx @@ -34,11 +34,15 @@ const DisabledAssistant = ({ children, description }: PropsWithChildren<{ descri isRunning: false, isRefreshing: false, unreadCount: 0, + hasVisibleWidget: false, + activeWidgetId: null, open: () => undefined, close: () => undefined, toggle: () => undefined, sendPrompt: () => false, refreshCurrentView: () => Promise.resolve(), + setWidgetVisible: () => undefined, + activateWidget: () => undefined, }), [description], ); diff --git a/apps/nextjs/src/components/assistant/assistant-human-tools.tsx b/apps/nextjs/src/components/assistant/assistant-human-tools.tsx index 4f0075398..81f33373f 100644 --- a/apps/nextjs/src/components/assistant/assistant-human-tools.tsx +++ b/apps/nextjs/src/components/assistant/assistant-human-tools.tsx @@ -29,7 +29,7 @@ import { AppForm } from "@homarr/forms-collection"; import { useScopedI18n } from "@homarr/translation/client"; import type { appManageSchema } from "@homarr/validation/app"; -import { getAssistantAffirmativeResult, getAssistantAskUserOptionKind } from "./assistant-ask-user"; +import { getAssistantAskUserOptionKind } from "./assistant-ask-user"; import { useAssistantAutomaticAction } from "./assistant-auto-approval"; import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress"; import classes from "./assistant-panel.module.css"; @@ -67,7 +67,6 @@ export const AssistantAskUserTool = ({ result, addResult, status, - toolCallId, }: ToolCallMessagePartProps) => { const t = useScopedI18n("common.assistant.askUser"); const [showOther, setShowOther] = useState(false); @@ -75,17 +74,6 @@ export const AssistantAskUserTool = ({ const [submitting, setSubmitting] = useState(false); const otherInputRef = useRef(null); const options = Array.isArray(args?.options) ? args.options : []; - const affirmativeResult = getAssistantAffirmativeResult(options); - const hasValidQuestion = hasCompleteAssistantToolArguments(status) && Boolean(args?.question) && options.length >= 2; - const autoConfirming = useAssistantAutomaticAction({ - toolCallId, - ready: result === undefined && hasValidQuestion && affirmativeResult !== undefined, - completed: result !== undefined, - confirm: () => { - if (!affirmativeResult) return; - addResult(affirmativeResult); - }, - }); useEffect(() => { if (showOther) otherInputRef.current?.focus(); @@ -165,67 +153,62 @@ export const AssistantAskUserTool = ({ - {autoConfirming ? ( - - ) : ( - options.map((option, index) => { - const kind = getAssistantAskUserOptionKind(option); - const icon = - kind === "affirmative" ? ( - - ) : kind === "negative" ? ( - - ) : ( - - ); - const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray"; - - return ( - - submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" }) - } - > - - {icon} - - - - {option.label} - - {option.description && ( - - {option.description} - - )} - - - {t(`optionKind.${kind}`)} - - + {options.map((option, index) => { + const kind = getAssistantAskUserOptionKind(option); + const icon = + kind === "affirmative" ? ( + + ) : kind === "negative" ? ( + + ) : ( + ); - }) - )} + const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray"; - {!autoConfirming && - args.allowOther !== false && + return ( + + submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" }) + } + > + + {icon} + + + + {option.label} + + {option.description && ( + + {option.description} + + )} + + + {t(`optionKind.${kind}`)} + + + ); + })} + + {args.allowOther !== false && (showOther ? ( :last-child { + .composerContextLabel { + display: none; + } + + .panelThreadTitle { display: none; } } @@ -298,17 +353,70 @@ } .historyMenu { + min-width: 0; + overflow: hidden; background: var(--mantine-color-body); } +.historyMenu :global(.mantine-ScrollArea-content) { + display: block; + width: 100%; + min-width: 0; +} + .historyItem { + display: flex; + width: 100%; + min-width: 0; + max-width: 100%; + box-sizing: border-box; + align-items: center; + gap: 0.2rem; + overflow: hidden; + padding: 0.15rem 0.2rem 0.15rem 0.35rem; border-radius: var(--mantine-radius-md); } -.historyItem:hover { +.historyItem:hover, +.historyItem[data-active] { background: var(--mantine-color-default-hover); } +.historyItemTrigger { + min-width: 0; + flex: 1; + overflow: hidden; + padding: 0.35rem 0.25rem; + text-align: start; +} + +.historyItemTrigger > span { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.historyRenameInput { + min-width: 0; + flex: 1; +} + +.historyItemTrigger:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: -1px; +} + +.historyItemMenu { + flex: 0 0 auto; + opacity: 1; +} + +.historyItemActions { + flex: 0 0 auto; +} + .thread { position: relative; display: flex; @@ -339,6 +447,8 @@ .message { margin-block: 0.9rem; + contain-intrinsic-size: auto 12rem; + content-visibility: auto; } .userMessageWrap { @@ -706,24 +816,21 @@ .pendingQuestionDock { min-height: 0; - flex: 1 1 auto; + max-height: min(22rem, 48dvh); + flex: 0 1 auto; overflow-y: auto; overscroll-behavior: contain; padding: 0.75rem; + border-top: 1px solid var(--mantine-color-default-border); background: var(--mantine-color-body); } -.thread[data-pending-question] .viewport { - display: none; -} - .pendingQuestionTarget { display: flex; width: 100%; - min-height: 100%; max-width: 44rem; margin-inline: auto; - align-items: safe center; + align-items: flex-start; } .pendingQuestionTarget .humanTool { @@ -1091,6 +1198,38 @@ flex: 0 1 auto; } +.messageMoreMenu { + z-index: 410; + min-width: 11rem; + padding: 0.25rem; + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-md); + color: var(--mantine-color-text); + background: var(--mantine-color-body); + box-shadow: var(--mantine-shadow-md); +} + +.messageMoreItem { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.55rem; + border-radius: var(--mantine-radius-sm); + font-size: var(--mantine-font-size-sm); + cursor: pointer; + outline: 0; +} + +.messageMoreItem[data-highlighted] { + background: var(--mantine-color-default-hover); +} + +.messageMoreSeparator { + height: 1px; + margin-block: 0.2rem; + background: var(--mantine-color-default-border); +} + .userActions { min-height: 1.75rem; } @@ -1173,13 +1312,13 @@ .composerContext { display: flex; - width: 1.75rem; + width: auto; height: 1.75rem; align-items: center; justify-content: center; - padding: 0; + padding-inline: 0.3rem 0.42rem; border: 1px solid var(--mantine-color-default-border); - border-radius: 50%; + border-radius: var(--mantine-radius-sm); color: var(--mantine-color-text); background: var(--mantine-color-body); } @@ -1641,12 +1780,12 @@ @keyframes assistant-panel-in { from { opacity: 0; - transform: translate(-50%, 0.75rem) scale(0.985); + transform: translateY(0.75rem) scale(0.985); } to { opacity: 1; - transform: translate(-50%, 0) scale(1); + transform: translateY(0) scale(1); } } @@ -1656,10 +1795,11 @@ } .floatingPanel { - bottom: max(0.5rem, env(safe-area-inset-bottom, 0px)); - width: calc(100vw - 1rem); - height: min(44rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px))); - min-height: min(20rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px))); + inset: 0; + width: 100vw; + height: 100dvh; + min-height: 100dvh; + border-radius: 0; } .panelHeader { @@ -1705,6 +1845,10 @@ } @media (max-width: 30em) { + .historyItemMenu { + opacity: 1; + } + .historyButton { width: 2.625rem; max-width: 2.625rem; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 9ee5cfafa..a9f0f02eb 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -25,6 +25,8 @@ import type { import type { MessageStatus } from "@assistant-ui/react"; import { ActionBarPrimitive, + ActionBarMorePrimitive, + AuiIf, AttachmentPrimitive, BranchPickerPrimitive, ChainOfThoughtByIndicesProvider, @@ -66,6 +68,7 @@ import { SegmentedControl, Stack, Text, + TextInput, ThemeIcon, Tooltip, UnstyledButton, @@ -91,10 +94,12 @@ import { IconChevronUp, IconCopy, IconDownload, + IconDotsVertical, IconFile, IconFileExport, IconLink, IconMessage, + IconMicrophone, IconMinus, IconPaperclip, IconPalette, @@ -112,6 +117,8 @@ import { IconThumbUp, IconTool, IconTrash, + IconVolume, + IconVolumeOff, IconX, } from "@tabler/icons-react"; import remarkBreaks from "remark-breaks"; @@ -124,6 +131,7 @@ import { useTimeAgo } from "@homarr/common"; import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions"; import type { AssistantProvider } from "@homarr/definitions"; import { showErrorNotification, showSuccessNotification } from "@homarr/notifications"; +import { useConfirmModal } from "@homarr/modals"; import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import classes from "./assistant-panel.module.css"; @@ -184,6 +192,7 @@ interface AssistantPanelProps extends AssistantConversationControls { onClose: () => void; onDismissActivity: () => void; activityDismissed: boolean; + hasVisibleWidget: boolean; isRunning: boolean; unreadCount: number; latestAssistantText: string; @@ -889,6 +898,7 @@ const ToolPart = ({ toolCallId, ready: awaitingApproval, completed: !awaitingApproval, + mode: "approval", confirm: () => { respondToApproval({ approved: true, reason: "Approved automatically by the user." }); }, @@ -1107,63 +1117,43 @@ const downloadAssistantMarkdown = (markdown: string, filename: string) => { setTimeout(() => URL.revokeObjectURL(blobUrl), 0); }; -const AssistantMessageExportAction = () => { - const t = useScopedI18n("common.assistant"); - const message = useAuiState((state) => state.message); - - return ( - - { - const markdown = buildAssistantMessageMarkdown({ - id: message.id, - parentId: message.parentId, - format: "assistant-ui/runtime", - content: message, - createdAt: message.createdAt, - }); - downloadAssistantMarkdown(markdown, `assistant-message-${message.id.slice(0, 8)}.md`); - }} - > - - - - ); -}; - const AssistantMessageActions = () => { const t = useScopedI18n("common.assistant"); + const aui = useAui(); + const exportMessage = () => { + const message = aui.message().getState(); + const markdown = buildAssistantMessageMarkdown({ + id: message.id, + parentId: message.parentId, + format: "assistant-ui/runtime", + content: message, + createdAt: message.createdAt, + }); + downloadAssistantMarkdown(markdown, `assistant-message-${message.id.slice(0, 8)}.md`); + }; + return ( - - + + - + !state.message.isCopied}> + + + state.message.isCopied}> + + - - - - - - - - - - - - - - - @@ -1171,6 +1161,50 @@ const AssistantMessageActions = () => { + + + + + + + + + + + {t("helpful")} + + + + + + {t("notHelpful")} + + + state.thread.capabilities.speech && state.message.speech === undefined}> + + + + {t("readAloud")} + + + + state.message.speech !== undefined}> + + + + {t("stopReading")} + + + + + exportMessage()} asChild> + + + {t("exportMarkdown")} + + + + @@ -1181,7 +1215,7 @@ const AssistantMessageActions = () => { const UserMessageActions = () => { const t = useScopedI18n("common.assistant"); return ( - + @@ -1732,12 +1766,17 @@ const ConversationContext = () => { aria-haspopup="dialog" onClick={() => setOpened((value) => !value)} > - + + + + {hasContext ? `${contextPercentage}%` : t("mentions.context")} + + @@ -1948,14 +1987,21 @@ const AssistantMessage = () => ( ); +const assistantThreadMessageComponents = { UserMessage, AssistantMessage }; + const HistorySelectContext = createContext<() => void>(() => undefined); const ThreadListItem = () => { const t = useScopedI18n("common.assistant"); + const aui = useAui(); + const { openConfirmModal } = useConfirmModal(); const onSelect = useContext(HistorySelectContext); const remoteId = useAuiState((state) => state.threadListItem.remoteId); const title = useAuiState((state) => state.threadListItem.title); const [exporting, setExporting] = useState(false); + const [actionsOpened, setActionsOpened] = useState(false); + const [renameOpened, setRenameOpened] = useState(false); + const [nextTitle, setNextTitle] = useState(title ?? ""); const exportConversation = async () => { if (!remoteId || exporting) return; @@ -1977,23 +2023,87 @@ const ThreadListItem = () => { } }; + const renameConversation = () => { + const trimmedTitle = nextTitle.trim(); + if (!trimmedTitle) return; + aui.threadListItem().rename(trimmedTitle); + setRenameOpened(false); + setActionsOpened(false); + }; + + const deleteConversation = () => { + openConfirmModal({ + title: t("deleteConversation.title"), + children: t("deleteConversation.description", { title: title ?? t("newConversation") }), + confirmProps: { color: "red" }, + labels: { confirm: t("delete"), cancel: t("cancel") }, + onConfirm: () => aui.threadListItem().delete(), + }); + }; + + if (renameOpened) { + return ( + + setNextTitle(event.currentTarget.value)} + aria-label={t("renameConversation.label")} + maxLength={72} + size="xs" + data-autofocus + onKeyDown={(event) => { + if (event.key === "Enter") renameConversation(); + if (event.key === "Escape") setRenameOpened(false); + }} + /> + + + + setRenameOpened(false)} + > + + + + ); + } + return ( - - - - - + + { loading={exporting} loaderProps={{ type: "bars" }} disabled={!remoteId} + title={t("exportConversation.action")} aria-label={t("exportConversation.action")} onClick={() => void exportConversation()} > - - - + - - + + )} + setActionsOpened((current) => !current)} + > + {actionsOpened ? : } + ); }; const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => { const t = useScopedI18n("common.assistant"); + const [query, setQuery] = useState(""); + const normalizedQuery = query.trim().toLocaleLowerCase(); + const threadItems = useAuiState((state) => state.threads.threadItems); + const hasMatchingConversation = threadItems.some((item) => { + const itemTitle = item.title ?? t("newConversation"); + return itemTitle.toLocaleLowerCase().includes(normalizedQuery); + }); return ( @@ -2035,9 +2170,28 @@ const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => { {t("conversations")} - + setQuery(event.currentTarget.value)} + leftSection={} + placeholder={t("searchConversations")} + aria-label={t("searchConversations")} + size="xs" + /> + - + + {({ threadListItem }) => { + const itemTitle = threadListItem.title ?? t("newConversation"); + if (normalizedQuery && !itemTitle.toLocaleLowerCase().includes(normalizedQuery)) return null; + return ; + }} + + {normalizedQuery && !hasMatchingConversation && ( + + {t("noMatchingConversations")} + + )} @@ -2052,7 +2206,6 @@ const ConversationHistory = () => { setOpened(false)} position="bottom-end" width="min(22rem, calc(100vw - 1rem))" shadow="md" @@ -2191,7 +2344,7 @@ const EmptyThread = () => { - + - + - + - + @@ -2749,6 +2902,26 @@ const Composer = (props: ComposerProps) => { + state.thread.capabilities.dictation && state.composer.dictation === undefined} + > + + + + + + + + + state.composer.dictation !== undefined}> + + + + + + + + { - - - {t("mentions.hint")} - - - {t("commands.hint")} - - @@ -2979,9 +3144,30 @@ interface AssistantConversationSurfaceProps extends AssistantConversationControl pendingAction: AssistantPendingAction | undefined; onExpand?: () => void; onMinimize?: () => void; - onWindowClose?: () => void; + onDismiss?: () => void; } +const AssistantPanelIdentity = () => { + const t = useScopedI18n("common.assistant"); + const threadTitle = useAuiState((state) => state.threadListItem.title); + + return ( + + + + + + + {t("title")} + + + {threadTitle ?? t("newConversation")} + + + + ); +}; + export const AssistantConversationSurface = ({ isRunning, pendingAction, @@ -2996,7 +3182,7 @@ export const AssistantConversationSurface = ({ onReasoningChange, onExpand, onMinimize, - onWindowClose, + onDismiss, }: AssistantConversationSurfaceProps) => { const t = useScopedI18n("common.assistant"); const reducedMotion = useReducedMotion(); @@ -3011,52 +3197,23 @@ export const AssistantConversationSurface = ({ return ( - {onWindowClose && onMinimize && ( - - - - - - - - - - - - - )} + - } aria-label={t("newConversation")} > - - + {t("newConversation")} + {onExpand && ( @@ -3072,6 +3229,32 @@ export const AssistantConversationSurface = ({ )} + {onMinimize && ( + + + + + + )} + {onDismiss && ( + + + + + + )} @@ -3083,7 +3266,7 @@ export const AssistantConversationSurface = ({ - + @@ -3130,6 +3313,7 @@ export const AssistantPanel = ({ onClose, onDismissActivity, activityDismissed, + hasVisibleWidget, isRunning, unreadCount, latestAssistantText, @@ -3182,7 +3366,7 @@ export const AssistantPanel = ({ return ( <> - {!opened && ( + {!opened && !hasVisibleWidget && ( { + onDismiss={() => { onDismissActivity(); onClose(); }} diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index 43144bb31..dd61b214a 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -5,13 +5,23 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import type { AttachmentAdapter, + DictationAdapter, MessageFormatAdapter, RemoteThreadListAdapter, + SpeechSynthesisAdapter, ThreadHistoryAdapter, ThreadMessage, Toolkit, } from "@assistant-ui/react"; -import { defineToolkit, useAui, useAuiEvent, useAuiState, useRemoteThreadListRuntime } from "@assistant-ui/react"; +import { + defineToolkit, + useAui, + useAuiEvent, + useAuiState, + useRemoteThreadListRuntime, + WebSpeechDictationAdapter, + WebSpeechSynthesisAdapter, +} from "@assistant-ui/react"; import { useChat } from "@ai-sdk/react"; import { AssistantChatTransport, useAISDKRuntime } from "@assistant-ui/react-ai-sdk"; import { useHotkeys } from "@mantine/hooks"; @@ -41,7 +51,8 @@ import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assi import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences"; import { assistantAiSdkRuntimeOptions } from "./assistant-runtime-options"; import { - AssistantComposerSurfaceProvider, + AssistantComposerRuntimeProvider, + AssistantComposerSurfaceBoundary, AssistantRunFocusPreserver, AssistantRuntimeProviderWithTools, } from "./assistant-runtime-provider"; @@ -475,6 +486,14 @@ const AssistantThreadRuntime = () => { ), [selectedModel], ); + const [dictation, setDictation] = useState(); + const [speech, setSpeech] = useState(); + useEffect(() => { + if (WebSpeechDictationAdapter.isSupported()) setDictation(new WebSpeechDictationAdapter()); + if ("speechSynthesis" in window && typeof SpeechSynthesisUtterance !== "undefined") { + setSpeech(new WebSpeechSynthesisAdapter()); + } + }, []); const feedback = useMemo( () => ({ submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => { @@ -511,7 +530,9 @@ const AssistantThreadRuntime = () => { adapters: { history, attachments, + dictation, feedback, + speech, }, }); transport.setRuntime(runtime); @@ -667,6 +688,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { const [opened, setOpened] = useState(false); const [activityDismissed, setActivityDismissed] = useState(true); const [unreadCount, setUnreadCount] = useState(0); + const [visibleWidgetIds, setVisibleWidgetIds] = useState>(() => new Set()); const [queuedPrompt, setQueuedPrompt] = useState(null); const aui = useAui(); const preferences = useAssistantPreferences(); @@ -714,6 +736,21 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { setOpened(true); }, [markRead]); const close = useCallback(() => setOpened(false), []); + const setWidgetVisible = useCallback((widgetId: string, visible: boolean) => { + setVisibleWidgetIds((current) => { + if (current.has(widgetId) === visible) return current; + const next = new Set(current); + if (visible) next.add(widgetId); + else next.delete(widgetId); + return next; + }); + }, []); + const activateWidget = useCallback((widgetId: string) => { + setVisibleWidgetIds((current) => { + if (!current.has(widgetId) || current.values().next().value === widgetId) return current; + return new Set([widgetId, ...Array.from(current).filter((id) => id !== widgetId)]); + }); + }, []); const toggle = useCallback(() => { if (opened) close(); else open(); @@ -835,6 +872,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { }, [assistantIsRunning, latestStatus?.type, providerUnavailable, sendPrompt, t]); useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]); + const activeWidgetId = visibleWidgetIds.values().next().value ?? null; const value = useMemo( () => ({ enabled: true, @@ -843,46 +881,67 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => { isRunning: assistantIsRunning, isRefreshing, unreadCount, + hasVisibleWidget: visibleWidgetIds.size > 0, + activeWidgetId, open, close, toggle, sendPrompt, refreshCurrentView, + setWidgetVisible, + activateWidget, }), - [assistantIsRunning, close, isRefreshing, open, opened, refreshCurrentView, sendPrompt, toggle, unreadCount], + [ + assistantIsRunning, + activateWidget, + activeWidgetId, + close, + isRefreshing, + open, + opened, + refreshCurrentView, + sendPrompt, + setWidgetVisible, + toggle, + unreadCount, + visibleWidgetIds.size, + ], ); return ( - {children} - - { - markRead(); - setActivityDismissed(true); - }} - activityDismissed={activityDismissed} - isRunning={assistantIsRunning} - unreadCount={unreadCount} - latestAssistantText={latestAssistantText} - latestAssistantPartType={latestAssistantPartType} - latestUserText={queuedPrompt ?? latestUserText} - latestStatus={latestStatus} - pendingAction={pendingAction} - modelId={preferences.modelId} - models={preferences.models} - modelOptionsLoading={preferences.isLoading} - reasoning={preferences.reasoning} - isRefreshing={isRefreshing} - onRefresh={refreshCurrentView} - onModelChange={selectModel} - onReasoningChange={preferences.setReasoning} - /> - + + {children} + + { + markRead(); + setActivityDismissed(true); + }} + activityDismissed={activityDismissed} + hasVisibleWidget={visibleWidgetIds.size > 0} + isRunning={assistantIsRunning} + unreadCount={unreadCount} + latestAssistantText={latestAssistantText} + latestAssistantPartType={latestAssistantPartType} + latestUserText={queuedPrompt ?? latestUserText} + latestStatus={latestStatus} + pendingAction={pendingAction} + modelId={preferences.modelId} + models={preferences.models} + modelOptionsLoading={preferences.isLoading} + reasoning={preferences.reasoning} + isRefreshing={isRefreshing} + onRefresh={refreshCurrentView} + onModelChange={selectModel} + onReasoningChange={preferences.setReasoning} + /> + + ); diff --git a/apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx b/apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx index e849c2393..807fab16c 100644 --- a/apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-runtime-provider.tsx @@ -437,21 +437,23 @@ const AssistantComposerSurfaceEvents = () => { return null; }; -const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => { +export const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => { const t = useScopedI18n("common.assistant"); const aui = useAui(); + const instanceId = useId(); + const resolvedSurfaceId = `${surfaceId}:${instanceId}`; const elementRef = useRef(null); - const contextValue = useMemo(() => ({ id: surfaceId }), [surfaceId]); + const contextValue = useMemo(() => ({ id: resolvedSurfaceId }), [resolvedSurfaceId]); useLayoutEffect(() => { const element = elementRef.current; if (!element) return undefined; - surfaceElements.set(surfaceId, element); + surfaceElements.set(resolvedSurfaceId, element); return () => { - if (surfaceElements.get(surfaceId) === element) surfaceElements.delete(surfaceId); + if (surfaceElements.get(resolvedSurfaceId) === element) surfaceElements.delete(resolvedSurfaceId); }; - }, [surfaceId]); + }, [resolvedSurfaceId]); const handlePasteCapture = (event: ClipboardEvent) => { const target = event.target; @@ -571,13 +573,11 @@ interface AssistantComposerSurfaceProviderProps extends PropsWithChildren { } /** - * Gives one rendered assistant surface a local composer while preserving the shared conversation. - * Every component below this boundary can keep using the normal assistant-ui primitives and hooks. + * Gives every rendered assistant surface one shared local composer while preserving the shared + * conversation. Individual surfaces should add AssistantComposerSurfaceBoundary around their UI. */ -export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: AssistantComposerSurfaceProviderProps) => { +export const AssistantComposerRuntimeProvider = ({ children }: PropsWithChildren) => { const parentAui = useAui(); - const instanceId = useId(); - const resolvedSurfaceId = `${surfaceId}:${instanceId}`; // oxlint-disable-next-line no-underscore-dangle -- required to derive a scoped runtime without cloning the shared thread. const sharedRuntime = parentAui.threads().__internal_getAssistantRuntime?.(); const surfaceRuntime = useMemo(() => { @@ -588,7 +588,17 @@ export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: Assist return ( - {children} + {children} ); }; + +/** + * Convenience boundary for a standalone surface. Use AssistantComposerRuntimeProvider with + * localized boundaries when multiple surfaces need to share one draft and attachment queue. + */ +export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: AssistantComposerSurfaceProviderProps) => ( + + {children} + +); diff --git a/apps/nextjs/src/components/assistant/assistant-widget.tsx b/apps/nextjs/src/components/assistant/assistant-widget.tsx index abe9e1310..1b7acee3e 100644 --- a/apps/nextjs/src/components/assistant/assistant-widget.tsx +++ b/apps/nextjs/src/components/assistant/assistant-widget.tsx @@ -1,8 +1,9 @@ "use client"; import type { CSSProperties, ReactNode } from "react"; +import { useEffect, useId, useRef } from "react"; import { ActionIcon, Box, Center, Group, Stack, Text, ThemeIcon, Tooltip } from "@mantine/core"; -import { IconArrowsMaximize, IconRobot } from "@tabler/icons-react"; +import { IconArrowsMaximize, IconArrowsMinimize, IconRobot } from "@tabler/icons-react"; import { useAui, useAuiState } from "@assistant-ui/react"; import { useI18n } from "@homarr/translation/client"; @@ -13,23 +14,47 @@ import classes from "./assistant-panel.module.css"; import { AssistantConversationSurface } from "./assistant-panel"; import { getPendingAssistantAction } from "./assistant-pending-action"; import { useAssistantPreferences, useHomarrAssistant } from "./assistant-context"; -import { AssistantComposerSurfaceProvider } from "./assistant-runtime-provider"; +import { AssistantComposerSurfaceBoundary } from "./assistant-runtime-provider"; export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => { const assistant = useHomarrAssistant(); + const { enabled, setWidgetVisible } = assistant; + const generatedWidgetId = useId(); + const widgetId = props.itemId ?? generatedWidgetId; + const widgetRef = useRef(null); + + useEffect(() => { + const widget = widgetRef.current; + if (!enabled || !widget) return; + const observer = new IntersectionObserver(([entry]) => setWidgetVisible(widgetId, entry?.isIntersecting === true), { + threshold: 0.15, + }); + observer.observe(widget); + return () => { + observer.disconnect(); + setWidgetVisible(widgetId, false); + }; + }, [enabled, setWidgetVisible, widgetId]); if (!assistant.enabled) { return ; } return ( - - - + + + + + ); }; -const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetComponentProps<"assistant">) => { +const EnabledAssistantBoardWidget = ({ + width, + height, + isEditMode, + assistantWidgetId, +}: WidgetComponentProps<"assistant"> & { assistantWidgetId: string }) => { const board = useRequiredBoard(); const assistant = useHomarrAssistant(); const preferences = useAssistantPreferences(); @@ -45,6 +70,14 @@ const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetCompon threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId }); }; + if (assistant.opened) { + return ; + } + + if (assistant.activeWidgetId !== null && assistant.activeWidgetId !== assistantWidgetId) { + return assistant.activateWidget(assistantWidgetId)} location="widget" />; + } + if (width < 300 || height < 280) { return ; } @@ -75,8 +108,30 @@ const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetCompon ); }; -const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => { +const CompactAssistantWidget = ({ + onOpen, + location = "compact", +}: { + onOpen: () => void; + location?: "compact" | "panel" | "widget"; +}) => { const t = useI18n(); + let title = t("widget.assistant.compact.title"); + let description = t("widget.assistant.compact.description"); + let actionLabel = t("widget.assistant.compact.open"); + let actionIcon = ; + + if (location === "panel") { + title = t("widget.assistant.compact.openTitle"); + description = t("widget.assistant.compact.openDescription"); + actionLabel = t("widget.assistant.compact.returnToWidget"); + actionIcon = ; + } + if (location === "widget") { + title = t("widget.assistant.compact.otherWidgetTitle"); + description = t("widget.assistant.compact.otherWidgetDescription"); + actionLabel = t("widget.assistant.compact.useHere"); + } return ( @@ -87,23 +142,16 @@ const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => { - {t("widget.assistant.compact.title")} + {title} - {t("widget.assistant.compact.description")} + {description} - - - + + + {actionIcon} diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index cff0aa9b6..f665574b8 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2327,6 +2327,21 @@ "newConversation": "New conversation", "conversations": "Conversations", "delete": "Delete", + "cancel": "Cancel", + "conversationActions": "Conversation actions", + "searchConversations": "Search conversations", + "noMatchingConversations": "No matching conversations", + "moreActions": "More response actions", + "renameConversation": { + "action": "Rename", + "title": "Rename conversation", + "label": "Conversation name", + "save": "Save name" + }, + "deleteConversation": { + "title": "Delete conversation?", + "description": "“{title}” and its messages will be permanently deleted." + }, "closeHistory": "Close conversation history", "openHistory": "Open conversation history", "emptyTitle": "What can I help with?", @@ -2399,10 +2414,15 @@ "regenerate": "Regenerate response", "helpful": "Helpful", "notHelpful": "Not helpful", + "readAloud": "Read response aloud", + "stopReading": "Stop reading response", + "startVoiceInput": "Start voice input", + "stopVoiceInput": "Stop voice input", "feedbackError": "Your feedback could not be saved. Try again.", "exportMarkdown": "Export as Markdown", "exportConversation": { "action": "Export conversation as Markdown", + "working": "Exporting conversation…", "failedTitle": "Conversation could not be exported", "failedDescription": "Homarr could not load this conversation. Try again." }, @@ -2630,7 +2650,7 @@ "autoApproval": { "label": "Automatic approvals", "title": "Automatic approvals", - "description": "Automatically approve protected changes, use presented form details, and choose answers categorized as affirmative. Neutral selections still wait for you. Your Homarr permissions still apply. This turns off when you switch conversations.", + "description": "Automatically prepare forms and approve protected changes for this conversation. Questions still wait for your answer, and your Homarr permissions still apply. This turns off when you switch conversations.", "enable": "Enable for this conversation", "disable": "Turn off automatic approvals", "enabled": "Automatic approvals are on", @@ -5744,7 +5764,13 @@ "compact": { "title": "Assistant", "description": "Resize this widget to chat here, or open the full conversation.", - "open": "Open assistant" + "open": "Open assistant", + "openTitle": "Assistant open in side panel", + "openDescription": "The conversation stays in one active surface.", + "returnToWidget": "Return the conversation to this widget", + "otherWidgetTitle": "Assistant active in another widget", + "otherWidgetDescription": "Only one conversation surface stays active at a time.", + "useHere": "Use the assistant in this widget" } } },