Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e328f3e9a1 | ||
|
|
d241bac55d | ||
|
|
58a24569ce | ||
|
|
53b518960c | ||
|
|
ddf8ba9948 |
No files matched your search
@@ -44,12 +44,15 @@ host gateway address.
|
||||
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.
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
tags:
|
||||
- Administration
|
||||
- Logs
|
||||
- Troubleshooting
|
||||
---
|
||||
|
||||
# Logs
|
||||
|
||||
The logs page shows recent Homarr application logs and continues updating as new events occur. Use it to investigate
|
||||
widget, integration, and server errors without opening the container logs separately.
|
||||
|
||||
## Opening the logs page
|
||||
|
||||
Navigate to **Management** > **Tools** > **Logs**. The page and widget log links are only available to users with the
|
||||
**View logs** permission.
|
||||
|
||||
When a widget reports an error, **Check logs for more details** opens the logs page in a new tab and marks the time when
|
||||
the widget displayed the error. The related server error normally appears immediately above that marker. If those logs
|
||||
have already been removed from the retained history, the marker explains that they are no longer available.
|
||||
|
||||
## Retained and live logs
|
||||
|
||||
Homarr retains the latest 5,000 log events in Redis. Opening the page first loads this history and then continues with
|
||||
live events without a gap. The history is bounded, so older events are removed automatically.
|
||||
|
||||
The history survives normal application restarts when the configured Redis instance persists its data. For complete
|
||||
platform-level history, startup failures, or events older than the retained limit, use the logs provided by Docker or
|
||||
your deployment platform.
|
||||
|
||||
## Display controls
|
||||
|
||||
The severity selector applies to both retained and live events. Selecting a different level redraws the existing view
|
||||
using that threshold. The font-size control changes the terminal text size and is remembered in the browser.
|
||||
@@ -13,7 +13,7 @@ tags:
|
||||
The settings page applies options to the whole Homarr application.
|
||||
Some changes need a full page reload before they take effect.
|
||||
|
||||
All changes are collected in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
|
||||
All settings sections remain visible in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
|
||||
|
||||
## Analytics
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) =>
|
||||
}))}
|
||||
{...form.getInputProps("defaultColorScheme")}
|
||||
SelectOption={AppearanceCustomOption}
|
||||
withinPortal
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Switch, Text } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -12,11 +12,11 @@ import type { FormValues } from "./settings-form";
|
||||
|
||||
interface BoardSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
selectableBoards: RouterOutputs["board"]["getPublicBoards"];
|
||||
}
|
||||
|
||||
export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
|
||||
export const BoardSettingsForm = ({ form, selectableBoards }: BoardSettingsFormProps) => {
|
||||
const tBoard = useScopedI18n("management.page.settings.section.board");
|
||||
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
|
||||
|
||||
return (
|
||||
<SectionCard title={tBoard("title")}>
|
||||
@@ -26,6 +26,7 @@ export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("homeBoardId")}
|
||||
withinPortal
|
||||
/>
|
||||
|
||||
<BoardSelect
|
||||
@@ -34,6 +35,7 @@ export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
|
||||
clearable
|
||||
boards={selectableBoards}
|
||||
{...form.getInputProps("mobileHomeBoardId")}
|
||||
withinPortal
|
||||
/>
|
||||
|
||||
<Text fw={500}>{tBoard("status.title")}</Text>
|
||||
|
||||
@@ -22,6 +22,7 @@ export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => {
|
||||
label={tCulture("defaultLocale.label")}
|
||||
{...localeInputProps}
|
||||
value={localeInputProps.value as SupportedLanguage}
|
||||
withinPortal
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Select } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import type { UseFormReturnType } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -11,11 +11,11 @@ import type { FormValues } from "./settings-form";
|
||||
|
||||
interface SearchSettingsFormProps {
|
||||
form: UseFormReturnType<FormValues>;
|
||||
selectableSearchEngines: RouterOutputs["searchEngine"]["getSelectable"];
|
||||
}
|
||||
|
||||
export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => {
|
||||
export const SearchSettingsForm = ({ form, selectableSearchEngines }: SearchSettingsFormProps) => {
|
||||
const tSearch = useScopedI18n("management.page.settings.section.search");
|
||||
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
|
||||
|
||||
return (
|
||||
<SectionCard title={tSearch("title")}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { Button, Stack } from "@mantine/core";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import { env } from "@homarr/common/env";
|
||||
@@ -58,9 +59,11 @@ const buildInitialValues = (initialData: ServerSettings): FormValues => ({
|
||||
|
||||
interface SettingsFormProps {
|
||||
initialData: ServerSettings;
|
||||
selectableBoards: RouterOutputs["board"]["getPublicBoards"];
|
||||
selectableSearchEngines: RouterOutputs["searchEngine"]["getSelectable"];
|
||||
}
|
||||
|
||||
export const SettingsForm = ({ initialData }: SettingsFormProps) => {
|
||||
export const SettingsForm = ({ initialData, selectableBoards, selectableSearchEngines }: SettingsFormProps) => {
|
||||
const t = useI18n();
|
||||
const tSettings = useScopedI18n("management.page.settings");
|
||||
|
||||
@@ -169,13 +172,13 @@ export const SettingsForm = ({ initialData }: SettingsFormProps) => {
|
||||
return (
|
||||
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
|
||||
<Stack gap="xl">
|
||||
<AnalyticsSettings form={form} />
|
||||
<CrawlingAndIndexingSettings form={form} />
|
||||
<BoardSettingsForm form={form} />
|
||||
<BoardSettingsForm form={form} selectableBoards={selectableBoards} />
|
||||
<UserSettingsForm form={form} />
|
||||
<SearchSettingsForm form={form} />
|
||||
<SearchSettingsForm form={form} selectableSearchEngines={selectableSearchEngines} />
|
||||
<AppearanceSettingsForm form={form} />
|
||||
<CultureSettingsForm form={form} />
|
||||
<AnalyticsSettings form={form} />
|
||||
<CrawlingAndIndexingSettings form={form} />
|
||||
|
||||
{form.isDirty() && (
|
||||
<UnsavedChangesBar>
|
||||
|
||||
@@ -23,12 +23,20 @@ export default async function SettingsPage() {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const serverSettings = await api.serverSettings.getAll();
|
||||
const tSettings = await getScopedI18n("management.page.settings");
|
||||
const [serverSettings, selectableBoards, selectableSearchEngines, tSettings] = await Promise.all([
|
||||
api.serverSettings.getAll(),
|
||||
api.board.getPublicBoards(),
|
||||
api.searchEngine.getSelectable({ withIntegrations: false }),
|
||||
getScopedI18n("management.page.settings"),
|
||||
]);
|
||||
|
||||
return (
|
||||
<ManagePageLayout title={tSettings("title")}>
|
||||
<SettingsForm initialData={serverSettings} />
|
||||
<SettingsForm
|
||||
initialData={serverSettings}
|
||||
selectableBoards={selectableBoards}
|
||||
selectableSearchEngines={selectableSearchEngines}
|
||||
/>
|
||||
</ManagePageLayout>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const ClientSideTerminalComponent = dynamic(
|
||||
interface ClientSideTerminalComponentProps {
|
||||
focusTimestamp?: number;
|
||||
}
|
||||
|
||||
export const ClientSideTerminalComponent = dynamic<ClientSideTerminalComponentProps>(
|
||||
() => import("./terminal").then(({ TerminalComponent }) => TerminalComponent),
|
||||
{
|
||||
ssr: false,
|
||||
|
||||
@@ -29,12 +29,25 @@ export async function generateMetadata() {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function LogsManagementPage() {
|
||||
interface LogsManagementPageProps {
|
||||
searchParams: Promise<{ focus?: string | string[] }>;
|
||||
}
|
||||
|
||||
export default async function LogsManagementPage({ searchParams }: LogsManagementPageProps) {
|
||||
const session = await auth();
|
||||
if (!session?.user.permissions.includes("other-view-logs")) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const focus = (await searchParams).focus;
|
||||
let focusTimestamp: number | undefined;
|
||||
if (typeof focus === "string") {
|
||||
const parsedFocusTimestamp = Number(focus);
|
||||
if (Number.isSafeInteger(parsedFocusTimestamp) && parsedFocusTimestamp > 0) {
|
||||
focusTimestamp = parsedFocusTimestamp;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<LogContextProvider defaultLevel={logsEnv.LEVEL}>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
@@ -45,7 +58,7 @@ export default async function LogsManagementPage() {
|
||||
</Group>
|
||||
</Group>
|
||||
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
|
||||
<ClientSideTerminalComponent />
|
||||
<ClientSideTerminalComponent focusTimestamp={focusTimestamp} />
|
||||
</Box>
|
||||
</LogContextProvider>
|
||||
);
|
||||
|
||||
@@ -7,25 +7,163 @@ import { FitAddon } from "@xterm/addon-fit";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { LoggerMessage, LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
||||
import { LOG_HISTORY_MAX_ENTRIES, logLevels } from "@homarr/core/infrastructure/logs/constants";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { useLogContext } from "./log-context";
|
||||
import classes from "./terminal.module.css";
|
||||
|
||||
export const TerminalComponent = () => {
|
||||
const ALL_LOG_LEVELS = [...logLevels];
|
||||
const TERMINAL_SCROLLBACK_LINES = LOG_HISTORY_MAX_ENTRIES * 20;
|
||||
|
||||
const getFocusMarker = (
|
||||
messages: LoggerMessage[],
|
||||
visibleMessages: LoggerMessage[],
|
||||
focusTimestamp: number | undefined,
|
||||
markerText: string,
|
||||
expiredMarkerText: string,
|
||||
) => {
|
||||
if (focusTimestamp === undefined) return null;
|
||||
|
||||
const oldestTimestamp = messages[0]?.timestamp.getTime();
|
||||
if (oldestTimestamp === undefined || focusTimestamp < oldestTimestamp) {
|
||||
return { index: 0, text: expiredMarkerText };
|
||||
}
|
||||
|
||||
return {
|
||||
index: visibleMessages.findLastIndex((message) => message.timestamp.getTime() <= focusTimestamp) + 1,
|
||||
text: markerText,
|
||||
};
|
||||
};
|
||||
|
||||
const findMarkerLine = (terminal: Terminal, linesFromEnd: number) => {
|
||||
const buffer = terminal.buffer.active;
|
||||
const logicalLineStarts: number[] = [];
|
||||
const cursorLine = buffer.baseY + buffer.cursorY;
|
||||
|
||||
for (let index = 0; index <= cursorLine; index++) {
|
||||
const line = buffer.getLine(index);
|
||||
if (!line) continue;
|
||||
|
||||
if (!line.isWrapped) {
|
||||
logicalLineStarts.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
return logicalLineStarts.at(-linesFromEnd) ?? null;
|
||||
};
|
||||
|
||||
const renderMessages = ({
|
||||
terminal,
|
||||
messages,
|
||||
activeLevels,
|
||||
focusTimestamp,
|
||||
markerText,
|
||||
expiredMarkerText,
|
||||
}: {
|
||||
terminal: Terminal;
|
||||
messages: LoggerMessage[];
|
||||
activeLevels: LogLevel[];
|
||||
focusTimestamp: number | undefined;
|
||||
markerText: string;
|
||||
expiredMarkerText: string;
|
||||
}) => {
|
||||
const activeLevelSet = new Set(activeLevels);
|
||||
const visibleMessages = messages.filter((message) => activeLevelSet.has(message.level));
|
||||
const focusMarker = getFocusMarker(messages, visibleMessages, focusTimestamp, markerText, expiredMarkerText);
|
||||
const output: string[] = [];
|
||||
let markerOutputIndex: number | null = null;
|
||||
|
||||
visibleMessages.forEach((message, index) => {
|
||||
if (focusMarker?.index === index) {
|
||||
markerOutputIndex = output.length;
|
||||
output.push(`\x1b[33m--- ${focusMarker.text} ---\x1b[0m`);
|
||||
}
|
||||
output.push(message.message);
|
||||
});
|
||||
|
||||
if (focusMarker?.index === visibleMessages.length) {
|
||||
markerOutputIndex = output.length;
|
||||
output.push(`\x1b[33m--- ${focusMarker.text} ---\x1b[0m`);
|
||||
}
|
||||
|
||||
let markerLinesFromEnd: number | null = null;
|
||||
if (markerOutputIndex !== null) {
|
||||
markerLinesFromEnd =
|
||||
1 + output.slice(markerOutputIndex).reduce((lineCount, line) => lineCount + line.split(/\r?\n/).length, 0);
|
||||
}
|
||||
|
||||
terminal.write(`\x1bc${output.join("\r\n")}${output.length > 0 ? "\r\n" : ""}`, () => {
|
||||
if (markerLinesFromEnd === null) {
|
||||
terminal.scrollToBottom();
|
||||
return;
|
||||
}
|
||||
|
||||
const markerLine = findMarkerLine(terminal, markerLinesFromEnd);
|
||||
if (markerLine !== null) {
|
||||
terminal.scrollToLine(markerLine);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
interface TerminalComponentProps {
|
||||
focusTimestamp?: number;
|
||||
}
|
||||
|
||||
export const TerminalComponent = ({ focusTimestamp }: TerminalComponentProps) => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { activeLevels, fontSize } = useLogContext();
|
||||
const t = useI18n();
|
||||
|
||||
const terminalRef = useRef<Terminal>(null);
|
||||
const fitAddonRef = useRef<FitAddon>(null);
|
||||
const messagesRef = useRef<LoggerMessage[]>([]);
|
||||
const initialFontSizeRef = useRef(fontSize);
|
||||
const activeLevelsRef = useRef(activeLevels);
|
||||
const markerTextRef = useRef("");
|
||||
const expiredMarkerTextRef = useRef("");
|
||||
const focusTimestampRef = useRef(focusTimestamp);
|
||||
|
||||
activeLevelsRef.current = activeLevels;
|
||||
markerTextRef.current = t("log.context.widgetError");
|
||||
expiredMarkerTextRef.current = t("log.context.expiredWidgetError");
|
||||
focusTimestampRef.current = focusTimestamp;
|
||||
|
||||
const redrawTerminal = () => {
|
||||
const terminal = terminalRef.current;
|
||||
if (!terminal) return;
|
||||
|
||||
renderMessages({
|
||||
terminal,
|
||||
messages: messagesRef.current,
|
||||
activeLevels: activeLevelsRef.current,
|
||||
focusTimestamp: focusTimestampRef.current,
|
||||
markerText: markerTextRef.current,
|
||||
expiredMarkerText: expiredMarkerTextRef.current,
|
||||
});
|
||||
};
|
||||
|
||||
clientApi.log.subscribe.useSubscription(
|
||||
{
|
||||
levels: activeLevels,
|
||||
levels: ALL_LOG_LEVELS,
|
||||
},
|
||||
{
|
||||
onData(data) {
|
||||
terminalRef.current?.writeln(data.message);
|
||||
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
|
||||
onData(event) {
|
||||
if (event.type === "history") {
|
||||
messagesRef.current = event.messages.slice(-LOG_HISTORY_MAX_ENTRIES);
|
||||
redrawTerminal();
|
||||
return;
|
||||
}
|
||||
|
||||
messagesRef.current.push(event.message);
|
||||
if (messagesRef.current.length > LOG_HISTORY_MAX_ENTRIES) {
|
||||
messagesRef.current.shift();
|
||||
}
|
||||
|
||||
if (activeLevelsRef.current.includes(event.message.level)) {
|
||||
terminalRef.current?.writeln(event.message.message);
|
||||
}
|
||||
},
|
||||
onError(err) {
|
||||
alert(err);
|
||||
@@ -44,22 +182,26 @@ export const TerminalComponent = () => {
|
||||
cursorBlink: false,
|
||||
disableStdin: true,
|
||||
convertEol: true,
|
||||
fontSize,
|
||||
fontSize: initialFontSizeRef.current,
|
||||
scrollback: TERMINAL_SCROLLBACK_LINES,
|
||||
});
|
||||
terminalRef.current.open(ref.current);
|
||||
terminalRef.current.loadAddon(canvasAddon);
|
||||
|
||||
setTimeout(() => {
|
||||
const fitTimeout = window.setTimeout(() => {
|
||||
const fitAddon = new FitAddon();
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminalRef.current?.loadAddon(fitAddon);
|
||||
fitAddon.fit();
|
||||
redrawTerminal();
|
||||
});
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(fitTimeout);
|
||||
canvasAddon.dispose();
|
||||
terminalRef.current?.dispose();
|
||||
terminalRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -69,5 +211,9 @@ export const TerminalComponent = () => {
|
||||
fitAddonRef.current?.fit();
|
||||
}, [fontSize]);
|
||||
|
||||
useEffect(() => {
|
||||
redrawTerminal();
|
||||
}, [activeLevels, focusTimestamp]);
|
||||
|
||||
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
|
||||
};
|
||||
@@ -104,6 +104,46 @@ describe("assistant automatic approval tracker", () => {
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("does not approve a ready action while switching conversations", async () => {
|
||||
const container = document.createElement("div");
|
||||
containers.push(container);
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
let confirmationCount = 0;
|
||||
const renderConversation = async (conversationId: string, ready: boolean) => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
createElement(
|
||||
AssistantAutoApprovalProvider,
|
||||
{ conversationId },
|
||||
createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(AutoApprovalProbe),
|
||||
createElement(AutomaticActionProbe, {
|
||||
ready,
|
||||
completed: false,
|
||||
confirm: () => {
|
||||
confirmationCount += 1;
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
await renderConversation("local-thread-1", false);
|
||||
act(() => currentAutoApproval?.setEnabled(true));
|
||||
await renderConversation("local-thread-2", true);
|
||||
|
||||
expect(confirmationCount).toBe(0);
|
||||
expect(currentAutoApproval?.enabled).toBe(false);
|
||||
expect(automaticActionInProgress).toBe(false);
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("clears claimed calls when automatic approvals are turned off", async () => {
|
||||
const container = document.createElement("div");
|
||||
containers.push(container);
|
||||
|
||||
@@ -52,6 +52,8 @@ export const AssistantAutoApprovalProvider = ({
|
||||
const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS));
|
||||
const retryTimersRef = useRef(new Map<string, number>());
|
||||
const previousConversationIdRef = useRef(conversationId);
|
||||
const conversationMatches = previousConversationIdRef.current === conversationId;
|
||||
const effectiveEnabled = conversationMatches && enabled;
|
||||
|
||||
const clearRetryTimer = useCallback((toolCallId: string) => {
|
||||
const timer = retryTimersRef.current.get(toolCallId);
|
||||
@@ -86,7 +88,7 @@ export const AssistantAutoApprovalProvider = ({
|
||||
|
||||
const requestAction = useCallback(
|
||||
(toolCallId: string, confirm: () => void) => {
|
||||
if (!enabled || !trackerRef.current.claim(toolCallId)) return false;
|
||||
if (!effectiveEnabled || !trackerRef.current.claim(toolCallId)) return false;
|
||||
|
||||
try {
|
||||
confirm();
|
||||
@@ -105,7 +107,7 @@ export const AssistantAutoApprovalProvider = ({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[clearRetryTimer, enabled],
|
||||
[clearRetryTimer, effectiveEnabled],
|
||||
);
|
||||
|
||||
const completeAction = useCallback(
|
||||
@@ -116,10 +118,15 @@ export const AssistantAutoApprovalProvider = ({
|
||||
[clearRetryTimer],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ enabled, setEnabled, requestAction, completeAction, retryRevision }),
|
||||
[completeAction, enabled, requestAction, retryRevision, setEnabled],
|
||||
);
|
||||
const value = useMemo(() => {
|
||||
return {
|
||||
enabled: effectiveEnabled,
|
||||
setEnabled,
|
||||
requestAction,
|
||||
completeAction,
|
||||
retryRevision,
|
||||
};
|
||||
}, [completeAction, effectiveEnabled, requestAction, retryRevision, setEnabled]);
|
||||
|
||||
return <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>;
|
||||
};
|
||||
|
||||
@@ -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<void>;
|
||||
setWidgetVisible: (widgetId: string, visible: boolean) => void;
|
||||
activateWidget: (widgetId: string) => void;
|
||||
}
|
||||
|
||||
export const AssistantContext = createContext<AssistantContextValue | null>(null);
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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<AskUserArgs, AskUserResult>) => {
|
||||
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<HTMLInputElement>(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 = ({
|
||||
</Group>
|
||||
|
||||
<Stack gap="xs" mt="md">
|
||||
{autoConfirming ? (
|
||||
<AssistantAutomaticActionProgress label={t("automaticAffirmative")} />
|
||||
) : (
|
||||
options.map((option, index) => {
|
||||
const kind = getAssistantAskUserOptionKind(option);
|
||||
const icon =
|
||||
kind === "affirmative" ? (
|
||||
<IconCheck size={17} />
|
||||
) : kind === "negative" ? (
|
||||
<IconX size={17} />
|
||||
) : (
|
||||
<IconArrowRight size={17} />
|
||||
);
|
||||
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={`${option.id}:${index}`}
|
||||
className={classes.humanToolOption}
|
||||
data-kind={kind}
|
||||
disabled={submitting}
|
||||
onClick={() =>
|
||||
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
|
||||
}
|
||||
>
|
||||
<ThemeIcon className={classes.humanToolOptionIcon} variant="light" color={color} size="sm">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Box className={classes.humanToolOptionText} ta="start">
|
||||
<Text component="span" size="sm" fw={650} className={classes.humanToolOptionLabel}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.description && (
|
||||
<Text
|
||||
component="span"
|
||||
display="block"
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
fw={400}
|
||||
className={classes.humanToolOptionDescription}
|
||||
>
|
||||
{option.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Badge
|
||||
className={classes.humanToolOptionBadge}
|
||||
classNames={{ label: classes.humanToolOptionBadgeLabel }}
|
||||
variant="light"
|
||||
color={color}
|
||||
size="xs"
|
||||
>
|
||||
{t(`optionKind.${kind}`)}
|
||||
</Badge>
|
||||
</UnstyledButton>
|
||||
{options.map((option, index) => {
|
||||
const kind = getAssistantAskUserOptionKind(option);
|
||||
const icon =
|
||||
kind === "affirmative" ? (
|
||||
<IconCheck size={17} />
|
||||
) : kind === "negative" ? (
|
||||
<IconX size={17} />
|
||||
) : (
|
||||
<IconArrowRight size={17} />
|
||||
);
|
||||
})
|
||||
)}
|
||||
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
|
||||
|
||||
{!autoConfirming &&
|
||||
args.allowOther !== false &&
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={`${option.id}:${index}`}
|
||||
className={classes.humanToolOption}
|
||||
data-kind={kind}
|
||||
disabled={submitting}
|
||||
onClick={() =>
|
||||
submitResult({ answer: option.label, optionId: option.id, optionKind: kind, source: "option" })
|
||||
}
|
||||
>
|
||||
<ThemeIcon className={classes.humanToolOptionIcon} variant="light" color={color} size="sm">
|
||||
{icon}
|
||||
</ThemeIcon>
|
||||
<Box className={classes.humanToolOptionText} ta="start">
|
||||
<Text component="span" size="sm" fw={650} className={classes.humanToolOptionLabel}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{option.description && (
|
||||
<Text
|
||||
component="span"
|
||||
display="block"
|
||||
size="xs"
|
||||
c="dimmed"
|
||||
fw={400}
|
||||
className={classes.humanToolOptionDescription}
|
||||
>
|
||||
{option.description}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Badge
|
||||
className={classes.humanToolOptionBadge}
|
||||
classNames={{ label: classes.humanToolOptionBadgeLabel }}
|
||||
variant="light"
|
||||
color={color}
|
||||
size="xs"
|
||||
>
|
||||
{t(`optionKind.${kind}`)}
|
||||
</Badge>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
|
||||
{args.allowOther !== false &&
|
||||
(showOther ? (
|
||||
<Group align="flex-end" gap="xs" wrap="nowrap">
|
||||
<TextInput
|
||||
|
||||
@@ -98,18 +98,18 @@
|
||||
container-type: size;
|
||||
position: fixed;
|
||||
z-index: 290;
|
||||
inset-inline-start: 50%;
|
||||
bottom: 1rem;
|
||||
inset-block: 1rem;
|
||||
inset-inline-end: 1rem;
|
||||
inset-inline-start: auto;
|
||||
display: flex;
|
||||
width: min(46rem, calc(100vw - 2rem));
|
||||
height: min(36rem, calc(100dvh - 5rem));
|
||||
min-height: min(22rem, calc(100dvh - 5rem));
|
||||
width: min(34rem, calc(100vw - 2rem));
|
||||
height: auto;
|
||||
min-height: min(30rem, calc(100dvh - 2rem));
|
||||
margin: 0;
|
||||
transform: translateX(-50%);
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
color: var(--mantine-color-text);
|
||||
background: var(--mantine-color-body);
|
||||
@@ -157,27 +157,28 @@
|
||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.panelIdentity {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.panelIdentityIcon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.panelIdentityTitle,
|
||||
.panelThreadTitle {
|
||||
max-width: 14rem;
|
||||
}
|
||||
|
||||
.panelActions {
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.windowControls {
|
||||
.newConversationButton {
|
||||
flex: 0 0 auto;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.windowControl {
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
}
|
||||
|
||||
.windowControl:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.panelAction {
|
||||
@@ -191,10 +192,6 @@
|
||||
padding: 0.45rem 0.5rem 0.45rem 0.65rem;
|
||||
}
|
||||
|
||||
.composerHints {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.historyButton {
|
||||
width: 2.625rem;
|
||||
max-width: 2.625rem;
|
||||
@@ -210,6 +207,21 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.newConversationButton {
|
||||
width: 2.625rem;
|
||||
max-width: 2.625rem;
|
||||
height: 2.625rem;
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.newConversationButton :global(.mantine-Button-section) {
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.newConversationButton :global(.mantine-Button-label) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.messages {
|
||||
padding: 0.5rem 0.65rem 1rem;
|
||||
}
|
||||
@@ -246,9 +258,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
@container assistant-widget (max-width: 36rem) {
|
||||
.historyButton {
|
||||
width: 2.625rem;
|
||||
max-width: 2.625rem;
|
||||
height: 2.625rem;
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.historyButtonSection {
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
.historyButtonLabel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@container assistant-widget (max-width: 22rem) {
|
||||
.panelAction,
|
||||
.historyButton {
|
||||
.historyButton,
|
||||
.newConversationButton {
|
||||
width: 2.25rem;
|
||||
max-width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
@@ -268,7 +298,11 @@
|
||||
padding-inline: 0.15rem;
|
||||
}
|
||||
|
||||
.composerContext > :last-child {
|
||||
.composerContextLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panelThreadTitle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -298,17 +332,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;
|
||||
@@ -341,6 +428,11 @@
|
||||
margin-block: 0.9rem;
|
||||
}
|
||||
|
||||
.message[data-static] {
|
||||
contain-intrinsic-size: auto 12rem;
|
||||
content-visibility: auto;
|
||||
}
|
||||
|
||||
.userMessageWrap {
|
||||
width: fit-content;
|
||||
max-width: 86%;
|
||||
@@ -706,24 +798,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 +1180,43 @@
|
||||
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);
|
||||
}
|
||||
|
||||
.messageMoreItem:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.messageMoreSeparator {
|
||||
height: 1px;
|
||||
margin-block: 0.2rem;
|
||||
background: var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.userActions {
|
||||
min-height: 1.75rem;
|
||||
}
|
||||
@@ -1173,13 +1299,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);
|
||||
}
|
||||
@@ -1415,10 +1541,6 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.composerHints {
|
||||
padding-inline: 0.45rem;
|
||||
}
|
||||
|
||||
.composerFooter {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
@@ -1641,12 +1763,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 +1778,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 {
|
||||
@@ -1686,10 +1809,6 @@
|
||||
padding-inline: 0.1rem;
|
||||
}
|
||||
|
||||
.composerHints {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.runtimeSelectorTrigger {
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -1705,6 +1824,10 @@
|
||||
}
|
||||
|
||||
@media (max-width: 30em) {
|
||||
.historyItemMenu {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.historyButton {
|
||||
width: 2.625rem;
|
||||
max-width: 2.625rem;
|
||||
|
||||
@@ -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,6 +94,7 @@ import {
|
||||
IconChevronUp,
|
||||
IconCopy,
|
||||
IconDownload,
|
||||
IconDotsVertical,
|
||||
IconFile,
|
||||
IconFileExport,
|
||||
IconLink,
|
||||
@@ -112,6 +116,8 @@ import {
|
||||
IconThumbUp,
|
||||
IconTool,
|
||||
IconTrash,
|
||||
IconVolume,
|
||||
IconVolumeOff,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
@@ -124,6 +130,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 +191,7 @@ interface AssistantPanelProps extends AssistantConversationControls {
|
||||
onClose: () => void;
|
||||
onDismissActivity: () => void;
|
||||
activityDismissed: boolean;
|
||||
hasVisibleWidget: boolean;
|
||||
isRunning: boolean;
|
||||
unreadCount: number;
|
||||
latestAssistantText: string;
|
||||
@@ -1107,63 +1115,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 (
|
||||
<Tooltip label={t("exportMarkdown")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label={t("exportMarkdown")}
|
||||
onClick={() => {
|
||||
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`);
|
||||
}}
|
||||
>
|
||||
<IconFileExport size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Box className={classes.messageActions}>
|
||||
<ActionBarPrimitive.Root hideWhenRunning className={classes.messageActionBar}>
|
||||
<Group gap={2} wrap="wrap">
|
||||
<ActionBarPrimitive.Root
|
||||
hideWhenRunning
|
||||
autohide="not-last"
|
||||
autohideFloat="single-branch"
|
||||
className={classes.messageActionBar}
|
||||
>
|
||||
<Group gap={2} wrap="nowrap">
|
||||
<ProviderMessageInfo />
|
||||
<Tooltip label={t("copy")}>
|
||||
<ActionBarPrimitive.Copy asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copy")}>
|
||||
<IconCopy size={14} />
|
||||
<AuiIf condition={(state) => !state.message.isCopied}>
|
||||
<IconCopy size={14} />
|
||||
</AuiIf>
|
||||
<AuiIf condition={(state) => state.message.isCopied}>
|
||||
<IconCheck size={14} />
|
||||
</AuiIf>
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.Copy>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("helpful")}>
|
||||
<ActionBarPrimitive.FeedbackPositive asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("helpful")}>
|
||||
<IconThumbUp size={14} />
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.FeedbackPositive>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("notHelpful")}>
|
||||
<ActionBarPrimitive.FeedbackNegative asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("notHelpful")}>
|
||||
<IconThumbDown size={14} />
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.FeedbackNegative>
|
||||
</Tooltip>
|
||||
<AssistantMessageExportAction />
|
||||
<Tooltip label={t("regenerate")}>
|
||||
<ActionBarPrimitive.Reload asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("regenerate")}>
|
||||
@@ -1171,6 +1159,50 @@ const AssistantMessageActions = () => {
|
||||
</ActionIcon>
|
||||
</ActionBarPrimitive.Reload>
|
||||
</Tooltip>
|
||||
<ActionBarMorePrimitive.Root>
|
||||
<ActionBarMorePrimitive.Trigger asChild>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("moreActions")}>
|
||||
<IconDotsVertical size={14} />
|
||||
</ActionIcon>
|
||||
</ActionBarMorePrimitive.Trigger>
|
||||
<ActionBarMorePrimitive.Content className={classes.messageMoreMenu} sideOffset={4} align="end">
|
||||
<ActionBarPrimitive.FeedbackPositive asChild>
|
||||
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
||||
<IconThumbUp size={15} />
|
||||
{t("helpful")}
|
||||
</ActionBarMorePrimitive.Item>
|
||||
</ActionBarPrimitive.FeedbackPositive>
|
||||
<ActionBarPrimitive.FeedbackNegative asChild>
|
||||
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
||||
<IconThumbDown size={15} />
|
||||
{t("notHelpful")}
|
||||
</ActionBarMorePrimitive.Item>
|
||||
</ActionBarPrimitive.FeedbackNegative>
|
||||
<AuiIf condition={(state) => state.thread.capabilities.speech && state.message.speech === undefined}>
|
||||
<ActionBarPrimitive.Speak asChild>
|
||||
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
||||
<IconVolume size={15} />
|
||||
{t("readAloud")}
|
||||
</ActionBarMorePrimitive.Item>
|
||||
</ActionBarPrimitive.Speak>
|
||||
</AuiIf>
|
||||
<AuiIf condition={(state) => state.message.speech !== undefined}>
|
||||
<ActionBarPrimitive.StopSpeaking asChild>
|
||||
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
||||
<IconVolumeOff size={15} />
|
||||
{t("stopReading")}
|
||||
</ActionBarMorePrimitive.Item>
|
||||
</ActionBarPrimitive.StopSpeaking>
|
||||
</AuiIf>
|
||||
<ActionBarMorePrimitive.Separator className={classes.messageMoreSeparator} />
|
||||
<ActionBarPrimitive.ExportMarkdown onExport={() => exportMessage()} asChild>
|
||||
<ActionBarMorePrimitive.Item className={classes.messageMoreItem}>
|
||||
<IconFileExport size={15} />
|
||||
{t("exportMarkdown")}
|
||||
</ActionBarMorePrimitive.Item>
|
||||
</ActionBarPrimitive.ExportMarkdown>
|
||||
</ActionBarMorePrimitive.Content>
|
||||
</ActionBarMorePrimitive.Root>
|
||||
</Group>
|
||||
</ActionBarPrimitive.Root>
|
||||
<BranchPicker />
|
||||
@@ -1181,7 +1213,7 @@ const AssistantMessageActions = () => {
|
||||
const UserMessageActions = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
return (
|
||||
<ActionBarPrimitive.Root hideWhenRunning className={classes.userActions}>
|
||||
<ActionBarPrimitive.Root hideWhenRunning autohide="not-last" className={classes.userActions}>
|
||||
<Group gap={2}>
|
||||
<Tooltip label={t("copyMessage")}>
|
||||
<ActionBarPrimitive.Copy asChild>
|
||||
@@ -1225,7 +1257,7 @@ const UserMessage = () => {
|
||||
const isEditing = useAuiState((state) => state.composer.isEditing);
|
||||
if (isEditing) return <EditComposer />;
|
||||
return (
|
||||
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`}>
|
||||
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`} data-static>
|
||||
<Box className={classes.userMessage}>
|
||||
<MessagePrimitive.Quote>
|
||||
{(quote) => (
|
||||
@@ -1732,12 +1764,17 @@ const ConversationContext = () => {
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => setOpened((value) => !value)}
|
||||
>
|
||||
<RingProgress
|
||||
size={24}
|
||||
thickness={3}
|
||||
roundCaps
|
||||
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
|
||||
/>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<RingProgress
|
||||
size={22}
|
||||
thickness={3}
|
||||
roundCaps
|
||||
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
|
||||
/>
|
||||
<Text className={classes.composerContextLabel} component="span" size="xs" fw={650}>
|
||||
{hasContext ? `${contextPercentage}%` : t("mentions.context")}
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown className={classes.conversationContextPopover}>
|
||||
@@ -1902,60 +1939,80 @@ const WebSearchActivity = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const AssistantMessage = () => (
|
||||
<MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
|
||||
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty">
|
||||
{({ part, children }) => {
|
||||
switch (part.type) {
|
||||
case "group-agent-trace": {
|
||||
const startIndex = part.indices[0];
|
||||
const endIndex = part.indices.at(-1);
|
||||
if (startIndex === undefined || endIndex === undefined) return null;
|
||||
return (
|
||||
<ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
|
||||
<AssistantChainOfThought>{children}</AssistantChainOfThought>
|
||||
</ChainOfThoughtByIndicesProvider>
|
||||
);
|
||||
const AssistantMessage = () => {
|
||||
const isComplete = useAuiState((state) => state.message.status?.type === "complete");
|
||||
|
||||
return (
|
||||
<MessagePrimitive.Root
|
||||
className={`${classes.message} ${classes.assistantMessage}`}
|
||||
data-static={isComplete || undefined}
|
||||
>
|
||||
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty">
|
||||
{({ part, children }) => {
|
||||
switch (part.type) {
|
||||
case "group-agent-trace": {
|
||||
const startIndex = part.indices[0];
|
||||
const endIndex = part.indices.at(-1);
|
||||
if (startIndex === undefined || endIndex === undefined) return null;
|
||||
return (
|
||||
<ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
|
||||
<AssistantChainOfThought>{children}</AssistantChainOfThought>
|
||||
</ChainOfThoughtByIndicesProvider>
|
||||
);
|
||||
}
|
||||
case "group-reasoning":
|
||||
return <>{children}</>;
|
||||
case "group-tool":
|
||||
return <AgentTraceToolGroup>{children}</AgentTraceToolGroup>;
|
||||
case "indicator":
|
||||
return <AssistantMessagePending status={{ type: "running" }} />;
|
||||
case "text":
|
||||
return <AssistantTextPart />;
|
||||
case "reasoning":
|
||||
return <ReasoningPart {...part} />;
|
||||
case "tool-call":
|
||||
return part.toolUI ?? <ToolPart {...part} />;
|
||||
case "source":
|
||||
return <SourcePart {...part} />;
|
||||
case "file":
|
||||
return <FilePart {...part} />;
|
||||
case "image":
|
||||
return <ImagePart {...part} />;
|
||||
case "data":
|
||||
return part.dataRendererUI;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
case "group-reasoning":
|
||||
return <>{children}</>;
|
||||
case "group-tool":
|
||||
return <AgentTraceToolGroup>{children}</AgentTraceToolGroup>;
|
||||
case "indicator":
|
||||
return <AssistantMessagePending status={{ type: "running" }} />;
|
||||
case "text":
|
||||
return <AssistantTextPart />;
|
||||
case "reasoning":
|
||||
return <ReasoningPart {...part} />;
|
||||
case "tool-call":
|
||||
return part.toolUI ?? <ToolPart {...part} />;
|
||||
case "source":
|
||||
return <SourcePart {...part} />;
|
||||
case "file":
|
||||
return <FilePart {...part} />;
|
||||
case "image":
|
||||
return <ImagePart {...part} />;
|
||||
case "data":
|
||||
return part.dataRendererUI;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}}
|
||||
</MessagePrimitive.GroupedParts>
|
||||
<RuntimeError />
|
||||
<WebSearchActivity />
|
||||
<AssistantMessageActions />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
}}
|
||||
</MessagePrimitive.GroupedParts>
|
||||
<RuntimeError />
|
||||
<WebSearchActivity />
|
||||
<AssistantMessageActions />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
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 [renaming, setRenaming] = useState(false);
|
||||
const [nextTitle, setNextTitle] = useState(title ?? "");
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (renameOpened) renameInputRef.current?.focus();
|
||||
}, [renameOpened]);
|
||||
|
||||
const exportConversation = async () => {
|
||||
if (!remoteId || exporting) return;
|
||||
@@ -1977,23 +2034,109 @@ const ThreadListItem = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const renameConversation = async () => {
|
||||
const trimmedTitle = nextTitle.trim();
|
||||
if (!trimmedTitle || renaming) return;
|
||||
setRenaming(true);
|
||||
try {
|
||||
await aui.threadListItem().rename(trimmedTitle);
|
||||
setRenameOpened(false);
|
||||
setActionsOpened(false);
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("renameConversation.failedTitle"),
|
||||
message: t("renameConversation.failedDescription"),
|
||||
});
|
||||
} finally {
|
||||
setRenaming(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: async () => {
|
||||
try {
|
||||
await aui.threadListItem().delete();
|
||||
} catch {
|
||||
showErrorNotification({
|
||||
title: t("deleteConversation.failedTitle"),
|
||||
message: t("deleteConversation.failedDescription"),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (renameOpened) {
|
||||
return (
|
||||
<ThreadListItemPrimitive.Root className={classes.historyItem}>
|
||||
<TextInput
|
||||
ref={renameInputRef}
|
||||
className={classes.historyRenameInput}
|
||||
value={nextTitle}
|
||||
onChange={(event) => setNextTitle(event.currentTarget.value)}
|
||||
aria-label={t("renameConversation.label")}
|
||||
maxLength={72}
|
||||
size="xs"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void renameConversation();
|
||||
if (event.key === "Escape" && !renaming) setRenameOpened(false);
|
||||
}}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="green"
|
||||
size="sm"
|
||||
aria-label={t("renameConversation.save")}
|
||||
disabled={!nextTitle.trim()}
|
||||
loading={renaming}
|
||||
loaderProps={{ type: "bars" }}
|
||||
onClick={() => void renameConversation()}
|
||||
>
|
||||
<IconCheck size={15} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
aria-label={t("cancel")}
|
||||
disabled={renaming}
|
||||
onClick={() => setRenameOpened(false)}
|
||||
>
|
||||
<IconX size={15} />
|
||||
</ActionIcon>
|
||||
</ThreadListItemPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreadListItemPrimitive.Root className={classes.historyItem}>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<ThreadListItemPrimitive.Trigger asChild>
|
||||
<Button
|
||||
<ThreadListItemPrimitive.Trigger asChild>
|
||||
<UnstyledButton className={classes.historyItemTrigger} title={title} onClick={onSelect}>
|
||||
<Text component="span" size="sm" fw={500} truncate>
|
||||
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
</ThreadListItemPrimitive.Trigger>
|
||||
{actionsOpened && (
|
||||
<Group className={classes.historyItemActions} gap={1} wrap="nowrap">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
justify="flex-start"
|
||||
flex={1}
|
||||
style={{ minWidth: 0 }}
|
||||
onClick={onSelect}
|
||||
size="sm"
|
||||
title={t("renameConversation.action")}
|
||||
aria-label={t("renameConversation.action")}
|
||||
onClick={() => {
|
||||
setNextTitle(title ?? "");
|
||||
setRenameOpened(true);
|
||||
}}
|
||||
>
|
||||
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
|
||||
</Button>
|
||||
</ThreadListItemPrimitive.Trigger>
|
||||
<Tooltip label={t("exportConversation.action")}>
|
||||
<IconPencil size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
@@ -2001,24 +2144,49 @@ const ThreadListItem = () => {
|
||||
loading={exporting}
|
||||
loaderProps={{ type: "bars" }}
|
||||
disabled={!remoteId}
|
||||
title={t("exportConversation.action")}
|
||||
aria-label={t("exportConversation.action")}
|
||||
onClick={() => void exportConversation()}
|
||||
>
|
||||
<IconFileExport size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ThreadListItemPrimitive.Delete asChild>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" aria-label={t("delete")}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
title={t("delete")}
|
||||
aria-label={t("delete")}
|
||||
onClick={deleteConversation}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</ThreadListItemPrimitive.Delete>
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
<ActionIcon
|
||||
className={classes.historyItemMenu}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
title={t("conversationActions")}
|
||||
aria-label={t("conversationActions")}
|
||||
aria-expanded={actionsOpened}
|
||||
onClick={() => setActionsOpened((current) => !current)}
|
||||
>
|
||||
{actionsOpened ? <IconX size={15} /> : <IconDotsVertical size={15} />}
|
||||
</ActionIcon>
|
||||
</ThreadListItemPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<HistorySelectContext.Provider value={onSelect}>
|
||||
<Stack className={classes.historyMenu} gap="xs" p="xs">
|
||||
@@ -2035,9 +2203,28 @@ const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
|
||||
{t("conversations")}
|
||||
</Text>
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah="min(24rem, 55dvh)" type="auto" offsetScrollbars>
|
||||
<TextInput
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={15} />}
|
||||
placeholder={t("searchConversations")}
|
||||
aria-label={t("searchConversations")}
|
||||
size="xs"
|
||||
/>
|
||||
<ScrollArea.Autosize mah="min(24rem, 55dvh)" type="auto" scrollbars="y" offsetScrollbars>
|
||||
<Stack gap={3}>
|
||||
<ThreadListPrimitive.Items components={{ ThreadListItem }} />
|
||||
<ThreadListPrimitive.Items>
|
||||
{({ threadListItem }) => {
|
||||
const itemTitle = threadListItem.title ?? t("newConversation");
|
||||
if (normalizedQuery && !itemTitle.toLocaleLowerCase().includes(normalizedQuery)) return null;
|
||||
return <ThreadListItem />;
|
||||
}}
|
||||
</ThreadListPrimitive.Items>
|
||||
{normalizedQuery && !hasMatchingConversation && (
|
||||
<Text size="xs" c="dimmed" ta="center" py="md">
|
||||
{t("noMatchingConversations")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
@@ -2052,7 +2239,6 @@ const ConversationHistory = () => {
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
onDismiss={() => setOpened(false)}
|
||||
position="bottom-end"
|
||||
width="min(22rem, calc(100vw - 1rem))"
|
||||
shadow="md"
|
||||
@@ -2191,7 +2377,7 @@ const EmptyThread = () => {
|
||||
</Text>
|
||||
</Stack>
|
||||
<Box className={classes.suggestions}>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send asChild>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.health.prompt")} send={false} clearComposer asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className={classes.suggestion}
|
||||
@@ -2200,17 +2386,17 @@ const EmptyThread = () => {
|
||||
{t("suggestions.health.label")}
|
||||
</Button>
|
||||
</ThreadPrimitive.Suggestion>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send asChild>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.explore.prompt")} send={false} clearComposer asChild>
|
||||
<Button variant="default" className={classes.suggestion} leftSection={<IconApps size={18} />}>
|
||||
{t("suggestions.explore.label")}
|
||||
</Button>
|
||||
</ThreadPrimitive.Suggestion>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send asChild>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.media.prompt")} send={false} clearComposer asChild>
|
||||
<Button variant="default" className={classes.suggestion} leftSection={<IconSearch size={18} />}>
|
||||
{t("suggestions.media.label")}
|
||||
</Button>
|
||||
</ThreadPrimitive.Suggestion>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send asChild>
|
||||
<ThreadPrimitive.Suggestion prompt={t("suggestions.style.prompt")} send={false} clearComposer asChild>
|
||||
<Button variant="default" className={classes.suggestion} leftSection={<IconPalette size={18} />}>
|
||||
{t("suggestions.style.label")}
|
||||
</Button>
|
||||
@@ -2792,14 +2978,6 @@ const Composer = (props: ComposerProps) => {
|
||||
<HomarrProviderQuota />
|
||||
<ConversationContext />
|
||||
</Group>
|
||||
<Group className={classes.composerHints} gap="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("mentions.hint")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("commands.hint")}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</ComposerPrimitive.Root>
|
||||
</ComposerPrimitive.AttachmentDropzone>
|
||||
@@ -2979,9 +3157,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 (
|
||||
<Group className={classes.panelIdentity} gap="xs" wrap="nowrap">
|
||||
<ThemeIcon className={classes.panelIdentityIcon} variant="light" color="red" radius="xl" size="md">
|
||||
<IconRobot size={16} />
|
||||
</ThemeIcon>
|
||||
<Box miw={0}>
|
||||
<Text className={classes.panelIdentityTitle} size="sm" fw={700} truncate>
|
||||
{t("title")}
|
||||
</Text>
|
||||
<Text className={classes.panelThreadTitle} size="xs" c="dimmed" truncate>
|
||||
{threadTitle ?? t("newConversation")}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export const AssistantConversationSurface = ({
|
||||
isRunning,
|
||||
pendingAction,
|
||||
@@ -2996,7 +3195,7 @@ export const AssistantConversationSurface = ({
|
||||
onReasoningChange,
|
||||
onExpand,
|
||||
onMinimize,
|
||||
onWindowClose,
|
||||
onDismiss,
|
||||
}: AssistantConversationSurfaceProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const reducedMotion = useReducedMotion();
|
||||
@@ -3011,52 +3210,23 @@ export const AssistantConversationSurface = ({
|
||||
return (
|
||||
<AssistantDirectiveEntitiesProvider>
|
||||
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
|
||||
{onWindowClose && onMinimize && (
|
||||
<Group
|
||||
component="fieldset"
|
||||
className={classes.windowControls}
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
aria-label={t("windowControls")}
|
||||
>
|
||||
<Tooltip label={t("close")}>
|
||||
<ActionIcon
|
||||
className={classes.windowControl}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onWindowClose}
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("minimize")}>
|
||||
<ActionIcon
|
||||
className={classes.windowControl}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onMinimize}
|
||||
aria-label={t("minimize")}
|
||||
>
|
||||
<IconMinus size={17} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
<AssistantPanelIdentity />
|
||||
<Group className={classes.panelActions} gap={2} wrap="nowrap">
|
||||
<ConversationHistory />
|
||||
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
|
||||
<AutoApprovalControl />
|
||||
<Tooltip label={t("newConversation")}>
|
||||
<ThreadListPrimitive.New asChild>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
<Button
|
||||
className={classes.newConversationButton}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
leftSection={<IconPlus size={17} />}
|
||||
aria-label={t("newConversation")}
|
||||
>
|
||||
<IconPlus size={17} />
|
||||
</ActionIcon>
|
||||
{t("newConversation")}
|
||||
</Button>
|
||||
</ThreadListPrimitive.New>
|
||||
</Tooltip>
|
||||
{onExpand && (
|
||||
@@ -3072,6 +3242,32 @@ export const AssistantConversationSurface = ({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onMinimize && (
|
||||
<Tooltip label={t("minimize")}>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onMinimize}
|
||||
aria-label={t("minimize")}
|
||||
>
|
||||
<IconMinus size={17} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<Tooltip label={t("close")}>
|
||||
<ActionIcon
|
||||
className={classes.panelAction}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onDismiss}
|
||||
aria-label={t("close")}
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
<AssistantQuestionPortalProvider target={questionPortalTarget}>
|
||||
@@ -3083,7 +3279,7 @@ export const AssistantConversationSurface = ({
|
||||
<ThreadPrimitive.Viewport className={classes.viewport} autoScroll>
|
||||
<Box className={classes.messages}>
|
||||
<EmptyThread />
|
||||
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
|
||||
<ThreadPrimitive.Messages components={assistantThreadMessageComponents} />
|
||||
</Box>
|
||||
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
|
||||
<SelectionToolbarPrimitive.Quote asChild>
|
||||
@@ -3130,6 +3326,7 @@ export const AssistantPanel = ({
|
||||
onClose,
|
||||
onDismissActivity,
|
||||
activityDismissed,
|
||||
hasVisibleWidget,
|
||||
isRunning,
|
||||
unreadCount,
|
||||
latestAssistantText,
|
||||
@@ -3147,8 +3344,18 @@ export const AssistantPanel = ({
|
||||
onReasoningChange,
|
||||
}: AssistantPanelProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const aui = useAui();
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
const panelRef = useRef<HTMLDialogElement>(null);
|
||||
const stopSpeechWithoutSurface = () => {
|
||||
if (hasVisibleWidget) return;
|
||||
const thread = aui.thread();
|
||||
if (thread.getState().speech !== undefined) thread.stopSpeaking();
|
||||
};
|
||||
const minimize = () => {
|
||||
stopSpeechWithoutSurface();
|
||||
onClose();
|
||||
};
|
||||
|
||||
useWindowEvent("keydown", (event) => {
|
||||
if (
|
||||
@@ -3160,7 +3367,7 @@ export const AssistantPanel = ({
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
minimize();
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!opened) {
|
||||
@@ -3182,7 +3389,7 @@ export const AssistantPanel = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{!opened && (
|
||||
{!opened && !hasVisibleWidget && (
|
||||
<AssistantActivityBar
|
||||
onOpen={onOpen}
|
||||
onDismissActivity={onDismissActivity}
|
||||
@@ -3210,8 +3417,9 @@ export const AssistantPanel = ({
|
||||
onModelChange={onModelChange}
|
||||
onReasoningChange={onReasoningChange}
|
||||
autoFocusComposer
|
||||
onMinimize={onClose}
|
||||
onWindowClose={() => {
|
||||
onMinimize={minimize}
|
||||
onDismiss={() => {
|
||||
stopSpeechWithoutSurface();
|
||||
onDismissActivity();
|
||||
onClose();
|
||||
}}
|
||||
|
||||
@@ -7,11 +7,19 @@ import type {
|
||||
AttachmentAdapter,
|
||||
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,
|
||||
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 +49,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 +484,12 @@ const AssistantThreadRuntime = () => {
|
||||
),
|
||||
[selectedModel],
|
||||
);
|
||||
const [speech, setSpeech] = useState<SpeechSynthesisAdapter>();
|
||||
useEffect(() => {
|
||||
if ("speechSynthesis" in window && typeof SpeechSynthesisUtterance !== "undefined") {
|
||||
setSpeech(new WebSpeechSynthesisAdapter());
|
||||
}
|
||||
}, []);
|
||||
const feedback = useMemo(
|
||||
() => ({
|
||||
submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => {
|
||||
@@ -512,6 +527,7 @@ const AssistantThreadRuntime = () => {
|
||||
history,
|
||||
attachments,
|
||||
feedback,
|
||||
speech,
|
||||
},
|
||||
});
|
||||
transport.setRuntime(runtime);
|
||||
@@ -667,6 +683,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [activityDismissed, setActivityDismissed] = useState(true);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [visibleWidgetIds, setVisibleWidgetIds] = useState<Set<string>>(() => new Set());
|
||||
const [queuedPrompt, setQueuedPrompt] = useState<string | null>(null);
|
||||
const aui = useAui();
|
||||
const preferences = useAssistantPreferences();
|
||||
@@ -714,6 +731,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 +867,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 +876,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 (
|
||||
<AssistantAutoApprovalProvider conversationId={conversationId}>
|
||||
<AssistantContext.Provider value={value}>
|
||||
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
|
||||
<AssistantComposerSurfaceProvider surfaceId="floating-panel">
|
||||
<AssistantPanel
|
||||
opened={opened}
|
||||
onOpen={open}
|
||||
onClose={close}
|
||||
onDismissActivity={() => {
|
||||
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}
|
||||
/>
|
||||
</AssistantComposerSurfaceProvider>
|
||||
<AssistantComposerRuntimeProvider>
|
||||
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
|
||||
<AssistantComposerSurfaceBoundary surfaceId="assistant-panel">
|
||||
<AssistantPanel
|
||||
opened={opened}
|
||||
onOpen={open}
|
||||
onClose={close}
|
||||
onDismissActivity={() => {
|
||||
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}
|
||||
/>
|
||||
</AssistantComposerSurfaceBoundary>
|
||||
</AssistantComposerRuntimeProvider>
|
||||
</AssistantContext.Provider>
|
||||
</AssistantAutoApprovalProvider>
|
||||
);
|
||||
|
||||
@@ -26,7 +26,8 @@ import { z } from "zod/v4";
|
||||
import { showErrorNotification } from "@homarr/notifications";
|
||||
|
||||
import {
|
||||
AssistantComposerSurfaceProvider,
|
||||
AssistantComposerRuntimeProvider,
|
||||
AssistantComposerSurfaceBoundary,
|
||||
AssistantRunFocusPreserver,
|
||||
AssistantRuntimeProviderWithTools,
|
||||
assistantSurfaceComposerCacheLimit,
|
||||
@@ -148,14 +149,22 @@ const SurfaceTestAssistant = ({
|
||||
{ runtime, toolkit },
|
||||
createElement(AssistantRunFocusPreserver),
|
||||
createElement(
|
||||
AssistantComposerSurfaceProvider,
|
||||
{ surfaceId: "surface-a" },
|
||||
createElement(SurfaceProbe, { id: "a", onReady }),
|
||||
AssistantComposerRuntimeProvider,
|
||||
null,
|
||||
createElement(
|
||||
AssistantComposerSurfaceBoundary,
|
||||
{ surfaceId: "surface-a" },
|
||||
createElement(SurfaceProbe, { id: "a", onReady }),
|
||||
),
|
||||
),
|
||||
createElement(
|
||||
AssistantComposerSurfaceProvider,
|
||||
{ surfaceId: "surface-b" },
|
||||
createElement(SurfaceProbe, { id: "b", onReady }),
|
||||
AssistantComposerRuntimeProvider,
|
||||
null,
|
||||
createElement(
|
||||
AssistantComposerSurfaceBoundary,
|
||||
{ surfaceId: "surface-b" },
|
||||
createElement(SurfaceProbe, { id: "b", onReady }),
|
||||
),
|
||||
),
|
||||
simulateRunFocus ? createElement(LexicalRunFocusProbe) : null,
|
||||
);
|
||||
|
||||
@@ -157,9 +157,6 @@ const createSurfaceComposerHost = (
|
||||
get attachments() {
|
||||
return getThreadAdapters(threadCore)?.attachments ? attachments : undefined;
|
||||
},
|
||||
get dictation() {
|
||||
return getThreadAdapters(threadCore)?.dictation;
|
||||
},
|
||||
};
|
||||
|
||||
return new Proxy(threadCore as ThreadCoreWithAdapters, {
|
||||
@@ -294,18 +291,21 @@ export const createAssistantSurfaceRuntime = (runtime: AssistantRuntime): Assist
|
||||
subscribe(callback) {
|
||||
let activeCore = sharedBinding.getState();
|
||||
let activeConversationId = getConversationId();
|
||||
let activeComposer = getComposer(activeConversationId, activeCore);
|
||||
retainConversation(activeConversationId);
|
||||
let unsubscribeComposer = getComposer(activeConversationId, activeCore).subscribe(callback);
|
||||
let unsubscribeComposer = activeComposer.subscribe(callback);
|
||||
const handleSharedUpdate = () => {
|
||||
const nextCore = sharedBinding.getState();
|
||||
const nextConversationId = getConversationId();
|
||||
if (nextCore !== activeCore || nextConversationId !== activeConversationId) {
|
||||
if (activeCore.speech !== undefined) activeCore.stopSpeaking();
|
||||
unsubscribeComposer();
|
||||
releaseConversation(activeConversationId);
|
||||
activeCore = nextCore;
|
||||
activeConversationId = nextConversationId;
|
||||
retainConversation(activeConversationId);
|
||||
unsubscribeComposer = getComposer(activeConversationId, activeCore).subscribe(callback);
|
||||
activeComposer = getComposer(activeConversationId, activeCore);
|
||||
unsubscribeComposer = activeComposer.subscribe(callback);
|
||||
}
|
||||
callback();
|
||||
};
|
||||
@@ -360,6 +360,13 @@ export const createAssistantSurfaceRuntime = (runtime: AssistantRuntime): Assist
|
||||
registerModelContextProvider: () => () => {},
|
||||
dispose() {
|
||||
activeConversationCounts.clear();
|
||||
const threadCores = new Set([
|
||||
...Array.from(surfaceCoreByConversation.values(), (entry) => entry.source),
|
||||
...Array.from(composerByConversation.values(), (entry) => entry.source),
|
||||
]);
|
||||
for (const threadCore of threadCores) {
|
||||
if (threadCore.speech !== undefined) threadCore.stopSpeaking();
|
||||
}
|
||||
for (const [conversationId, entry] of composerByConversation) disposeComposer(conversationId, entry);
|
||||
},
|
||||
};
|
||||
@@ -373,7 +380,7 @@ const AssistantComposerSurfaceContext = createContext<AssistantComposerSurfaceVa
|
||||
|
||||
export const useAssistantComposerSurface = () => {
|
||||
const value = useContext(AssistantComposerSurfaceContext);
|
||||
if (!value) throw new Error("useAssistantComposerSurface must be used within AssistantComposerSurfaceProvider");
|
||||
if (!value) throw new Error("useAssistantComposerSurface must be used within AssistantComposerSurfaceBoundary");
|
||||
return value;
|
||||
};
|
||||
|
||||
@@ -437,21 +444,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<HTMLDivElement>(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<HTMLDivElement>) => {
|
||||
const target = event.target;
|
||||
@@ -507,7 +516,6 @@ const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChil
|
||||
}}
|
||||
onPasteCapture={handlePasteCapture}
|
||||
>
|
||||
<AssistantComposerSurfaceEvents />
|
||||
{children}
|
||||
</div>
|
||||
</AssistantComposerSurfaceContext.Provider>
|
||||
@@ -566,18 +574,12 @@ export const AssistantRunFocusPreserver = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
interface AssistantComposerSurfaceProviderProps extends PropsWithChildren {
|
||||
surfaceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +590,8 @@ export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: Assist
|
||||
|
||||
return (
|
||||
<AssistantRuntimeProvider aui={parentAui} runtime={surfaceRuntime}>
|
||||
<AssistantComposerSurfaceBoundary surfaceId={resolvedSurfaceId}>{children}</AssistantComposerSurfaceBoundary>
|
||||
<AssistantComposerSurfaceEvents />
|
||||
{children}
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
};
|
||||
@@ -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<HTMLDivElement>(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 <UnavailableAssistantWidget description={assistant.unavailableDescription} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AssistantComposerSurfaceProvider surfaceId={`board-widget-${props.itemId ?? "preview"}`}>
|
||||
<EnabledAssistantBoardWidget {...props} />
|
||||
</AssistantComposerSurfaceProvider>
|
||||
<AssistantComposerSurfaceBoundary surfaceId={`assistant-widget-${widgetId}`}>
|
||||
<Box ref={widgetRef} w="100%" h="100%">
|
||||
<EnabledAssistantBoardWidget {...props} assistantWidgetId={widgetId} />
|
||||
</Box>
|
||||
</AssistantComposerSurfaceBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
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 <CompactAssistantWidget onOpen={assistant.close} location="panel" />;
|
||||
}
|
||||
|
||||
if (assistant.activeWidgetId !== null && assistant.activeWidgetId !== assistantWidgetId) {
|
||||
return <CompactAssistantWidget onOpen={() => assistant.activateWidget(assistantWidgetId)} location="widget" />;
|
||||
}
|
||||
|
||||
if (width < 300 || height < 280) {
|
||||
return <CompactAssistantWidget onOpen={assistant.open} />;
|
||||
}
|
||||
@@ -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 = <IconArrowsMaximize size={18} />;
|
||||
|
||||
if (location === "panel") {
|
||||
title = t("widget.assistant.compact.openTitle");
|
||||
description = t("widget.assistant.compact.openDescription");
|
||||
actionLabel = t("widget.assistant.compact.returnToWidget");
|
||||
actionIcon = <IconArrowsMinimize size={18} />;
|
||||
}
|
||||
if (location === "widget") {
|
||||
title = t("widget.assistant.compact.otherWidgetTitle");
|
||||
description = t("widget.assistant.compact.otherWidgetDescription");
|
||||
actionLabel = t("widget.assistant.compact.useHere");
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={classes.widgetPanel}>
|
||||
@@ -87,23 +142,16 @@ const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => {
|
||||
</ThemeIcon>
|
||||
<Stack className={classes.compactCopy} gap={1}>
|
||||
<Text className={classes.compactTitle} size="sm" fw={700} lineClamp={1}>
|
||||
{t("widget.assistant.compact.title")}
|
||||
{title}
|
||||
</Text>
|
||||
<Text className={classes.compactDescription} size="xs" c="dimmed" lineClamp={2}>
|
||||
{t("widget.assistant.compact.description")}
|
||||
{description}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Tooltip label={t("widget.assistant.compact.open")}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size={44}
|
||||
radius="md"
|
||||
onClick={onOpen}
|
||||
aria-label={t("widget.assistant.compact.open")}
|
||||
>
|
||||
<IconArrowsMaximize size={18} />
|
||||
<Tooltip label={actionLabel}>
|
||||
<ActionIcon variant="light" color="red" size={44} radius="md" onClick={onOpen} aria-label={actionLabel}>
|
||||
{actionIcon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
@@ -11,6 +11,10 @@ import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||
|
||||
const logger = createLogger({ module: "logRouter" });
|
||||
|
||||
type LogSubscriptionEvent =
|
||||
| { type: "history"; messages: LoggerMessage[] }
|
||||
| { type: "message"; message: LoggerMessage };
|
||||
|
||||
export const logRouter = createTRPCRouter({
|
||||
subscribe: permissionRequiredProcedure
|
||||
.requiresPermission("other-view-logs")
|
||||
@@ -20,15 +24,61 @@ export const logRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.subscription(({ input }) => {
|
||||
return observable<LoggerMessage>((emit) => {
|
||||
const unsubscribe = loggingChannel.subscribe((data) => {
|
||||
if (!input.levels.includes(data.level)) return;
|
||||
emit.next(data);
|
||||
});
|
||||
logger.info("A tRPC client has connected to the logging procedure");
|
||||
return observable<LogSubscriptionEvent>((emit) => {
|
||||
let isClosed = false;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let isHistoryLoaded = false;
|
||||
const bufferedMessages: LoggerMessage[] = [];
|
||||
|
||||
const onMessage = (message: LoggerMessage) => {
|
||||
if (!input.levels.includes(message.level)) return;
|
||||
|
||||
if (!isHistoryLoaded) {
|
||||
bufferedMessages.push(message);
|
||||
return;
|
||||
}
|
||||
|
||||
emit.next({ type: "message", message });
|
||||
};
|
||||
|
||||
void loggingChannel
|
||||
.subscribeAsync(onMessage)
|
||||
.then(async (unsubscribeFromLogging) => {
|
||||
if (isClosed) {
|
||||
unsubscribeFromLogging();
|
||||
return;
|
||||
}
|
||||
|
||||
unsubscribe = unsubscribeFromLogging;
|
||||
logger.info("A tRPC client has connected to the logging procedure");
|
||||
|
||||
const history = (await loggingChannel.getRecentAsync()).filter((message) =>
|
||||
input.levels.includes(message.level),
|
||||
);
|
||||
if (isClosed) return;
|
||||
|
||||
emit.next({ type: "history", messages: history });
|
||||
|
||||
const historyIds = new Set(history.map((message) => message.id));
|
||||
isHistoryLoaded = true;
|
||||
for (const message of bufferedMessages) {
|
||||
if (!historyIds.has(message.id)) {
|
||||
emit.next({ type: "message", message });
|
||||
}
|
||||
}
|
||||
bufferedMessages.length = 0;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
unsubscribe?.();
|
||||
unsubscribe = null;
|
||||
if (!isClosed) {
|
||||
emit.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
isClosed = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import type { LoggerMessage } from "@homarr/redis";
|
||||
|
||||
import { logRouter } from "../log";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getRecentAsync: vi.fn(),
|
||||
subscribeAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/redis", () => ({
|
||||
loggingChannel: {
|
||||
getRecentAsync: mocks.getRecentAsync,
|
||||
subscribeAsync: mocks.subscribeAsync,
|
||||
},
|
||||
}));
|
||||
|
||||
const session = {
|
||||
user: {
|
||||
id: "log-viewer",
|
||||
permissions: ["other-view-logs"],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date(0).toISOString(),
|
||||
} satisfies Session;
|
||||
|
||||
const createMessage = (id: string, timestamp: number): LoggerMessage => ({
|
||||
id,
|
||||
timestamp: new Date(timestamp),
|
||||
level: "info",
|
||||
message: id,
|
||||
});
|
||||
|
||||
describe("log.subscribe", () => {
|
||||
test("emits history before buffered live messages without duplicates", async () => {
|
||||
const historyDeferred = Promise.withResolvers<LoggerMessage[]>();
|
||||
let onMessage: ((message: LoggerMessage) => void) | undefined;
|
||||
const unsubscribe = vi.fn();
|
||||
|
||||
mocks.subscribeAsync.mockImplementation(async (callback: (message: LoggerMessage) => void) => {
|
||||
onMessage = callback;
|
||||
return unsubscribe;
|
||||
});
|
||||
mocks.getRecentAsync.mockReturnValue(historyDeferred.promise);
|
||||
|
||||
const caller = logRouter.createCaller({
|
||||
db: null as never,
|
||||
deviceType: undefined,
|
||||
session,
|
||||
});
|
||||
const subscription = await caller.subscribe({ levels: ["info"] });
|
||||
const events: object[] = [];
|
||||
const observer = subscription.subscribe({
|
||||
next(event) {
|
||||
events.push(event);
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(mocks.getRecentAsync).toHaveBeenCalledOnce());
|
||||
|
||||
const retainedMessage = createMessage("retained", 1);
|
||||
const liveMessage = createMessage("live", 2);
|
||||
onMessage?.(retainedMessage);
|
||||
onMessage?.(liveMessage);
|
||||
historyDeferred.resolve([retainedMessage]);
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(events).toEqual([
|
||||
{ type: "history", messages: [retainedMessage] },
|
||||
{ type: "message", message: liveMessage },
|
||||
]),
|
||||
);
|
||||
|
||||
observer.unsubscribe();
|
||||
expect(unsubscribe).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,20 @@
|
||||
import z from "zod/v4";
|
||||
|
||||
export const logLevels = ["error", "warn", "info", "debug"] as const;
|
||||
export type LogLevel = (typeof logLevels)[number];
|
||||
|
||||
export const loggerMessageSchema = z.object({
|
||||
id: z.string(),
|
||||
timestamp: z.date(),
|
||||
message: z.string(),
|
||||
level: z.enum(logLevels),
|
||||
});
|
||||
export type LoggerMessage = z.infer<typeof loggerMessageSchema>;
|
||||
|
||||
export const LOG_HISTORY_MAX_ENTRIES = 5_000;
|
||||
export const LOG_HISTORY_KEY = "list:logging:history";
|
||||
export const LOG_PUBLISH_CHANNEL = "pubSub:logging";
|
||||
|
||||
export const logLevelConfiguration = {
|
||||
error: {
|
||||
prefix: "🔴",
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import superjson from "superjson";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { stringify } from "superjson";
|
||||
import Transport from "winston-transport";
|
||||
|
||||
import type { RedisClient } from "../../redis/client";
|
||||
import { createRedisClient } from "../../redis/client";
|
||||
import type { LoggerMessage } from "../constants";
|
||||
import { LOG_HISTORY_KEY, LOG_HISTORY_MAX_ENTRIES, LOG_PUBLISH_CHANNEL } from "../constants";
|
||||
|
||||
const messageSymbol = Symbol.for("message");
|
||||
const levelSymbol = Symbol.for("level");
|
||||
@@ -13,32 +16,41 @@ const levelSymbol = Symbol.for("level");
|
||||
//
|
||||
export class RedisTransport extends Transport {
|
||||
private redis: RedisClient | null = null;
|
||||
public static readonly publishChannel = "pubSub:logging";
|
||||
|
||||
/**
|
||||
* Log the info to the Redis channel
|
||||
* Retain the log in a bounded Redis list and publish it to live subscribers.
|
||||
*/
|
||||
log(info: { [messageSymbol]: string; [levelSymbol]: string }, callback: () => void) {
|
||||
log(info: { [messageSymbol]: string; [levelSymbol]: LoggerMessage["level"] }, callback: () => void) {
|
||||
setImmediate(() => {
|
||||
this.emit("logged", info);
|
||||
});
|
||||
|
||||
// Is only initialized here because it did not work when initialized in the constructor or outside the class
|
||||
this.redis ??= createRedisClient();
|
||||
const message: LoggerMessage = {
|
||||
id: randomUUID(),
|
||||
timestamp: new Date(),
|
||||
message: info[messageSymbol],
|
||||
level: info[levelSymbol],
|
||||
};
|
||||
const serializedMessage = stringify(message);
|
||||
|
||||
this.redis
|
||||
.publish(
|
||||
RedisTransport.publishChannel,
|
||||
superjson.stringify({
|
||||
message: info[messageSymbol],
|
||||
level: info[levelSymbol],
|
||||
}),
|
||||
)
|
||||
callback();
|
||||
|
||||
void Promise.resolve()
|
||||
.then(() => {
|
||||
callback();
|
||||
// Is only initialized here because it did not work when initialized in the constructor or outside the class
|
||||
if (!this.redis) {
|
||||
this.redis = createRedisClient();
|
||||
this.redis.options.maxRetriesPerRequest = 1;
|
||||
this.redis.on("error", () => undefined);
|
||||
}
|
||||
|
||||
return this.redis
|
||||
.multi()
|
||||
.lpush(LOG_HISTORY_KEY, serializedMessage)
|
||||
.ltrim(LOG_HISTORY_KEY, 0, LOG_HISTORY_MAX_ENTRIES - 1)
|
||||
.publish(LOG_PUBLISH_CHANNEL, serializedMessage)
|
||||
.exec();
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore errors
|
||||
});
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ const CredentialsSetup = () => {
|
||||
|
||||
return (
|
||||
<AccountShell title={t("title")} description={t("description")}>
|
||||
<form onSubmit={form.onSubmit((values) => void submitAsync(values))}>
|
||||
<form onSubmit={form.onSubmit((values) => submitAsync(values))}>
|
||||
<Stack gap="md">
|
||||
<TextInput label={t("username")} autoComplete="username" withAsterisk {...form.getInputProps("username")} />
|
||||
<UserCreatePasswordFields
|
||||
@@ -119,7 +119,7 @@ const CredentialsSetup = () => {
|
||||
{submitError}
|
||||
</Alert>
|
||||
) : null}
|
||||
<Button type="submit" size="md" loading={mutation.isPending} rightSection={<IconArrowRight size={18} />}>
|
||||
<Button type="submit" size="md" loading={form.submitting} rightSection={<IconArrowRight size={18} />}>
|
||||
{t("create")}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import type { LogLevel } from "@homarr/core/infrastructure/logs/constants";
|
||||
import { parse } from "superjson";
|
||||
|
||||
import type { LoggerMessage } from "@homarr/core/infrastructure/logs/constants";
|
||||
import {
|
||||
loggerMessageSchema,
|
||||
LOG_HISTORY_KEY,
|
||||
LOG_HISTORY_MAX_ENTRIES,
|
||||
LOG_PUBLISH_CHANNEL,
|
||||
} from "@homarr/core/infrastructure/logs/constants";
|
||||
|
||||
import { createListChannel, createSubPubChannel } from "./lib/channel";
|
||||
import { ChannelSubscriptionTracker } from "./lib/channel-subscription-tracker";
|
||||
import { createRedisConnection } from "./lib/connection";
|
||||
|
||||
export {
|
||||
handshakeAsync,
|
||||
@@ -15,9 +25,31 @@ export const pingChannel = createSubPubChannel<
|
||||
>("ping");
|
||||
export const pingUrlChannel = createListChannel<string>("ping-url");
|
||||
|
||||
export interface LoggerMessage {
|
||||
message: string;
|
||||
level: LogLevel;
|
||||
}
|
||||
export type { LoggerMessage } from "@homarr/core/infrastructure/logs/constants";
|
||||
|
||||
export const loggingChannel = createSubPubChannel<LoggerMessage>("logging");
|
||||
const loggingHistoryClient = createRedisConnection();
|
||||
|
||||
const parseLoggerMessage = (message: string): LoggerMessage | null => {
|
||||
try {
|
||||
const result = loggerMessageSchema.safeParse(parse<unknown>(message));
|
||||
return result.success ? result.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const loggingChannel = {
|
||||
subscribeAsync: (callback: (message: LoggerMessage) => void) =>
|
||||
ChannelSubscriptionTracker.subscribeAsync(LOG_PUBLISH_CHANNEL, (message) => {
|
||||
const parsedMessage = parseLoggerMessage(message);
|
||||
if (parsedMessage) callback(parsedMessage);
|
||||
}),
|
||||
getRecentAsync: async () => {
|
||||
const messages =
|
||||
(await loggingHistoryClient?.lrange(LOG_HISTORY_KEY, 0, LOG_HISTORY_MAX_ENTRIES - 1).catch(() => [])) ?? [];
|
||||
return messages
|
||||
.map(parseLoggerMessage)
|
||||
.filter((message): message is LoggerMessage => message !== null)
|
||||
.toReversed();
|
||||
},
|
||||
};
|
||||
@@ -18,6 +18,7 @@ type SubscriptionCallback = (message: string) => MaybePromise<void>;
|
||||
*/
|
||||
export class ChannelSubscriptionTracker {
|
||||
private static subscriptions = new Map<string, Map<string, SubscriptionCallback>>();
|
||||
private static subscriptionReadiness = new Map<string, Promise<void>>();
|
||||
private static redis = createRedisConnection();
|
||||
private static listenerActive = false;
|
||||
|
||||
@@ -28,6 +29,25 @@ export class ChannelSubscriptionTracker {
|
||||
* @returns a function to unsubscribe from the channel
|
||||
*/
|
||||
public static subscribe(channelName: string, callback: SubscriptionCallback) {
|
||||
return this.registerSubscription(channelName, callback).unsubscribe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to a channel and resolves once Redis confirms the subscription.
|
||||
*/
|
||||
public static async subscribeAsync(channelName: string, callback: SubscriptionCallback) {
|
||||
const subscription = this.registerSubscription(channelName, callback);
|
||||
|
||||
try {
|
||||
await subscription.ready;
|
||||
return subscription.unsubscribe;
|
||||
} catch (error) {
|
||||
subscription.unsubscribe();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private static registerSubscription(channelName: string, callback: SubscriptionCallback) {
|
||||
logger.debug("Adding redis channel callback", { channel: channelName });
|
||||
|
||||
// We only want to activate the listener once
|
||||
@@ -38,11 +58,18 @@ export class ChannelSubscriptionTracker {
|
||||
|
||||
const channelSubscriptions = this.subscriptions.get(channelName) ?? new Map<string, SubscriptionCallback>();
|
||||
const id = randomUUID();
|
||||
let ready = this.subscriptionReadiness.get(channelName);
|
||||
|
||||
// If there are no subscriptions to the channel, subscribe to it
|
||||
if (channelSubscriptions.size === 0) {
|
||||
// Subscribe when this channel has no active or pending Redis subscription.
|
||||
if (!ready) {
|
||||
logger.debug("Subscribing to redis channel", { channel: channelName });
|
||||
void this.redis.subscribe(channelName);
|
||||
ready = this.redis.subscribe(channelName).then(() => undefined);
|
||||
this.subscriptionReadiness.set(channelName, ready);
|
||||
void ready.catch(() => {
|
||||
if (this.subscriptionReadiness.get(channelName) === ready) {
|
||||
this.subscriptionReadiness.delete(channelName);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug("Adding redis channel callback", { channel: channelName, id });
|
||||
@@ -50,24 +77,26 @@ export class ChannelSubscriptionTracker {
|
||||
|
||||
this.subscriptions.set(channelName, channelSubscriptions);
|
||||
|
||||
// Return a function to unsubscribe
|
||||
return () => {
|
||||
const unsubscribe = () => {
|
||||
logger.debug("Removing redis channel callback", { channel: channelName, id });
|
||||
|
||||
const channelSubscriptions = this.subscriptions.get(channelName);
|
||||
if (!channelSubscriptions) return;
|
||||
const currentSubscriptions = this.subscriptions.get(channelName);
|
||||
if (!currentSubscriptions) return;
|
||||
|
||||
channelSubscriptions.delete(id);
|
||||
currentSubscriptions.delete(id);
|
||||
|
||||
// If there are no subscriptions to the channel, unsubscribe from it
|
||||
if (channelSubscriptions.size >= 1) {
|
||||
if (currentSubscriptions.size >= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug("Unsubscribing from redis channel", { channel: channelName });
|
||||
void this.redis.unsubscribe(channelName);
|
||||
this.subscriptions.delete(channelName);
|
||||
this.subscriptionReadiness.delete(channelName);
|
||||
};
|
||||
|
||||
return { ready, unsubscribe };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2309,7 +2309,6 @@
|
||||
"open": "Open Homarr Assistant",
|
||||
"close": "Close assistant",
|
||||
"minimize": "Minimize assistant",
|
||||
"windowControls": "Conversation window controls",
|
||||
"spotlight": "Ask Homarr Assistant",
|
||||
"spotlightDescription": "Type and send a prompt without leaving the command menu.",
|
||||
"spotlightPrompt": {
|
||||
@@ -2327,6 +2326,24 @@
|
||||
"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",
|
||||
"label": "Conversation name",
|
||||
"save": "Save name",
|
||||
"failedTitle": "Conversation could not be renamed",
|
||||
"failedDescription": "Homarr could not rename this conversation. Try again."
|
||||
},
|
||||
"deleteConversation": {
|
||||
"title": "Delete conversation?",
|
||||
"description": "“{title}” and its messages will be permanently deleted.",
|
||||
"failedTitle": "Conversation could not be deleted",
|
||||
"failedDescription": "Homarr could not delete this conversation. Try again."
|
||||
},
|
||||
"closeHistory": "Close conversation history",
|
||||
"openHistory": "Open conversation history",
|
||||
"emptyTitle": "What can I help with?",
|
||||
@@ -2399,6 +2416,8 @@
|
||||
"regenerate": "Regenerate response",
|
||||
"helpful": "Helpful",
|
||||
"notHelpful": "Not helpful",
|
||||
"readAloud": "Read response aloud",
|
||||
"stopReading": "Stop reading response",
|
||||
"feedbackError": "Your feedback could not be saved. Try again.",
|
||||
"exportMarkdown": "Export as Markdown",
|
||||
"exportConversation": {
|
||||
@@ -2427,7 +2446,6 @@
|
||||
},
|
||||
"mentions": {
|
||||
"menu": "Add Homarr context",
|
||||
"hint": "Type @ to add context",
|
||||
"context": "Context",
|
||||
"app": "Apps",
|
||||
"integration": "Integrations",
|
||||
@@ -2442,7 +2460,6 @@
|
||||
},
|
||||
"commands": {
|
||||
"menu": "Assistant commands",
|
||||
"hint": "Type / for shortcuts",
|
||||
"health": "Prepare a service-health check",
|
||||
"explore": "Prepare an overview of this Homarr instance",
|
||||
"media": "Prepare a media search",
|
||||
@@ -2555,7 +2572,6 @@
|
||||
"otherPlaceholder": "Type another answer…",
|
||||
"submit": "Send answer",
|
||||
"cancelOther": "Cancel other answer",
|
||||
"automaticAffirmative": "Choosing the affirmative answer automatically",
|
||||
"optionKind": {
|
||||
"affirmative": "Confirm",
|
||||
"negative": "Decline",
|
||||
@@ -2630,7 +2646,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",
|
||||
@@ -6083,7 +6099,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -8724,6 +8746,10 @@
|
||||
}
|
||||
},
|
||||
"log": {
|
||||
"context": {
|
||||
"widgetError": "Widget reported an error around this time",
|
||||
"expiredWidgetError": "Logs around this widget error are no longer retained"
|
||||
},
|
||||
"level": {
|
||||
"option": {
|
||||
"debug": "Debug",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Anchor, Button, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import type { stringOrTranslation } from "@homarr/translation";
|
||||
import { translateIfNecessary } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -15,14 +17,22 @@ export interface BaseWidgetErrorProps {
|
||||
|
||||
export const BaseWidgetError = (props: BaseWidgetErrorProps) => {
|
||||
const t = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const [errorTimestamp] = useState(Date.now);
|
||||
|
||||
return (
|
||||
<Stack h="100%" align="center" justify="center" gap="md" data-homarr-widget-error>
|
||||
<props.icon size={40} />
|
||||
<Stack gap={0}>
|
||||
<Text ta="center">{translateIfNecessary(t, props.message)}</Text>
|
||||
{props.showLogsLink && (
|
||||
<Anchor component={Link} href="/manage/tools/logs" target="_blank" ta="center" size="sm">
|
||||
{props.showLogsLink && session?.user.permissions.includes("other-view-logs") && (
|
||||
<Anchor
|
||||
component={Link}
|
||||
href={`/manage/tools/logs?focus=${errorTimestamp}`}
|
||||
target="_blank"
|
||||
ta="center"
|
||||
size="sm"
|
||||
>
|
||||
{t("common.action.checkLogs")}
|
||||
</Anchor>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,10 @@ vi.mock("@homarr/translation/client", () => ({
|
||||
useI18n: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/auth/client", () => ({
|
||||
useSession: () => ({ data: null }),
|
||||
}));
|
||||
|
||||
const matchMedia = (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
|
||||
Reference in new issue
Block a user