feat: improve assistant thinking states
This commit is contained in:
1 parent
ebd63bea74
commit
59cb3a4d99
9 files changed
+542
-23
No files matched your search
@@ -151,7 +151,9 @@ from minimal through high; explicit levels are sent using the AI SDK's provider-
|
||||
still depend on support from the chosen model and endpoint. Reasoning text is shown live when the provider
|
||||
streams it through assistant-ui's compact chain-of-thought disclosure. The **Thinking** disclosure renders
|
||||
Markdown, can be opened or closed without interrupting the response, and keeps the user's collapsed state when
|
||||
another message starts.
|
||||
another message starts. A compact dot matrix distinguishes an initial thinking pass from response streaming and
|
||||
settles into waiting, success, or error states in the minimized activity bar. The state always keeps its text label,
|
||||
and the animation is disabled when the operating system requests reduced motion.
|
||||
|
||||
A compact context control beside the model selector gives a quick glance at cumulative conversation tokens and
|
||||
cost. Its popover keeps the latest complete context reading while a new response starts and lists every request
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getAssistantActivityState, getRunningAssistantPartType } from "./assistant-activity-state";
|
||||
|
||||
describe("getAssistantActivityState", () => {
|
||||
test("does not treat output from the previous completed response as current streaming", () => {
|
||||
expect(getRunningAssistantPartType("complete", "text")).toBeUndefined();
|
||||
expect(getRunningAssistantPartType("running", "text")).toBe("text");
|
||||
});
|
||||
|
||||
test("distinguishes thinking from a response that is already streaming", () => {
|
||||
expect(
|
||||
getAssistantActivityState({ isRunning: true, latestPartType: undefined, needsApproval: false, failed: false }),
|
||||
).toBe("thinking");
|
||||
expect(
|
||||
getAssistantActivityState({ isRunning: true, latestPartType: "reasoning", needsApproval: false, failed: false }),
|
||||
).toBe("thinking");
|
||||
expect(
|
||||
getAssistantActivityState({ isRunning: true, latestPartType: "text", needsApproval: false, failed: false }),
|
||||
).toBe("streaming");
|
||||
});
|
||||
|
||||
test("shows a waiting pattern while a tool is running", () => {
|
||||
expect(
|
||||
getAssistantActivityState({ isRunning: true, latestPartType: "tool-call", needsApproval: false, failed: false }),
|
||||
).toBe("waiting");
|
||||
});
|
||||
|
||||
test("maps settled states to waiting, failure, and success glyphs", () => {
|
||||
expect(
|
||||
getAssistantActivityState({ isRunning: false, latestPartType: "text", needsApproval: true, failed: false }),
|
||||
).toBe("waiting");
|
||||
expect(
|
||||
getAssistantActivityState({ isRunning: false, latestPartType: "text", needsApproval: false, failed: true }),
|
||||
).toBe("error");
|
||||
expect(
|
||||
getAssistantActivityState({ isRunning: false, latestPartType: "text", needsApproval: false, failed: false }),
|
||||
).toBe("success");
|
||||
});
|
||||
|
||||
test("keeps a required action visible even if the last run also failed", () => {
|
||||
expect(
|
||||
getAssistantActivityState({ isRunning: false, latestPartType: "text", needsApproval: true, failed: true }),
|
||||
).toBe("waiting");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { AssistantDotMatrixState } from "./assistant-dot-matrix";
|
||||
|
||||
interface AssistantActivityStateInput {
|
||||
isRunning: boolean;
|
||||
latestPartType: string | undefined;
|
||||
needsApproval: boolean;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
export const getRunningAssistantPartType = (
|
||||
messageStatusType: string | undefined,
|
||||
latestPartType: string | undefined,
|
||||
) => (messageStatusType === "running" ? latestPartType : undefined);
|
||||
|
||||
export const getAssistantActivityState = ({
|
||||
isRunning,
|
||||
latestPartType,
|
||||
needsApproval,
|
||||
failed,
|
||||
}: AssistantActivityStateInput): AssistantDotMatrixState => {
|
||||
if (isRunning) {
|
||||
if (latestPartType === "tool-call") return "waiting";
|
||||
if (latestPartType === undefined || latestPartType === "reasoning") return "thinking";
|
||||
return "streaming";
|
||||
}
|
||||
if (needsApproval) return "waiting";
|
||||
if (failed) return "error";
|
||||
return "success";
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
@property --assistant-dot-matrix-hi {
|
||||
syntax: "<number>";
|
||||
inherits: false;
|
||||
initial-value: 1;
|
||||
}
|
||||
|
||||
@property --assistant-dot-matrix-lo {
|
||||
syntax: "<number>";
|
||||
inherits: false;
|
||||
initial-value: 0.15;
|
||||
}
|
||||
|
||||
.root {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex: 0 0 auto;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.root[data-state="idle"],
|
||||
.root[data-state="paused"],
|
||||
.root[data-state="stopped"],
|
||||
.root[data-state="offline"] {
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.root[data-state="recording"],
|
||||
.root[data-state="error"] {
|
||||
color: var(--mantine-color-red-filled);
|
||||
}
|
||||
|
||||
.root[data-state="success"] {
|
||||
color: var(--mantine-color-green-filled);
|
||||
}
|
||||
|
||||
.root[data-state="warning"] {
|
||||
color: var(--mantine-color-yellow-filled);
|
||||
}
|
||||
|
||||
.root[data-state="info"] {
|
||||
color: var(--mantine-color-blue-filled);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dot {
|
||||
transition-duration: 300ms;
|
||||
transition-property: --assistant-dot-matrix-hi, --assistant-dot-matrix-lo, opacity;
|
||||
animation-name: assistant-dot-matrix-blink;
|
||||
animation-timing-function: ease-in-out;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: -1px;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@keyframes assistant-dot-matrix-blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: var(--assistant-dot-matrix-hi, 1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: var(--assistant-dot-matrix-lo, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.dot {
|
||||
transition-duration: 0ms;
|
||||
animation-name: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { AssistantDotMatrix, assistantDotMatrixStates } from "./assistant-dot-matrix";
|
||||
|
||||
describe("AssistantDotMatrix", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: ReturnType<typeof createRoot>;
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
root = createRoot(host);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
test("renders an accessible deterministic 5 by 5 loading matrix", async () => {
|
||||
await act(async () => root.render(createElement(AssistantDotMatrix, { label: "Loading response" })));
|
||||
|
||||
const matrix = host.querySelector<HTMLElement>("[data-slot='assistant-dot-matrix']");
|
||||
expect(matrix?.dataset.state).toBe("loading");
|
||||
expect(matrix?.tagName).toBe("OUTPUT");
|
||||
expect(matrix?.textContent).toBe("Loading response");
|
||||
expect(matrix?.querySelector("svg")?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(matrix?.querySelectorAll("[data-slot='assistant-dot-matrix-dot']")).toHaveLength(25);
|
||||
});
|
||||
|
||||
test("switches from the thinking wave to the streaming wave without changing its structure", async () => {
|
||||
await act(async () => root.render(createElement(AssistantDotMatrix, { state: "thinking", label: "Thinking" })));
|
||||
const matrix = host.querySelector<HTMLElement>("[data-slot='assistant-dot-matrix']");
|
||||
const firstDot = matrix?.querySelector<SVGCircleElement>("[data-slot='assistant-dot-matrix-dot']");
|
||||
expect(matrix?.dataset.state).toBe("thinking");
|
||||
expect(firstDot?.style.animationDuration).toBe("1.2s");
|
||||
|
||||
await act(async () =>
|
||||
root.render(createElement(AssistantDotMatrix, { state: "streaming", label: "Writing response" })),
|
||||
);
|
||||
const updatedMatrix = host.querySelector<HTMLElement>("[data-slot='assistant-dot-matrix']");
|
||||
const updatedFirstDot = updatedMatrix?.querySelector<SVGCircleElement>("[data-slot='assistant-dot-matrix-dot']");
|
||||
expect(updatedMatrix).toBe(matrix);
|
||||
expect(updatedMatrix?.dataset.state).toBe("streaming");
|
||||
expect(updatedMatrix?.textContent).toBe("Writing response");
|
||||
expect(updatedFirstDot?.style.animationDuration).toBe("0.9s");
|
||||
expect(updatedMatrix?.querySelectorAll("[data-slot='assistant-dot-matrix-dot']")).toHaveLength(25);
|
||||
});
|
||||
|
||||
test("supports every documented assistant-ui matrix state", async () => {
|
||||
expect(assistantDotMatrixStates).toEqual([
|
||||
"idle",
|
||||
"loading",
|
||||
"thinking",
|
||||
"streaming",
|
||||
"searching",
|
||||
"syncing",
|
||||
"connecting",
|
||||
"waiting",
|
||||
"uploading",
|
||||
"downloading",
|
||||
"listening",
|
||||
"speaking",
|
||||
"recording",
|
||||
"success",
|
||||
"error",
|
||||
"warning",
|
||||
"info",
|
||||
"paused",
|
||||
"stopped",
|
||||
"offline",
|
||||
]);
|
||||
|
||||
for (const state of assistantDotMatrixStates) {
|
||||
await act(async () => root.render(createElement(AssistantDotMatrix, { state })));
|
||||
expect(host.querySelector("[data-slot='assistant-dot-matrix']")?.getAttribute("data-state")).toBe(state);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { ComponentProps, CSSProperties } from "react";
|
||||
|
||||
import classes from "./assistant-dot-matrix.module.css";
|
||||
|
||||
const gridSize = 5;
|
||||
const center = (gridSize - 1) / 2;
|
||||
const dotIndexes = Array.from({ length: gridSize * gridSize }, (_, index) => index);
|
||||
|
||||
const hash = (value: number, salt: number, range: number) => {
|
||||
let mixed = (Math.imul(value, 374761393) + Math.imul(salt, 668265263)) >>> 0;
|
||||
mixed = Math.imul(mixed ^ (mixed >>> 13), 1274126177) >>> 0;
|
||||
return ((mixed ^ (mixed >>> 16)) % range) / 1000;
|
||||
};
|
||||
|
||||
const glyph = (dots: [number, number][]) => new Set(dots.map(([row, column]) => row * gridSize + column));
|
||||
|
||||
const check = glyph([
|
||||
[1, 4],
|
||||
[2, 3],
|
||||
[3, 0],
|
||||
[3, 2],
|
||||
[4, 1],
|
||||
]);
|
||||
const cross = glyph([
|
||||
[0, 0],
|
||||
[0, 4],
|
||||
[1, 1],
|
||||
[1, 3],
|
||||
[2, 2],
|
||||
[3, 1],
|
||||
[3, 3],
|
||||
[4, 0],
|
||||
[4, 4],
|
||||
]);
|
||||
const bang = glyph([
|
||||
[0, 2],
|
||||
[1, 2],
|
||||
[2, 2],
|
||||
[4, 2],
|
||||
]);
|
||||
const info = glyph([
|
||||
[0, 2],
|
||||
[2, 2],
|
||||
[3, 2],
|
||||
[4, 2],
|
||||
]);
|
||||
const pause = glyph([
|
||||
[1, 1],
|
||||
[2, 1],
|
||||
[3, 1],
|
||||
[1, 3],
|
||||
[2, 3],
|
||||
[3, 3],
|
||||
]);
|
||||
const stop = glyph([
|
||||
[1, 1],
|
||||
[1, 2],
|
||||
[1, 3],
|
||||
[2, 1],
|
||||
[2, 2],
|
||||
[2, 3],
|
||||
[3, 1],
|
||||
[3, 2],
|
||||
[3, 3],
|
||||
]);
|
||||
const record = glyph([
|
||||
[1, 2],
|
||||
[2, 1],
|
||||
[2, 2],
|
||||
[2, 3],
|
||||
[3, 2],
|
||||
]);
|
||||
const ellipsis = glyph([
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[2, 4],
|
||||
]);
|
||||
|
||||
interface Blink {
|
||||
duration: number;
|
||||
delay: number;
|
||||
lo: number;
|
||||
}
|
||||
|
||||
interface StateConfig {
|
||||
glyph?: Set<number>;
|
||||
base?: number;
|
||||
dim?: number;
|
||||
blink?: (index: number, row: number, column: number) => Blink;
|
||||
}
|
||||
|
||||
const states = {
|
||||
idle: { base: 0.3 },
|
||||
loading: {
|
||||
blink: (index) => ({
|
||||
duration: 0.9 + hash(index, 2, 700),
|
||||
delay: -hash(index, 1, 1200),
|
||||
lo: 0.15,
|
||||
}),
|
||||
},
|
||||
thinking: {
|
||||
blink: (_index, row, column) => ({
|
||||
duration: 1.2,
|
||||
delay: -(row + column) * 0.09,
|
||||
lo: 0.2,
|
||||
}),
|
||||
},
|
||||
streaming: {
|
||||
blink: (_index, row, column) => ({
|
||||
duration: 0.9,
|
||||
delay: -(row * 0.12 + hash(column, 3, 900)),
|
||||
lo: 0.15,
|
||||
}),
|
||||
},
|
||||
searching: {
|
||||
blink: (_index, _row, column) => ({ duration: 1.1, delay: -column * 0.12, lo: 0.2 }),
|
||||
},
|
||||
syncing: {
|
||||
blink: (_index, row, column) => {
|
||||
const turn = (Math.atan2(row - center, column - center) + Math.PI) / (2 * Math.PI);
|
||||
return { duration: 1.3, delay: -turn * 1.3, lo: 0.2 };
|
||||
},
|
||||
},
|
||||
connecting: {
|
||||
blink: (_index, row, column) => ({
|
||||
duration: 1.4,
|
||||
delay: -Math.max(Math.abs(row - center), Math.abs(column - center)) * 0.18,
|
||||
lo: 0.15,
|
||||
}),
|
||||
},
|
||||
waiting: {
|
||||
glyph: ellipsis,
|
||||
blink: (_index, _row, column) => ({
|
||||
duration: 1.2,
|
||||
delay: -column * 0.09,
|
||||
lo: 0.2,
|
||||
}),
|
||||
},
|
||||
uploading: {
|
||||
blink: (_index, row) => ({
|
||||
duration: 1,
|
||||
delay: -(gridSize - 1 - row) * 0.12,
|
||||
lo: 0.2,
|
||||
}),
|
||||
},
|
||||
downloading: {
|
||||
blink: (_index, row) => ({ duration: 1, delay: -row * 0.12, lo: 0.2 }),
|
||||
},
|
||||
listening: {
|
||||
blink: (_index, _row, column) => ({
|
||||
duration: 0.7 + hash(column, 4, 500),
|
||||
delay: -hash(column, 5, 900),
|
||||
lo: 0.25,
|
||||
}),
|
||||
},
|
||||
speaking: {
|
||||
blink: (_index, _row, column) => ({
|
||||
duration: 0.4 + hash(column, 6, 350),
|
||||
delay: -hash(column, 7, 700),
|
||||
lo: 0.2,
|
||||
}),
|
||||
},
|
||||
recording: {
|
||||
glyph: record,
|
||||
dim: 0.12,
|
||||
blink: () => ({ duration: 1.4, delay: 0, lo: 0.3 }),
|
||||
},
|
||||
success: { glyph: check },
|
||||
error: {
|
||||
glyph: cross,
|
||||
blink: () => ({ duration: 1.1, delay: 0, lo: 0.4 }),
|
||||
},
|
||||
warning: {
|
||||
glyph: bang,
|
||||
blink: () => ({ duration: 1.6, delay: 0, lo: 0.45 }),
|
||||
},
|
||||
info: { glyph: info },
|
||||
paused: { glyph: pause },
|
||||
stopped: { glyph: stop },
|
||||
offline: { base: 0.15 },
|
||||
} satisfies Record<string, StateConfig>;
|
||||
|
||||
export type AssistantDotMatrixState = keyof typeof states;
|
||||
|
||||
export const assistantDotMatrixStates = Object.keys(states) as readonly AssistantDotMatrixState[];
|
||||
|
||||
interface AssistantDotMatrixProps extends Omit<ComponentProps<"output">, "children"> {
|
||||
state?: AssistantDotMatrixState;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const AssistantDotMatrix = ({ className, state = "loading", label, ...props }: AssistantDotMatrixProps) => {
|
||||
const config: StateConfig = states[state];
|
||||
|
||||
return (
|
||||
<output
|
||||
data-slot="assistant-dot-matrix"
|
||||
data-state={state}
|
||||
className={[classes.root, className].filter(Boolean).join(" ")}
|
||||
{...props}
|
||||
>
|
||||
<span className={classes.visuallyHidden}>{label ?? state}</span>
|
||||
<svg aria-hidden viewBox="0 0 20 20" fill="currentColor" className={classes.grid}>
|
||||
{dotIndexes.map((index) => {
|
||||
const row = Math.floor(index / gridSize);
|
||||
const column = index % gridSize;
|
||||
const on = !config.glyph || config.glyph.has(index);
|
||||
const hi = on ? (config.base ?? 1) : (config.dim ?? 0.15);
|
||||
const blink = on ? config.blink?.(index, row, column) : undefined;
|
||||
|
||||
return (
|
||||
<circle
|
||||
key={index}
|
||||
data-slot="assistant-dot-matrix-dot"
|
||||
cx={2 + column * 4}
|
||||
cy={2 + row * 4}
|
||||
r={1.3}
|
||||
className={classes.dot}
|
||||
style={
|
||||
{
|
||||
opacity: hi,
|
||||
animationDuration: `${blink?.duration ?? 1}s`,
|
||||
animationDelay: `${blink?.delay ?? 0}s`,
|
||||
"--assistant-dot-matrix-hi": hi,
|
||||
"--assistant-dot-matrix-lo": blink?.lo ?? hi,
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</output>
|
||||
);
|
||||
};
|
||||
@@ -352,6 +352,12 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.messagePending {
|
||||
width: max-content;
|
||||
min-height: 1.75rem;
|
||||
padding-inline: 0.1rem;
|
||||
}
|
||||
|
||||
.messageText {
|
||||
line-height: 1.6;
|
||||
overflow-wrap: anywhere;
|
||||
@@ -909,8 +915,10 @@
|
||||
|
||||
.reasoning {
|
||||
margin-block: 0.45rem;
|
||||
padding-inline-start: 0.6rem;
|
||||
border-inline-start: 2px solid var(--mantine-color-default-border);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: color-mix(in srgb, var(--mantine-color-default) 72%, transparent);
|
||||
}
|
||||
|
||||
.reasoningToggle {
|
||||
@@ -918,11 +926,14 @@
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-block: 0.25rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
padding: 0.4rem 0.55rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.reasoningToggle:hover {
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.reasoningToggle:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
@@ -937,7 +948,8 @@
|
||||
}
|
||||
|
||||
.reasoningContent {
|
||||
padding: 0.3rem 0 0.2rem;
|
||||
padding: 0.45rem 0.55rem 0.55rem;
|
||||
border-top: 1px solid var(--mantine-color-default-border);
|
||||
}
|
||||
|
||||
.reasoningText {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import type {
|
||||
EmptyMessagePartProps,
|
||||
FileMessagePartProps,
|
||||
ImageMessagePartProps,
|
||||
ReasoningMessagePartProps,
|
||||
@@ -119,7 +120,9 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import classes from "./assistant-panel.module.css";
|
||||
import { getAssistantActivityState } from "./assistant-activity-state";
|
||||
import { useAssistantAutoApproval, useAssistantAutomaticAction } from "./assistant-auto-approval";
|
||||
import { AssistantDotMatrix } from "./assistant-dot-matrix";
|
||||
import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from "./assistant-directives";
|
||||
import { remarkAssistantDirectives, resolveAssistantDirectiveEntity } from "./assistant-markdown-directives";
|
||||
import type { AssistantDirectiveEntity } from "./assistant-markdown-directives";
|
||||
@@ -160,6 +163,7 @@ interface AssistantPanelProps extends AssistantConversationControls {
|
||||
isRunning: boolean;
|
||||
unreadCount: number;
|
||||
latestAssistantText: string;
|
||||
latestAssistantPartType: string | undefined;
|
||||
latestUserText: string;
|
||||
latestStatus: MessageStatus | undefined;
|
||||
pendingAction: AssistantPendingAction | undefined;
|
||||
@@ -527,11 +531,24 @@ const ReasoningPart = (_props: ReasoningMessagePartProps) => (
|
||||
remarkPlugins={markdownRemarkPlugins}
|
||||
components={assistantMarkdownComponents}
|
||||
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
|
||||
defer
|
||||
/>
|
||||
</AssistantDirectiveEntitiesContext.Provider>
|
||||
);
|
||||
|
||||
const AssistantMessagePending = ({ status }: EmptyMessagePartProps) => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
if (status.type !== "running") return null;
|
||||
|
||||
return (
|
||||
<Group component="output" className={classes.messagePending} gap="xs" wrap="nowrap" aria-live="polite">
|
||||
<AssistantDotMatrix state="thinking" role="presentation" aria-hidden />
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("activity.thinking")}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const ReasoningVisibilityContext = createContext<{
|
||||
collapsed: boolean;
|
||||
setCollapsed: (collapsed: boolean) => void;
|
||||
@@ -970,7 +987,9 @@ const AssistantChainOfThought = () => {
|
||||
onClick={() => setPreferredCollapsed(!collapsed)}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{running && <Loader type="bars" size="xs" color="gray" />}
|
||||
{(running || chainStatus.type === "requires-action") && (
|
||||
<AssistantDotMatrix state={running ? "thinking" : "waiting"} role="presentation" aria-hidden />
|
||||
)}
|
||||
<Text size="xs" fw={650} c="dimmed">
|
||||
{t("reasoning")}
|
||||
</Text>
|
||||
@@ -1736,7 +1755,9 @@ const WebSearchActivity = () => {
|
||||
const AssistantMessage = () => (
|
||||
<MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
|
||||
<MessagePrimitive.Parts
|
||||
unstable_showEmptyOnNonTextEnd={false}
|
||||
components={{
|
||||
Empty: AssistantMessagePending,
|
||||
Text: AssistantTextPart,
|
||||
Source: SourcePart,
|
||||
File: FilePart,
|
||||
@@ -2490,6 +2511,7 @@ const AssistantActivityBar = ({
|
||||
isRunning,
|
||||
unreadCount,
|
||||
latestAssistantText,
|
||||
latestAssistantPartType,
|
||||
latestUserText,
|
||||
latestStatus,
|
||||
pendingAction,
|
||||
@@ -2501,6 +2523,7 @@ const AssistantActivityBar = ({
|
||||
| "isRunning"
|
||||
| "unreadCount"
|
||||
| "latestAssistantText"
|
||||
| "latestAssistantPartType"
|
||||
| "latestUserText"
|
||||
| "latestStatus"
|
||||
| "pendingAction"
|
||||
@@ -2530,26 +2553,21 @@ const AssistantActivityBar = ({
|
||||
const detail = isRunning
|
||||
? latestUserText || t("activity.working")
|
||||
: pendingCopy?.detail || latestAssistantText || (failed ? t("responseError.description") : t("activity.completed"));
|
||||
const activityState = getAssistantActivityState({
|
||||
isRunning,
|
||||
latestPartType: latestAssistantPartType,
|
||||
needsApproval,
|
||||
failed,
|
||||
});
|
||||
const activityColor = needsApproval ? "yellow" : failed ? "red" : isRunning ? "red" : "green";
|
||||
|
||||
return (
|
||||
<Box component="output" className={classes.activityBar} aria-live="polite">
|
||||
<UnstyledButton className={classes.activityTrigger} onClick={onOpen} aria-label={t("activity.expand")}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{isRunning ? (
|
||||
<ThemeIcon variant="light" color="red" radius="xl">
|
||||
<Loader type="bars" size="sm" color="red" />
|
||||
</ThemeIcon>
|
||||
) : (
|
||||
<ThemeIcon variant="light" color={failed ? "red" : needsApproval ? "yellow" : "green"} radius="xl">
|
||||
{failed ? (
|
||||
<IconAlertTriangle size={17} />
|
||||
) : needsApproval ? (
|
||||
<IconRobot size={17} />
|
||||
) : (
|
||||
<IconCheck size={17} />
|
||||
)}
|
||||
</ThemeIcon>
|
||||
)}
|
||||
<ThemeIcon variant="light" color={activityColor} radius="xl">
|
||||
<AssistantDotMatrix state={activityState} label={title} role="presentation" aria-hidden />
|
||||
</ThemeIcon>
|
||||
<Stack gap={0} className={classes.activityCopy}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="sm" fw={700}>
|
||||
@@ -2740,6 +2758,7 @@ export const AssistantPanel = ({
|
||||
isRunning,
|
||||
unreadCount,
|
||||
latestAssistantText,
|
||||
latestAssistantPartType,
|
||||
latestUserText,
|
||||
latestStatus,
|
||||
pendingAction,
|
||||
@@ -2786,6 +2805,7 @@ export const AssistantPanel = ({
|
||||
isRunning={isRunning}
|
||||
unreadCount={unreadCount}
|
||||
latestAssistantText={latestAssistantText}
|
||||
latestAssistantPartType={latestAssistantPartType}
|
||||
latestUserText={latestUserText}
|
||||
latestStatus={latestStatus}
|
||||
pendingAction={pendingAction}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { AssistantWidgetRendererProvider } from "@homarr/widgets";
|
||||
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
|
||||
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
|
||||
import { AssistantAutoApprovalProvider } from "./assistant-auto-approval";
|
||||
import { getRunningAssistantPartType } from "./assistant-activity-state";
|
||||
import { prepareAssistantRequestBody } from "./assistant-attachment-payload";
|
||||
import { createAssistantBrowserToolExecutors } from "./assistant-browser-tool-executors";
|
||||
import { AssistantAskUserTool, AssistantConfigureAppTool } from "./assistant-human-tools";
|
||||
@@ -536,6 +537,10 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
const latestAssistantText = getMessageText(latestAssistantMessage);
|
||||
const latestUserText = getMessageText(latestUserMessage);
|
||||
const latestStatus = latestAssistantMessage?.role === "assistant" ? latestAssistantMessage.status : undefined;
|
||||
const latestAssistantPartType = getRunningAssistantPartType(
|
||||
latestStatus?.type,
|
||||
latestAssistantMessage?.content.at(-1)?.type,
|
||||
);
|
||||
const pendingAction = getPendingAssistantAction(latestAssistantMessage);
|
||||
const assistantIsRunning = isRunning || queuedPrompt !== null;
|
||||
const notificationKey = getNotificationKey(latestAssistantMessage);
|
||||
@@ -698,6 +703,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
|
||||
isRunning={assistantIsRunning}
|
||||
unreadCount={unreadCount}
|
||||
latestAssistantText={latestAssistantText}
|
||||
latestAssistantPartType={latestAssistantPartType}
|
||||
latestUserText={queuedPrompt ?? latestUserText}
|
||||
latestStatus={latestStatus}
|
||||
pendingAction={pendingAction}
|
||||
|
||||
Reference in new issue
Block a user