Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f7d150d01 | ||
|
|
ef6d350b4d | ||
|
|
f67f8c1c33 | ||
|
|
d79c748c4d | ||
|
|
80b90802e5 |
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
|
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.
|
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
|
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.
|
summary of accessible Homarr resources. Live status is fetched only when needed.
|
||||||
|
|
||||||
## Permissions and privacy
|
## 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.
|
- Homarr permissions apply to every tool call and mentioned item.
|
||||||
- API keys and custom headers are encrypted and never returned to the browser.
|
- API keys and custom headers are encrypted and never returned to the browser.
|
||||||
- Conversations are stored per user and can be exported or deleted.
|
- Conversations are stored per user and can be exported or deleted.
|
||||||
|
|||||||
@@ -104,6 +104,46 @@ describe("assistant automatic approval tracker", () => {
|
|||||||
await act(async () => root.unmount());
|
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 () => {
|
test("clears claimed calls when automatic approvals are turned off", async () => {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
containers.push(container);
|
containers.push(container);
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ export const AssistantAutoApprovalProvider = ({
|
|||||||
const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS));
|
const trackerRef = useRef(createAssistantAutoApprovalTracker(AUTO_APPROVAL_MAX_ATTEMPTS));
|
||||||
const retryTimersRef = useRef(new Map<string, number>());
|
const retryTimersRef = useRef(new Map<string, number>());
|
||||||
const previousConversationIdRef = useRef(conversationId);
|
const previousConversationIdRef = useRef(conversationId);
|
||||||
|
const conversationMatches = previousConversationIdRef.current === conversationId;
|
||||||
|
const effectiveEnabled = conversationMatches && enabled;
|
||||||
|
|
||||||
const clearRetryTimer = useCallback((toolCallId: string) => {
|
const clearRetryTimer = useCallback((toolCallId: string) => {
|
||||||
const timer = retryTimersRef.current.get(toolCallId);
|
const timer = retryTimersRef.current.get(toolCallId);
|
||||||
@@ -86,7 +88,7 @@ export const AssistantAutoApprovalProvider = ({
|
|||||||
|
|
||||||
const requestAction = useCallback(
|
const requestAction = useCallback(
|
||||||
(toolCallId: string, confirm: () => void) => {
|
(toolCallId: string, confirm: () => void) => {
|
||||||
if (!enabled || !trackerRef.current.claim(toolCallId)) return false;
|
if (!effectiveEnabled || !trackerRef.current.claim(toolCallId)) return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
confirm();
|
confirm();
|
||||||
@@ -105,7 +107,7 @@ export const AssistantAutoApprovalProvider = ({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[clearRetryTimer, enabled],
|
[clearRetryTimer, effectiveEnabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
const completeAction = useCallback(
|
const completeAction = useCallback(
|
||||||
@@ -116,10 +118,15 @@ export const AssistantAutoApprovalProvider = ({
|
|||||||
[clearRetryTimer],
|
[clearRetryTimer],
|
||||||
);
|
);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(() => {
|
||||||
() => ({ enabled, setEnabled, requestAction, completeAction, retryRevision }),
|
return {
|
||||||
[completeAction, enabled, requestAction, retryRevision, setEnabled],
|
enabled: effectiveEnabled,
|
||||||
);
|
setEnabled,
|
||||||
|
requestAction,
|
||||||
|
completeAction,
|
||||||
|
retryRevision,
|
||||||
|
};
|
||||||
|
}, [completeAction, effectiveEnabled, requestAction, retryRevision, setEnabled]);
|
||||||
|
|
||||||
return <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>;
|
return <AssistantAutoApprovalContext.Provider value={value}>{children}</AssistantAutoApprovalContext.Provider>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,11 +20,15 @@ export interface AssistantContextValue {
|
|||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
isRefreshing: boolean;
|
isRefreshing: boolean;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
|
hasVisibleWidget: boolean;
|
||||||
|
activeWidgetId: string | null;
|
||||||
open: () => void;
|
open: () => void;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
sendPrompt: (prompt: string) => boolean;
|
sendPrompt: (prompt: string) => boolean;
|
||||||
refreshCurrentView: () => Promise<void>;
|
refreshCurrentView: () => Promise<void>;
|
||||||
|
setWidgetVisible: (widgetId: string, visible: boolean) => void;
|
||||||
|
activateWidget: (widgetId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AssistantContext = createContext<AssistantContextValue | null>(null);
|
export const AssistantContext = createContext<AssistantContextValue | null>(null);
|
||||||
|
|||||||
@@ -34,11 +34,15 @@ const DisabledAssistant = ({ children, description }: PropsWithChildren<{ descri
|
|||||||
isRunning: false,
|
isRunning: false,
|
||||||
isRefreshing: false,
|
isRefreshing: false,
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
|
hasVisibleWidget: false,
|
||||||
|
activeWidgetId: null,
|
||||||
open: () => undefined,
|
open: () => undefined,
|
||||||
close: () => undefined,
|
close: () => undefined,
|
||||||
toggle: () => undefined,
|
toggle: () => undefined,
|
||||||
sendPrompt: () => false,
|
sendPrompt: () => false,
|
||||||
refreshCurrentView: () => Promise.resolve(),
|
refreshCurrentView: () => Promise.resolve(),
|
||||||
|
setWidgetVisible: () => undefined,
|
||||||
|
activateWidget: () => undefined,
|
||||||
}),
|
}),
|
||||||
[description],
|
[description],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { AppForm } from "@homarr/forms-collection";
|
|||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { appManageSchema } from "@homarr/validation/app";
|
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 { useAssistantAutomaticAction } from "./assistant-auto-approval";
|
||||||
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
|
import { AssistantAutomaticActionProgress } from "./assistant-automatic-action-progress";
|
||||||
import classes from "./assistant-panel.module.css";
|
import classes from "./assistant-panel.module.css";
|
||||||
@@ -67,7 +67,6 @@ export const AssistantAskUserTool = ({
|
|||||||
result,
|
result,
|
||||||
addResult,
|
addResult,
|
||||||
status,
|
status,
|
||||||
toolCallId,
|
|
||||||
}: ToolCallMessagePartProps<AskUserArgs, AskUserResult>) => {
|
}: ToolCallMessagePartProps<AskUserArgs, AskUserResult>) => {
|
||||||
const t = useScopedI18n("common.assistant.askUser");
|
const t = useScopedI18n("common.assistant.askUser");
|
||||||
const [showOther, setShowOther] = useState(false);
|
const [showOther, setShowOther] = useState(false);
|
||||||
@@ -75,17 +74,6 @@ export const AssistantAskUserTool = ({
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const otherInputRef = useRef<HTMLInputElement>(null);
|
const otherInputRef = useRef<HTMLInputElement>(null);
|
||||||
const options = Array.isArray(args?.options) ? args.options : [];
|
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(() => {
|
useEffect(() => {
|
||||||
if (showOther) otherInputRef.current?.focus();
|
if (showOther) otherInputRef.current?.focus();
|
||||||
@@ -165,67 +153,62 @@ export const AssistantAskUserTool = ({
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Stack gap="xs" mt="md">
|
<Stack gap="xs" mt="md">
|
||||||
{autoConfirming ? (
|
{options.map((option, index) => {
|
||||||
<AssistantAutomaticActionProgress label={t("automaticAffirmative")} />
|
const kind = getAssistantAskUserOptionKind(option);
|
||||||
) : (
|
const icon =
|
||||||
options.map((option, index) => {
|
kind === "affirmative" ? (
|
||||||
const kind = getAssistantAskUserOptionKind(option);
|
<IconCheck size={17} />
|
||||||
const icon =
|
) : kind === "negative" ? (
|
||||||
kind === "affirmative" ? (
|
<IconX size={17} />
|
||||||
<IconCheck size={17} />
|
) : (
|
||||||
) : kind === "negative" ? (
|
<IconArrowRight size={17} />
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
})
|
const color = kind === "affirmative" ? "green" : kind === "negative" ? "red" : "gray";
|
||||||
)}
|
|
||||||
|
|
||||||
{!autoConfirming &&
|
return (
|
||||||
args.allowOther !== false &&
|
<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 ? (
|
(showOther ? (
|
||||||
<Group align="flex-end" gap="xs" wrap="nowrap">
|
<Group align="flex-end" gap="xs" wrap="nowrap">
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -98,18 +98,18 @@
|
|||||||
container-type: size;
|
container-type: size;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 290;
|
z-index: 290;
|
||||||
inset-inline-start: 50%;
|
inset-block: 1rem;
|
||||||
bottom: 1rem;
|
inset-inline-end: 1rem;
|
||||||
|
inset-inline-start: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
width: min(46rem, calc(100vw - 2rem));
|
width: min(34rem, calc(100vw - 2rem));
|
||||||
height: min(36rem, calc(100dvh - 5rem));
|
height: auto;
|
||||||
min-height: min(22rem, calc(100dvh - 5rem));
|
min-height: min(30rem, calc(100dvh - 2rem));
|
||||||
margin: 0;
|
margin: 0;
|
||||||
transform: translateX(-50%);
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
border: 0;
|
border: 1px solid var(--mantine-color-default-border);
|
||||||
border-radius: var(--mantine-radius-lg);
|
border-radius: var(--mantine-radius-lg);
|
||||||
color: var(--mantine-color-text);
|
color: var(--mantine-color-text);
|
||||||
background: var(--mantine-color-body);
|
background: var(--mantine-color-body);
|
||||||
@@ -157,27 +157,28 @@
|
|||||||
border-bottom: 1px solid var(--mantine-color-default-border);
|
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 {
|
.panelActions {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
margin-inline-start: auto;
|
margin-inline-start: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.windowControls {
|
.newConversationButton {
|
||||||
flex: 0 0 auto;
|
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 {
|
.panelAction {
|
||||||
@@ -191,10 +192,6 @@
|
|||||||
padding: 0.45rem 0.5rem 0.45rem 0.65rem;
|
padding: 0.45rem 0.5rem 0.45rem 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composerHints {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.historyButton {
|
.historyButton {
|
||||||
width: 2.625rem;
|
width: 2.625rem;
|
||||||
max-width: 2.625rem;
|
max-width: 2.625rem;
|
||||||
@@ -210,6 +207,21 @@
|
|||||||
display: none;
|
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 {
|
.messages {
|
||||||
padding: 0.5rem 0.65rem 1rem;
|
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) {
|
@container assistant-widget (max-width: 22rem) {
|
||||||
.panelAction,
|
.panelAction,
|
||||||
.historyButton {
|
.historyButton,
|
||||||
|
.newConversationButton {
|
||||||
width: 2.25rem;
|
width: 2.25rem;
|
||||||
max-width: 2.25rem;
|
max-width: 2.25rem;
|
||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
@@ -268,7 +298,11 @@
|
|||||||
padding-inline: 0.15rem;
|
padding-inline: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composerContext > :last-child {
|
.composerContextLabel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelThreadTitle {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,17 +332,70 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.historyMenu {
|
.historyMenu {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
background: var(--mantine-color-body);
|
background: var(--mantine-color-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.historyMenu :global(.mantine-ScrollArea-content) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.historyItem {
|
.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);
|
border-radius: var(--mantine-radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.historyItem:hover {
|
.historyItem:hover,
|
||||||
|
.historyItem[data-active] {
|
||||||
background: var(--mantine-color-default-hover);
|
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 {
|
.thread {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -341,6 +428,11 @@
|
|||||||
margin-block: 0.9rem;
|
margin-block: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message[data-static] {
|
||||||
|
contain-intrinsic-size: auto 12rem;
|
||||||
|
content-visibility: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.userMessageWrap {
|
.userMessageWrap {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
max-width: 86%;
|
max-width: 86%;
|
||||||
@@ -706,24 +798,21 @@
|
|||||||
|
|
||||||
.pendingQuestionDock {
|
.pendingQuestionDock {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1 1 auto;
|
max-height: min(22rem, 48dvh);
|
||||||
|
flex: 0 1 auto;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
|
border-top: 1px solid var(--mantine-color-default-border);
|
||||||
background: var(--mantine-color-body);
|
background: var(--mantine-color-body);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread[data-pending-question] .viewport {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pendingQuestionTarget {
|
.pendingQuestionTarget {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100%;
|
|
||||||
max-width: 44rem;
|
max-width: 44rem;
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
align-items: safe center;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pendingQuestionTarget .humanTool {
|
.pendingQuestionTarget .humanTool {
|
||||||
@@ -1091,6 +1180,43 @@
|
|||||||
flex: 0 1 auto;
|
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 {
|
.userActions {
|
||||||
min-height: 1.75rem;
|
min-height: 1.75rem;
|
||||||
}
|
}
|
||||||
@@ -1173,13 +1299,13 @@
|
|||||||
|
|
||||||
.composerContext {
|
.composerContext {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 1.75rem;
|
width: auto;
|
||||||
height: 1.75rem;
|
height: 1.75rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0;
|
padding-inline: 0.3rem 0.42rem;
|
||||||
border: 1px solid var(--mantine-color-default-border);
|
border: 1px solid var(--mantine-color-default-border);
|
||||||
border-radius: 50%;
|
border-radius: var(--mantine-radius-sm);
|
||||||
color: var(--mantine-color-text);
|
color: var(--mantine-color-text);
|
||||||
background: var(--mantine-color-body);
|
background: var(--mantine-color-body);
|
||||||
}
|
}
|
||||||
@@ -1415,10 +1541,6 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composerHints {
|
|
||||||
padding-inline: 0.45rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.composerFooter {
|
.composerFooter {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -1641,12 +1763,12 @@
|
|||||||
@keyframes assistant-panel-in {
|
@keyframes assistant-panel-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(-50%, 0.75rem) scale(0.985);
|
transform: translateY(0.75rem) scale(0.985);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(-50%, 0) scale(1);
|
transform: translateY(0) scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1656,10 +1778,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.floatingPanel {
|
.floatingPanel {
|
||||||
bottom: max(0.5rem, env(safe-area-inset-bottom, 0px));
|
inset: 0;
|
||||||
width: calc(100vw - 1rem);
|
width: 100vw;
|
||||||
height: min(44rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px)));
|
height: 100dvh;
|
||||||
min-height: min(20rem, calc(100dvh - 1rem - env(safe-area-inset-bottom, 0px)));
|
min-height: 100dvh;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panelHeader {
|
.panelHeader {
|
||||||
@@ -1686,10 +1809,6 @@
|
|||||||
padding-inline: 0.1rem;
|
padding-inline: 0.1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composerHints {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.runtimeSelectorTrigger {
|
.runtimeSelectorTrigger {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
@@ -1705,6 +1824,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 30em) {
|
@media (max-width: 30em) {
|
||||||
|
.historyItemMenu {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.historyButton {
|
.historyButton {
|
||||||
width: 2.625rem;
|
width: 2.625rem;
|
||||||
max-width: 2.625rem;
|
max-width: 2.625rem;
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import type {
|
|||||||
import type { MessageStatus } from "@assistant-ui/react";
|
import type { MessageStatus } from "@assistant-ui/react";
|
||||||
import {
|
import {
|
||||||
ActionBarPrimitive,
|
ActionBarPrimitive,
|
||||||
|
ActionBarMorePrimitive,
|
||||||
|
AuiIf,
|
||||||
AttachmentPrimitive,
|
AttachmentPrimitive,
|
||||||
BranchPickerPrimitive,
|
BranchPickerPrimitive,
|
||||||
ChainOfThoughtByIndicesProvider,
|
ChainOfThoughtByIndicesProvider,
|
||||||
@@ -66,6 +68,7 @@ import {
|
|||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
@@ -91,6 +94,7 @@ import {
|
|||||||
IconChevronUp,
|
IconChevronUp,
|
||||||
IconCopy,
|
IconCopy,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
|
IconDotsVertical,
|
||||||
IconFile,
|
IconFile,
|
||||||
IconFileExport,
|
IconFileExport,
|
||||||
IconLink,
|
IconLink,
|
||||||
@@ -112,6 +116,8 @@ import {
|
|||||||
IconThumbUp,
|
IconThumbUp,
|
||||||
IconTool,
|
IconTool,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
|
IconVolume,
|
||||||
|
IconVolumeOff,
|
||||||
IconX,
|
IconX,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import remarkBreaks from "remark-breaks";
|
import remarkBreaks from "remark-breaks";
|
||||||
@@ -124,6 +130,7 @@ import { useTimeAgo } from "@homarr/common";
|
|||||||
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
|
import { assistantProviderIds, assistantProviderPresets, assistantReasoningModes } from "@homarr/definitions";
|
||||||
import type { AssistantProvider } from "@homarr/definitions";
|
import type { AssistantProvider } from "@homarr/definitions";
|
||||||
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
|
||||||
|
import { useConfirmModal } from "@homarr/modals";
|
||||||
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
|
||||||
|
|
||||||
import classes from "./assistant-panel.module.css";
|
import classes from "./assistant-panel.module.css";
|
||||||
@@ -184,6 +191,7 @@ interface AssistantPanelProps extends AssistantConversationControls {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDismissActivity: () => void;
|
onDismissActivity: () => void;
|
||||||
activityDismissed: boolean;
|
activityDismissed: boolean;
|
||||||
|
hasVisibleWidget: boolean;
|
||||||
isRunning: boolean;
|
isRunning: boolean;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
latestAssistantText: string;
|
latestAssistantText: string;
|
||||||
@@ -1107,63 +1115,43 @@ const downloadAssistantMarkdown = (markdown: string, filename: string) => {
|
|||||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 0);
|
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 AssistantMessageActions = () => {
|
||||||
const t = useScopedI18n("common.assistant");
|
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 (
|
return (
|
||||||
<Box className={classes.messageActions}>
|
<Box className={classes.messageActions}>
|
||||||
<ActionBarPrimitive.Root hideWhenRunning className={classes.messageActionBar}>
|
<ActionBarPrimitive.Root
|
||||||
<Group gap={2} wrap="wrap">
|
hideWhenRunning
|
||||||
|
autohide="not-last"
|
||||||
|
autohideFloat="single-branch"
|
||||||
|
className={classes.messageActionBar}
|
||||||
|
>
|
||||||
|
<Group gap={2} wrap="nowrap">
|
||||||
<ProviderMessageInfo />
|
<ProviderMessageInfo />
|
||||||
<Tooltip label={t("copy")}>
|
<Tooltip label={t("copy")}>
|
||||||
<ActionBarPrimitive.Copy asChild>
|
<ActionBarPrimitive.Copy asChild>
|
||||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("copy")}>
|
<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>
|
</ActionIcon>
|
||||||
</ActionBarPrimitive.Copy>
|
</ActionBarPrimitive.Copy>
|
||||||
</Tooltip>
|
</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")}>
|
<Tooltip label={t("regenerate")}>
|
||||||
<ActionBarPrimitive.Reload asChild>
|
<ActionBarPrimitive.Reload asChild>
|
||||||
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("regenerate")}>
|
<ActionIcon variant="subtle" color="gray" size="sm" aria-label={t("regenerate")}>
|
||||||
@@ -1171,6 +1159,50 @@ const AssistantMessageActions = () => {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</ActionBarPrimitive.Reload>
|
</ActionBarPrimitive.Reload>
|
||||||
</Tooltip>
|
</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>
|
</Group>
|
||||||
</ActionBarPrimitive.Root>
|
</ActionBarPrimitive.Root>
|
||||||
<BranchPicker />
|
<BranchPicker />
|
||||||
@@ -1181,7 +1213,7 @@ const AssistantMessageActions = () => {
|
|||||||
const UserMessageActions = () => {
|
const UserMessageActions = () => {
|
||||||
const t = useScopedI18n("common.assistant");
|
const t = useScopedI18n("common.assistant");
|
||||||
return (
|
return (
|
||||||
<ActionBarPrimitive.Root hideWhenRunning className={classes.userActions}>
|
<ActionBarPrimitive.Root hideWhenRunning autohide="not-last" className={classes.userActions}>
|
||||||
<Group gap={2}>
|
<Group gap={2}>
|
||||||
<Tooltip label={t("copyMessage")}>
|
<Tooltip label={t("copyMessage")}>
|
||||||
<ActionBarPrimitive.Copy asChild>
|
<ActionBarPrimitive.Copy asChild>
|
||||||
@@ -1225,7 +1257,7 @@ const UserMessage = () => {
|
|||||||
const isEditing = useAuiState((state) => state.composer.isEditing);
|
const isEditing = useAuiState((state) => state.composer.isEditing);
|
||||||
if (isEditing) return <EditComposer />;
|
if (isEditing) return <EditComposer />;
|
||||||
return (
|
return (
|
||||||
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`}>
|
<MessagePrimitive.Root className={`${classes.message} ${classes.userMessageWrap}`} data-static>
|
||||||
<Box className={classes.userMessage}>
|
<Box className={classes.userMessage}>
|
||||||
<MessagePrimitive.Quote>
|
<MessagePrimitive.Quote>
|
||||||
{(quote) => (
|
{(quote) => (
|
||||||
@@ -1732,12 +1764,17 @@ const ConversationContext = () => {
|
|||||||
aria-haspopup="dialog"
|
aria-haspopup="dialog"
|
||||||
onClick={() => setOpened((value) => !value)}
|
onClick={() => setOpened((value) => !value)}
|
||||||
>
|
>
|
||||||
<RingProgress
|
<Group gap={5} wrap="nowrap">
|
||||||
size={24}
|
<RingProgress
|
||||||
thickness={3}
|
size={22}
|
||||||
roundCaps
|
thickness={3}
|
||||||
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
|
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>
|
</UnstyledButton>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown className={classes.conversationContextPopover}>
|
<Popover.Dropdown className={classes.conversationContextPopover}>
|
||||||
@@ -1902,60 +1939,80 @@ const WebSearchActivity = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AssistantMessage = () => (
|
const AssistantMessage = () => {
|
||||||
<MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
|
const isComplete = useAuiState((state) => state.message.status?.type === "complete");
|
||||||
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty">
|
|
||||||
{({ part, children }) => {
|
return (
|
||||||
switch (part.type) {
|
<MessagePrimitive.Root
|
||||||
case "group-agent-trace": {
|
className={`${classes.message} ${classes.assistantMessage}`}
|
||||||
const startIndex = part.indices[0];
|
data-static={isComplete || undefined}
|
||||||
const endIndex = part.indices.at(-1);
|
>
|
||||||
if (startIndex === undefined || endIndex === undefined) return null;
|
<MessagePrimitive.GroupedParts groupBy={assistantMessageGroupBy} indicator="empty">
|
||||||
return (
|
{({ part, children }) => {
|
||||||
<ChainOfThoughtByIndicesProvider startIndex={startIndex} endIndex={endIndex}>
|
switch (part.type) {
|
||||||
<AssistantChainOfThought>{children}</AssistantChainOfThought>
|
case "group-agent-trace": {
|
||||||
</ChainOfThoughtByIndicesProvider>
|
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}</>;
|
</MessagePrimitive.GroupedParts>
|
||||||
case "group-tool":
|
<RuntimeError />
|
||||||
return <AgentTraceToolGroup>{children}</AgentTraceToolGroup>;
|
<WebSearchActivity />
|
||||||
case "indicator":
|
<AssistantMessageActions />
|
||||||
return <AssistantMessagePending status={{ type: "running" }} />;
|
</MessagePrimitive.Root>
|
||||||
case "text":
|
);
|
||||||
return <AssistantTextPart />;
|
};
|
||||||
case "reasoning":
|
|
||||||
return <ReasoningPart {...part} />;
|
const assistantThreadMessageComponents = { UserMessage, AssistantMessage };
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
const HistorySelectContext = createContext<() => void>(() => undefined);
|
const HistorySelectContext = createContext<() => void>(() => undefined);
|
||||||
|
|
||||||
const ThreadListItem = () => {
|
const ThreadListItem = () => {
|
||||||
const t = useScopedI18n("common.assistant");
|
const t = useScopedI18n("common.assistant");
|
||||||
|
const aui = useAui();
|
||||||
|
const { openConfirmModal } = useConfirmModal();
|
||||||
const onSelect = useContext(HistorySelectContext);
|
const onSelect = useContext(HistorySelectContext);
|
||||||
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
|
const remoteId = useAuiState((state) => state.threadListItem.remoteId);
|
||||||
const title = useAuiState((state) => state.threadListItem.title);
|
const title = useAuiState((state) => state.threadListItem.title);
|
||||||
const [exporting, setExporting] = useState(false);
|
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 () => {
|
const exportConversation = async () => {
|
||||||
if (!remoteId || exporting) return;
|
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 (
|
return (
|
||||||
<ThreadListItemPrimitive.Root className={classes.historyItem}>
|
<ThreadListItemPrimitive.Root className={classes.historyItem}>
|
||||||
<Group gap={4} wrap="nowrap">
|
<ThreadListItemPrimitive.Trigger asChild>
|
||||||
<ThreadListItemPrimitive.Trigger asChild>
|
<UnstyledButton className={classes.historyItemTrigger} title={title} onClick={onSelect}>
|
||||||
<Button
|
<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"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
size="compact-sm"
|
size="sm"
|
||||||
justify="flex-start"
|
title={t("renameConversation.action")}
|
||||||
flex={1}
|
aria-label={t("renameConversation.action")}
|
||||||
style={{ minWidth: 0 }}
|
onClick={() => {
|
||||||
onClick={onSelect}
|
setNextTitle(title ?? "");
|
||||||
|
setRenameOpened(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ThreadListItemPrimitive.Title fallback={t("newConversation")} />
|
<IconPencil size={14} />
|
||||||
</Button>
|
</ActionIcon>
|
||||||
</ThreadListItemPrimitive.Trigger>
|
|
||||||
<Tooltip label={t("exportConversation.action")}>
|
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
@@ -2001,24 +2144,49 @@ const ThreadListItem = () => {
|
|||||||
loading={exporting}
|
loading={exporting}
|
||||||
loaderProps={{ type: "bars" }}
|
loaderProps={{ type: "bars" }}
|
||||||
disabled={!remoteId}
|
disabled={!remoteId}
|
||||||
|
title={t("exportConversation.action")}
|
||||||
aria-label={t("exportConversation.action")}
|
aria-label={t("exportConversation.action")}
|
||||||
onClick={() => void exportConversation()}
|
onClick={() => void exportConversation()}
|
||||||
>
|
>
|
||||||
<IconFileExport size={14} />
|
<IconFileExport size={14} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
<ActionIcon
|
||||||
<ThreadListItemPrimitive.Delete asChild>
|
variant="subtle"
|
||||||
<ActionIcon variant="subtle" color="red" size="sm" aria-label={t("delete")}>
|
color="red"
|
||||||
|
size="sm"
|
||||||
|
title={t("delete")}
|
||||||
|
aria-label={t("delete")}
|
||||||
|
onClick={deleteConversation}
|
||||||
|
>
|
||||||
<IconTrash size={14} />
|
<IconTrash size={14} />
|
||||||
</ActionIcon>
|
</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>
|
</ThreadListItemPrimitive.Root>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
|
const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
|
||||||
const t = useScopedI18n("common.assistant");
|
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 (
|
return (
|
||||||
<HistorySelectContext.Provider value={onSelect}>
|
<HistorySelectContext.Provider value={onSelect}>
|
||||||
<Stack className={classes.historyMenu} gap="xs" p="xs">
|
<Stack className={classes.historyMenu} gap="xs" p="xs">
|
||||||
@@ -2035,9 +2203,28 @@ const ThreadHistory = ({ onSelect }: { onSelect: () => void }) => {
|
|||||||
{t("conversations")}
|
{t("conversations")}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</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}>
|
<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>
|
</Stack>
|
||||||
</ScrollArea.Autosize>
|
</ScrollArea.Autosize>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -2052,7 +2239,6 @@ const ConversationHistory = () => {
|
|||||||
<Popover
|
<Popover
|
||||||
opened={opened}
|
opened={opened}
|
||||||
onChange={setOpened}
|
onChange={setOpened}
|
||||||
onDismiss={() => setOpened(false)}
|
|
||||||
position="bottom-end"
|
position="bottom-end"
|
||||||
width="min(22rem, calc(100vw - 1rem))"
|
width="min(22rem, calc(100vw - 1rem))"
|
||||||
shadow="md"
|
shadow="md"
|
||||||
@@ -2191,7 +2377,7 @@ const EmptyThread = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Box className={classes.suggestions}>
|
<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
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
className={classes.suggestion}
|
className={classes.suggestion}
|
||||||
@@ -2200,17 +2386,17 @@ const EmptyThread = () => {
|
|||||||
{t("suggestions.health.label")}
|
{t("suggestions.health.label")}
|
||||||
</Button>
|
</Button>
|
||||||
</ThreadPrimitive.Suggestion>
|
</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} />}>
|
<Button variant="default" className={classes.suggestion} leftSection={<IconApps size={18} />}>
|
||||||
{t("suggestions.explore.label")}
|
{t("suggestions.explore.label")}
|
||||||
</Button>
|
</Button>
|
||||||
</ThreadPrimitive.Suggestion>
|
</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} />}>
|
<Button variant="default" className={classes.suggestion} leftSection={<IconSearch size={18} />}>
|
||||||
{t("suggestions.media.label")}
|
{t("suggestions.media.label")}
|
||||||
</Button>
|
</Button>
|
||||||
</ThreadPrimitive.Suggestion>
|
</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} />}>
|
<Button variant="default" className={classes.suggestion} leftSection={<IconPalette size={18} />}>
|
||||||
{t("suggestions.style.label")}
|
{t("suggestions.style.label")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2792,14 +2978,6 @@ const Composer = (props: ComposerProps) => {
|
|||||||
<HomarrProviderQuota />
|
<HomarrProviderQuota />
|
||||||
<ConversationContext />
|
<ConversationContext />
|
||||||
</Group>
|
</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>
|
</Group>
|
||||||
</ComposerPrimitive.Root>
|
</ComposerPrimitive.Root>
|
||||||
</ComposerPrimitive.AttachmentDropzone>
|
</ComposerPrimitive.AttachmentDropzone>
|
||||||
@@ -2979,9 +3157,30 @@ interface AssistantConversationSurfaceProps extends AssistantConversationControl
|
|||||||
pendingAction: AssistantPendingAction | undefined;
|
pendingAction: AssistantPendingAction | undefined;
|
||||||
onExpand?: () => void;
|
onExpand?: () => void;
|
||||||
onMinimize?: () => 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 = ({
|
export const AssistantConversationSurface = ({
|
||||||
isRunning,
|
isRunning,
|
||||||
pendingAction,
|
pendingAction,
|
||||||
@@ -2996,7 +3195,7 @@ export const AssistantConversationSurface = ({
|
|||||||
onReasoningChange,
|
onReasoningChange,
|
||||||
onExpand,
|
onExpand,
|
||||||
onMinimize,
|
onMinimize,
|
||||||
onWindowClose,
|
onDismiss,
|
||||||
}: AssistantConversationSurfaceProps) => {
|
}: AssistantConversationSurfaceProps) => {
|
||||||
const t = useScopedI18n("common.assistant");
|
const t = useScopedI18n("common.assistant");
|
||||||
const reducedMotion = useReducedMotion();
|
const reducedMotion = useReducedMotion();
|
||||||
@@ -3011,52 +3210,23 @@ export const AssistantConversationSurface = ({
|
|||||||
return (
|
return (
|
||||||
<AssistantDirectiveEntitiesProvider>
|
<AssistantDirectiveEntitiesProvider>
|
||||||
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
|
<Group className={classes.panelHeader} justify="space-between" wrap="nowrap" gap="xs">
|
||||||
{onWindowClose && onMinimize && (
|
<AssistantPanelIdentity />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
<Group className={classes.panelActions} gap={2} wrap="nowrap">
|
<Group className={classes.panelActions} gap={2} wrap="nowrap">
|
||||||
<ConversationHistory />
|
<ConversationHistory />
|
||||||
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
|
<ViewRefreshAction isRefreshing={isRefreshing} onRefresh={onRefresh} />
|
||||||
<AutoApprovalControl />
|
<AutoApprovalControl />
|
||||||
<Tooltip label={t("newConversation")}>
|
<Tooltip label={t("newConversation")}>
|
||||||
<ThreadListPrimitive.New asChild>
|
<ThreadListPrimitive.New asChild>
|
||||||
<ActionIcon
|
<Button
|
||||||
className={classes.panelAction}
|
className={classes.newConversationButton}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="gray"
|
color="gray"
|
||||||
|
size="compact-sm"
|
||||||
|
leftSection={<IconPlus size={17} />}
|
||||||
aria-label={t("newConversation")}
|
aria-label={t("newConversation")}
|
||||||
>
|
>
|
||||||
<IconPlus size={17} />
|
{t("newConversation")}
|
||||||
</ActionIcon>
|
</Button>
|
||||||
</ThreadListPrimitive.New>
|
</ThreadListPrimitive.New>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
{onExpand && (
|
{onExpand && (
|
||||||
@@ -3072,6 +3242,32 @@ export const AssistantConversationSurface = ({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</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>
|
||||||
</Group>
|
</Group>
|
||||||
<AssistantQuestionPortalProvider target={questionPortalTarget}>
|
<AssistantQuestionPortalProvider target={questionPortalTarget}>
|
||||||
@@ -3083,7 +3279,7 @@ export const AssistantConversationSurface = ({
|
|||||||
<ThreadPrimitive.Viewport className={classes.viewport} autoScroll>
|
<ThreadPrimitive.Viewport className={classes.viewport} autoScroll>
|
||||||
<Box className={classes.messages}>
|
<Box className={classes.messages}>
|
||||||
<EmptyThread />
|
<EmptyThread />
|
||||||
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
|
<ThreadPrimitive.Messages components={assistantThreadMessageComponents} />
|
||||||
</Box>
|
</Box>
|
||||||
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
|
<SelectionToolbarPrimitive.Root className={classes.selectionToolbar}>
|
||||||
<SelectionToolbarPrimitive.Quote asChild>
|
<SelectionToolbarPrimitive.Quote asChild>
|
||||||
@@ -3130,6 +3326,7 @@ export const AssistantPanel = ({
|
|||||||
onClose,
|
onClose,
|
||||||
onDismissActivity,
|
onDismissActivity,
|
||||||
activityDismissed,
|
activityDismissed,
|
||||||
|
hasVisibleWidget,
|
||||||
isRunning,
|
isRunning,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
latestAssistantText,
|
latestAssistantText,
|
||||||
@@ -3147,8 +3344,18 @@ export const AssistantPanel = ({
|
|||||||
onReasoningChange,
|
onReasoningChange,
|
||||||
}: AssistantPanelProps) => {
|
}: AssistantPanelProps) => {
|
||||||
const t = useScopedI18n("common.assistant");
|
const t = useScopedI18n("common.assistant");
|
||||||
|
const aui = useAui();
|
||||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||||
const panelRef = useRef<HTMLDialogElement>(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) => {
|
useWindowEvent("keydown", (event) => {
|
||||||
if (
|
if (
|
||||||
@@ -3160,7 +3367,7 @@ export const AssistantPanel = ({
|
|||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onClose();
|
minimize();
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!opened) {
|
if (!opened) {
|
||||||
@@ -3182,7 +3389,7 @@ export const AssistantPanel = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!opened && (
|
{!opened && !hasVisibleWidget && (
|
||||||
<AssistantActivityBar
|
<AssistantActivityBar
|
||||||
onOpen={onOpen}
|
onOpen={onOpen}
|
||||||
onDismissActivity={onDismissActivity}
|
onDismissActivity={onDismissActivity}
|
||||||
@@ -3210,8 +3417,9 @@ export const AssistantPanel = ({
|
|||||||
onModelChange={onModelChange}
|
onModelChange={onModelChange}
|
||||||
onReasoningChange={onReasoningChange}
|
onReasoningChange={onReasoningChange}
|
||||||
autoFocusComposer
|
autoFocusComposer
|
||||||
onMinimize={onClose}
|
onMinimize={minimize}
|
||||||
onWindowClose={() => {
|
onDismiss={() => {
|
||||||
|
stopSpeechWithoutSurface();
|
||||||
onDismissActivity();
|
onDismissActivity();
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -7,11 +7,19 @@ import type {
|
|||||||
AttachmentAdapter,
|
AttachmentAdapter,
|
||||||
MessageFormatAdapter,
|
MessageFormatAdapter,
|
||||||
RemoteThreadListAdapter,
|
RemoteThreadListAdapter,
|
||||||
|
SpeechSynthesisAdapter,
|
||||||
ThreadHistoryAdapter,
|
ThreadHistoryAdapter,
|
||||||
ThreadMessage,
|
ThreadMessage,
|
||||||
Toolkit,
|
Toolkit,
|
||||||
} from "@assistant-ui/react";
|
} 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 { useChat } from "@ai-sdk/react";
|
||||||
import { AssistantChatTransport, useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
|
import { AssistantChatTransport, useAISDKRuntime } from "@assistant-ui/react-ai-sdk";
|
||||||
import { useHotkeys } from "@mantine/hooks";
|
import { useHotkeys } from "@mantine/hooks";
|
||||||
@@ -41,7 +49,8 @@ import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assi
|
|||||||
import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
|
import { resolveAssistantPreferenceModelId, resolveAssistantThreadPreferenceModelId } from "./assistant-preferences";
|
||||||
import { assistantAiSdkRuntimeOptions } from "./assistant-runtime-options";
|
import { assistantAiSdkRuntimeOptions } from "./assistant-runtime-options";
|
||||||
import {
|
import {
|
||||||
AssistantComposerSurfaceProvider,
|
AssistantComposerRuntimeProvider,
|
||||||
|
AssistantComposerSurfaceBoundary,
|
||||||
AssistantRunFocusPreserver,
|
AssistantRunFocusPreserver,
|
||||||
AssistantRuntimeProviderWithTools,
|
AssistantRuntimeProviderWithTools,
|
||||||
} from "./assistant-runtime-provider";
|
} from "./assistant-runtime-provider";
|
||||||
@@ -475,6 +484,12 @@ const AssistantThreadRuntime = () => {
|
|||||||
),
|
),
|
||||||
[selectedModel],
|
[selectedModel],
|
||||||
);
|
);
|
||||||
|
const [speech, setSpeech] = useState<SpeechSynthesisAdapter>();
|
||||||
|
useEffect(() => {
|
||||||
|
if ("speechSynthesis" in window && typeof SpeechSynthesisUtterance !== "undefined") {
|
||||||
|
setSpeech(new WebSpeechSynthesisAdapter());
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
const feedback = useMemo(
|
const feedback = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => {
|
submit: ({ message, type }: { message: ThreadMessage; type: "positive" | "negative" }) => {
|
||||||
@@ -512,6 +527,7 @@ const AssistantThreadRuntime = () => {
|
|||||||
history,
|
history,
|
||||||
attachments,
|
attachments,
|
||||||
feedback,
|
feedback,
|
||||||
|
speech,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
transport.setRuntime(runtime);
|
transport.setRuntime(runtime);
|
||||||
@@ -667,6 +683,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
|||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
const [activityDismissed, setActivityDismissed] = useState(true);
|
const [activityDismissed, setActivityDismissed] = useState(true);
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const [visibleWidgetIds, setVisibleWidgetIds] = useState<Set<string>>(() => new Set());
|
||||||
const [queuedPrompt, setQueuedPrompt] = useState<string | null>(null);
|
const [queuedPrompt, setQueuedPrompt] = useState<string | null>(null);
|
||||||
const aui = useAui();
|
const aui = useAui();
|
||||||
const preferences = useAssistantPreferences();
|
const preferences = useAssistantPreferences();
|
||||||
@@ -714,6 +731,21 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
|||||||
setOpened(true);
|
setOpened(true);
|
||||||
}, [markRead]);
|
}, [markRead]);
|
||||||
const close = useCallback(() => setOpened(false), []);
|
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(() => {
|
const toggle = useCallback(() => {
|
||||||
if (opened) close();
|
if (opened) close();
|
||||||
else open();
|
else open();
|
||||||
@@ -835,6 +867,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
|||||||
}, [assistantIsRunning, latestStatus?.type, providerUnavailable, sendPrompt, t]);
|
}, [assistantIsRunning, latestStatus?.type, providerUnavailable, sendPrompt, t]);
|
||||||
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
|
useRegisterSpotlightContextResults("homarr-assistant", [spotlightItem], [spotlightItem]);
|
||||||
|
|
||||||
|
const activeWidgetId = visibleWidgetIds.values().next().value ?? null;
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -843,46 +876,67 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
|||||||
isRunning: assistantIsRunning,
|
isRunning: assistantIsRunning,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
|
hasVisibleWidget: visibleWidgetIds.size > 0,
|
||||||
|
activeWidgetId,
|
||||||
open,
|
open,
|
||||||
close,
|
close,
|
||||||
toggle,
|
toggle,
|
||||||
sendPrompt,
|
sendPrompt,
|
||||||
refreshCurrentView,
|
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 (
|
return (
|
||||||
<AssistantAutoApprovalProvider conversationId={conversationId}>
|
<AssistantAutoApprovalProvider conversationId={conversationId}>
|
||||||
<AssistantContext.Provider value={value}>
|
<AssistantContext.Provider value={value}>
|
||||||
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
|
<AssistantComposerRuntimeProvider>
|
||||||
<AssistantComposerSurfaceProvider surfaceId="floating-panel">
|
<AssistantWidgetRendererProvider renderer={AssistantBoardWidget}>{children}</AssistantWidgetRendererProvider>
|
||||||
<AssistantPanel
|
<AssistantComposerSurfaceBoundary surfaceId="assistant-panel">
|
||||||
opened={opened}
|
<AssistantPanel
|
||||||
onOpen={open}
|
opened={opened}
|
||||||
onClose={close}
|
onOpen={open}
|
||||||
onDismissActivity={() => {
|
onClose={close}
|
||||||
markRead();
|
onDismissActivity={() => {
|
||||||
setActivityDismissed(true);
|
markRead();
|
||||||
}}
|
setActivityDismissed(true);
|
||||||
activityDismissed={activityDismissed}
|
}}
|
||||||
isRunning={assistantIsRunning}
|
activityDismissed={activityDismissed}
|
||||||
unreadCount={unreadCount}
|
hasVisibleWidget={visibleWidgetIds.size > 0}
|
||||||
latestAssistantText={latestAssistantText}
|
isRunning={assistantIsRunning}
|
||||||
latestAssistantPartType={latestAssistantPartType}
|
unreadCount={unreadCount}
|
||||||
latestUserText={queuedPrompt ?? latestUserText}
|
latestAssistantText={latestAssistantText}
|
||||||
latestStatus={latestStatus}
|
latestAssistantPartType={latestAssistantPartType}
|
||||||
pendingAction={pendingAction}
|
latestUserText={queuedPrompt ?? latestUserText}
|
||||||
modelId={preferences.modelId}
|
latestStatus={latestStatus}
|
||||||
models={preferences.models}
|
pendingAction={pendingAction}
|
||||||
modelOptionsLoading={preferences.isLoading}
|
modelId={preferences.modelId}
|
||||||
reasoning={preferences.reasoning}
|
models={preferences.models}
|
||||||
isRefreshing={isRefreshing}
|
modelOptionsLoading={preferences.isLoading}
|
||||||
onRefresh={refreshCurrentView}
|
reasoning={preferences.reasoning}
|
||||||
onModelChange={selectModel}
|
isRefreshing={isRefreshing}
|
||||||
onReasoningChange={preferences.setReasoning}
|
onRefresh={refreshCurrentView}
|
||||||
/>
|
onModelChange={selectModel}
|
||||||
</AssistantComposerSurfaceProvider>
|
onReasoningChange={preferences.setReasoning}
|
||||||
|
/>
|
||||||
|
</AssistantComposerSurfaceBoundary>
|
||||||
|
</AssistantComposerRuntimeProvider>
|
||||||
</AssistantContext.Provider>
|
</AssistantContext.Provider>
|
||||||
</AssistantAutoApprovalProvider>
|
</AssistantAutoApprovalProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import { z } from "zod/v4";
|
|||||||
import { showErrorNotification } from "@homarr/notifications";
|
import { showErrorNotification } from "@homarr/notifications";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AssistantComposerSurfaceProvider,
|
AssistantComposerRuntimeProvider,
|
||||||
|
AssistantComposerSurfaceBoundary,
|
||||||
AssistantRunFocusPreserver,
|
AssistantRunFocusPreserver,
|
||||||
AssistantRuntimeProviderWithTools,
|
AssistantRuntimeProviderWithTools,
|
||||||
assistantSurfaceComposerCacheLimit,
|
assistantSurfaceComposerCacheLimit,
|
||||||
@@ -148,14 +149,22 @@ const SurfaceTestAssistant = ({
|
|||||||
{ runtime, toolkit },
|
{ runtime, toolkit },
|
||||||
createElement(AssistantRunFocusPreserver),
|
createElement(AssistantRunFocusPreserver),
|
||||||
createElement(
|
createElement(
|
||||||
AssistantComposerSurfaceProvider,
|
AssistantComposerRuntimeProvider,
|
||||||
{ surfaceId: "surface-a" },
|
null,
|
||||||
createElement(SurfaceProbe, { id: "a", onReady }),
|
createElement(
|
||||||
|
AssistantComposerSurfaceBoundary,
|
||||||
|
{ surfaceId: "surface-a" },
|
||||||
|
createElement(SurfaceProbe, { id: "a", onReady }),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
createElement(
|
createElement(
|
||||||
AssistantComposerSurfaceProvider,
|
AssistantComposerRuntimeProvider,
|
||||||
{ surfaceId: "surface-b" },
|
null,
|
||||||
createElement(SurfaceProbe, { id: "b", onReady }),
|
createElement(
|
||||||
|
AssistantComposerSurfaceBoundary,
|
||||||
|
{ surfaceId: "surface-b" },
|
||||||
|
createElement(SurfaceProbe, { id: "b", onReady }),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
simulateRunFocus ? createElement(LexicalRunFocusProbe) : null,
|
simulateRunFocus ? createElement(LexicalRunFocusProbe) : null,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -157,9 +157,6 @@ const createSurfaceComposerHost = (
|
|||||||
get attachments() {
|
get attachments() {
|
||||||
return getThreadAdapters(threadCore)?.attachments ? attachments : undefined;
|
return getThreadAdapters(threadCore)?.attachments ? attachments : undefined;
|
||||||
},
|
},
|
||||||
get dictation() {
|
|
||||||
return getThreadAdapters(threadCore)?.dictation;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Proxy(threadCore as ThreadCoreWithAdapters, {
|
return new Proxy(threadCore as ThreadCoreWithAdapters, {
|
||||||
@@ -294,18 +291,21 @@ export const createAssistantSurfaceRuntime = (runtime: AssistantRuntime): Assist
|
|||||||
subscribe(callback) {
|
subscribe(callback) {
|
||||||
let activeCore = sharedBinding.getState();
|
let activeCore = sharedBinding.getState();
|
||||||
let activeConversationId = getConversationId();
|
let activeConversationId = getConversationId();
|
||||||
|
let activeComposer = getComposer(activeConversationId, activeCore);
|
||||||
retainConversation(activeConversationId);
|
retainConversation(activeConversationId);
|
||||||
let unsubscribeComposer = getComposer(activeConversationId, activeCore).subscribe(callback);
|
let unsubscribeComposer = activeComposer.subscribe(callback);
|
||||||
const handleSharedUpdate = () => {
|
const handleSharedUpdate = () => {
|
||||||
const nextCore = sharedBinding.getState();
|
const nextCore = sharedBinding.getState();
|
||||||
const nextConversationId = getConversationId();
|
const nextConversationId = getConversationId();
|
||||||
if (nextCore !== activeCore || nextConversationId !== activeConversationId) {
|
if (nextCore !== activeCore || nextConversationId !== activeConversationId) {
|
||||||
|
if (activeCore.speech !== undefined) activeCore.stopSpeaking();
|
||||||
unsubscribeComposer();
|
unsubscribeComposer();
|
||||||
releaseConversation(activeConversationId);
|
releaseConversation(activeConversationId);
|
||||||
activeCore = nextCore;
|
activeCore = nextCore;
|
||||||
activeConversationId = nextConversationId;
|
activeConversationId = nextConversationId;
|
||||||
retainConversation(activeConversationId);
|
retainConversation(activeConversationId);
|
||||||
unsubscribeComposer = getComposer(activeConversationId, activeCore).subscribe(callback);
|
activeComposer = getComposer(activeConversationId, activeCore);
|
||||||
|
unsubscribeComposer = activeComposer.subscribe(callback);
|
||||||
}
|
}
|
||||||
callback();
|
callback();
|
||||||
};
|
};
|
||||||
@@ -360,6 +360,13 @@ export const createAssistantSurfaceRuntime = (runtime: AssistantRuntime): Assist
|
|||||||
registerModelContextProvider: () => () => {},
|
registerModelContextProvider: () => () => {},
|
||||||
dispose() {
|
dispose() {
|
||||||
activeConversationCounts.clear();
|
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);
|
for (const [conversationId, entry] of composerByConversation) disposeComposer(conversationId, entry);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -373,7 +380,7 @@ const AssistantComposerSurfaceContext = createContext<AssistantComposerSurfaceVa
|
|||||||
|
|
||||||
export const useAssistantComposerSurface = () => {
|
export const useAssistantComposerSurface = () => {
|
||||||
const value = useContext(AssistantComposerSurfaceContext);
|
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;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -437,21 +444,23 @@ const AssistantComposerSurfaceEvents = () => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => {
|
export const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChildren<{ surfaceId: string }>) => {
|
||||||
const t = useScopedI18n("common.assistant");
|
const t = useScopedI18n("common.assistant");
|
||||||
const aui = useAui();
|
const aui = useAui();
|
||||||
|
const instanceId = useId();
|
||||||
|
const resolvedSurfaceId = `${surfaceId}:${instanceId}`;
|
||||||
const elementRef = useRef<HTMLDivElement>(null);
|
const elementRef = useRef<HTMLDivElement>(null);
|
||||||
const contextValue = useMemo(() => ({ id: surfaceId }), [surfaceId]);
|
const contextValue = useMemo(() => ({ id: resolvedSurfaceId }), [resolvedSurfaceId]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
const element = elementRef.current;
|
const element = elementRef.current;
|
||||||
if (!element) return undefined;
|
if (!element) return undefined;
|
||||||
surfaceElements.set(surfaceId, element);
|
surfaceElements.set(resolvedSurfaceId, element);
|
||||||
|
|
||||||
return () => {
|
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 handlePasteCapture = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
@@ -507,7 +516,6 @@ const AssistantComposerSurfaceBoundary = ({ children, surfaceId }: PropsWithChil
|
|||||||
}}
|
}}
|
||||||
onPasteCapture={handlePasteCapture}
|
onPasteCapture={handlePasteCapture}
|
||||||
>
|
>
|
||||||
<AssistantComposerSurfaceEvents />
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</AssistantComposerSurfaceContext.Provider>
|
</AssistantComposerSurfaceContext.Provider>
|
||||||
@@ -566,18 +574,12 @@ export const AssistantRunFocusPreserver = () => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AssistantComposerSurfaceProviderProps extends PropsWithChildren {
|
|
||||||
surfaceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gives one rendered assistant surface a local composer while preserving the shared conversation.
|
* Gives every rendered assistant surface one shared local composer while preserving the shared
|
||||||
* Every component below this boundary can keep using the normal assistant-ui primitives and hooks.
|
* conversation. Individual surfaces should add AssistantComposerSurfaceBoundary around their UI.
|
||||||
*/
|
*/
|
||||||
export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: AssistantComposerSurfaceProviderProps) => {
|
export const AssistantComposerRuntimeProvider = ({ children }: PropsWithChildren) => {
|
||||||
const parentAui = useAui();
|
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.
|
// 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 sharedRuntime = parentAui.threads().__internal_getAssistantRuntime?.();
|
||||||
const surfaceRuntime = useMemo(() => {
|
const surfaceRuntime = useMemo(() => {
|
||||||
@@ -588,7 +590,8 @@ export const AssistantComposerSurfaceProvider = ({ children, surfaceId }: Assist
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AssistantRuntimeProvider aui={parentAui} runtime={surfaceRuntime}>
|
<AssistantRuntimeProvider aui={parentAui} runtime={surfaceRuntime}>
|
||||||
<AssistantComposerSurfaceBoundary surfaceId={resolvedSurfaceId}>{children}</AssistantComposerSurfaceBoundary>
|
<AssistantComposerSurfaceEvents />
|
||||||
|
{children}
|
||||||
</AssistantRuntimeProvider>
|
</AssistantRuntimeProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
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 { 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 { useAui, useAuiState } from "@assistant-ui/react";
|
||||||
|
|
||||||
import { useI18n } from "@homarr/translation/client";
|
import { useI18n } from "@homarr/translation/client";
|
||||||
@@ -13,23 +14,47 @@ import classes from "./assistant-panel.module.css";
|
|||||||
import { AssistantConversationSurface } from "./assistant-panel";
|
import { AssistantConversationSurface } from "./assistant-panel";
|
||||||
import { getPendingAssistantAction } from "./assistant-pending-action";
|
import { getPendingAssistantAction } from "./assistant-pending-action";
|
||||||
import { useAssistantPreferences, useHomarrAssistant } from "./assistant-context";
|
import { useAssistantPreferences, useHomarrAssistant } from "./assistant-context";
|
||||||
import { AssistantComposerSurfaceProvider } from "./assistant-runtime-provider";
|
import { AssistantComposerSurfaceBoundary } from "./assistant-runtime-provider";
|
||||||
|
|
||||||
export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => {
|
export const AssistantBoardWidget = (props: WidgetComponentProps<"assistant">) => {
|
||||||
const assistant = useHomarrAssistant();
|
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) {
|
if (!assistant.enabled) {
|
||||||
return <UnavailableAssistantWidget description={assistant.unavailableDescription} />;
|
return <UnavailableAssistantWidget description={assistant.unavailableDescription} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AssistantComposerSurfaceProvider surfaceId={`board-widget-${props.itemId ?? "preview"}`}>
|
<AssistantComposerSurfaceBoundary surfaceId={`assistant-widget-${widgetId}`}>
|
||||||
<EnabledAssistantBoardWidget {...props} />
|
<Box ref={widgetRef} w="100%" h="100%">
|
||||||
</AssistantComposerSurfaceProvider>
|
<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 board = useRequiredBoard();
|
||||||
const assistant = useHomarrAssistant();
|
const assistant = useHomarrAssistant();
|
||||||
const preferences = useAssistantPreferences();
|
const preferences = useAssistantPreferences();
|
||||||
@@ -45,6 +70,14 @@ const EnabledAssistantBoardWidget = ({ width, height, isEditMode }: WidgetCompon
|
|||||||
threadListItem.updateCustom({ ...threadListItem.getState().custom, modelId });
|
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) {
|
if (width < 300 || height < 280) {
|
||||||
return <CompactAssistantWidget onOpen={assistant.open} />;
|
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();
|
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 (
|
return (
|
||||||
<Box className={classes.widgetPanel}>
|
<Box className={classes.widgetPanel}>
|
||||||
@@ -87,23 +142,16 @@ const CompactAssistantWidget = ({ onOpen }: { onOpen: () => void }) => {
|
|||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Stack className={classes.compactCopy} gap={1}>
|
<Stack className={classes.compactCopy} gap={1}>
|
||||||
<Text className={classes.compactTitle} size="sm" fw={700} lineClamp={1}>
|
<Text className={classes.compactTitle} size="sm" fw={700} lineClamp={1}>
|
||||||
{t("widget.assistant.compact.title")}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className={classes.compactDescription} size="xs" c="dimmed" lineClamp={2}>
|
<Text className={classes.compactDescription} size="xs" c="dimmed" lineClamp={2}>
|
||||||
{t("widget.assistant.compact.description")}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
<Tooltip label={t("widget.assistant.compact.open")}>
|
<Tooltip label={actionLabel}>
|
||||||
<ActionIcon
|
<ActionIcon variant="light" color="red" size={44} radius="md" onClick={onOpen} aria-label={actionLabel}>
|
||||||
variant="light"
|
{actionIcon}
|
||||||
color="red"
|
|
||||||
size={44}
|
|
||||||
radius="md"
|
|
||||||
onClick={onOpen}
|
|
||||||
aria-label={t("widget.assistant.compact.open")}
|
|
||||||
>
|
|
||||||
<IconArrowsMaximize size={18} />
|
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -2309,7 +2309,6 @@
|
|||||||
"open": "Open Homarr Assistant",
|
"open": "Open Homarr Assistant",
|
||||||
"close": "Close assistant",
|
"close": "Close assistant",
|
||||||
"minimize": "Minimize assistant",
|
"minimize": "Minimize assistant",
|
||||||
"windowControls": "Conversation window controls",
|
|
||||||
"spotlight": "Ask Homarr Assistant",
|
"spotlight": "Ask Homarr Assistant",
|
||||||
"spotlightDescription": "Type and send a prompt without leaving the command menu.",
|
"spotlightDescription": "Type and send a prompt without leaving the command menu.",
|
||||||
"spotlightPrompt": {
|
"spotlightPrompt": {
|
||||||
@@ -2327,6 +2326,24 @@
|
|||||||
"newConversation": "New conversation",
|
"newConversation": "New conversation",
|
||||||
"conversations": "Conversations",
|
"conversations": "Conversations",
|
||||||
"delete": "Delete",
|
"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",
|
"closeHistory": "Close conversation history",
|
||||||
"openHistory": "Open conversation history",
|
"openHistory": "Open conversation history",
|
||||||
"emptyTitle": "What can I help with?",
|
"emptyTitle": "What can I help with?",
|
||||||
@@ -2399,6 +2416,8 @@
|
|||||||
"regenerate": "Regenerate response",
|
"regenerate": "Regenerate response",
|
||||||
"helpful": "Helpful",
|
"helpful": "Helpful",
|
||||||
"notHelpful": "Not helpful",
|
"notHelpful": "Not helpful",
|
||||||
|
"readAloud": "Read response aloud",
|
||||||
|
"stopReading": "Stop reading response",
|
||||||
"feedbackError": "Your feedback could not be saved. Try again.",
|
"feedbackError": "Your feedback could not be saved. Try again.",
|
||||||
"exportMarkdown": "Export as Markdown",
|
"exportMarkdown": "Export as Markdown",
|
||||||
"exportConversation": {
|
"exportConversation": {
|
||||||
@@ -2427,7 +2446,6 @@
|
|||||||
},
|
},
|
||||||
"mentions": {
|
"mentions": {
|
||||||
"menu": "Add Homarr context",
|
"menu": "Add Homarr context",
|
||||||
"hint": "Type @ to add context",
|
|
||||||
"context": "Context",
|
"context": "Context",
|
||||||
"app": "Apps",
|
"app": "Apps",
|
||||||
"integration": "Integrations",
|
"integration": "Integrations",
|
||||||
@@ -2442,7 +2460,6 @@
|
|||||||
},
|
},
|
||||||
"commands": {
|
"commands": {
|
||||||
"menu": "Assistant commands",
|
"menu": "Assistant commands",
|
||||||
"hint": "Type / for shortcuts",
|
|
||||||
"health": "Prepare a service-health check",
|
"health": "Prepare a service-health check",
|
||||||
"explore": "Prepare an overview of this Homarr instance",
|
"explore": "Prepare an overview of this Homarr instance",
|
||||||
"media": "Prepare a media search",
|
"media": "Prepare a media search",
|
||||||
@@ -2555,7 +2572,6 @@
|
|||||||
"otherPlaceholder": "Type another answer…",
|
"otherPlaceholder": "Type another answer…",
|
||||||
"submit": "Send answer",
|
"submit": "Send answer",
|
||||||
"cancelOther": "Cancel other answer",
|
"cancelOther": "Cancel other answer",
|
||||||
"automaticAffirmative": "Choosing the affirmative answer automatically",
|
|
||||||
"optionKind": {
|
"optionKind": {
|
||||||
"affirmative": "Confirm",
|
"affirmative": "Confirm",
|
||||||
"negative": "Decline",
|
"negative": "Decline",
|
||||||
@@ -2630,7 +2646,7 @@
|
|||||||
"autoApproval": {
|
"autoApproval": {
|
||||||
"label": "Automatic approvals",
|
"label": "Automatic approvals",
|
||||||
"title": "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",
|
"enable": "Enable for this conversation",
|
||||||
"disable": "Turn off automatic approvals",
|
"disable": "Turn off automatic approvals",
|
||||||
"enabled": "Automatic approvals are on",
|
"enabled": "Automatic approvals are on",
|
||||||
@@ -5744,7 +5760,13 @@
|
|||||||
"compact": {
|
"compact": {
|
||||||
"title": "Assistant",
|
"title": "Assistant",
|
||||||
"description": "Resize this widget to chat here, or open the full conversation.",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user