mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-21 10:22:18 +00:00
feat(dashboard): add a reasoning-effort picker to the chat sidebar (#49141)
The web dashboard only showed a read-only "Reasoning" capability badge with no way to set the effort level — unlike the desktop app, which has an effort radio in its composer model menu. This adds a picker so the two surfaces reach parity. - ReasoningPicker: a Select rendered in the chat sidebar, gated on the effective model's supports_reasoning capability (from /api/model/info). Reads/writes agent.reasoning_effort via the existing config REST endpoints (read-modify-write, the dashboard's single-key save pattern), so the value lands in the config the agent boots a fresh chat from. Options mirror the desktop: Off/Minimal/Low/Medium/High/Max. - ChatSidebar: capture supports_reasoning from the model-info fetch and render the picker; on change, show the same 'apply on /new or reload' notice the model switch uses. - reasoning-effort.ts: DOM-free helpers (normalizeEffort + options) so the node-env vitest harness can cover the resolution logic, plus tests.
This commit is contained in:
parent
c06898098b
commit
ac00e73688
4 changed files with 233 additions and 0 deletions
|
|
@ -30,6 +30,7 @@ import { Card } from "@nous-research/ui/ui/components/card";
|
|||
|
||||
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
|
||||
import { ModelReloadConfirm } from "@/components/ModelReloadConfirm";
|
||||
import { ReasoningPicker } from "@/components/ReasoningPicker";
|
||||
import { ToolCall, type ToolEntry } from "@/components/ToolCall";
|
||||
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
|
||||
import { api, HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api";
|
||||
|
|
@ -113,6 +114,14 @@ export function ChatSidebar({
|
|||
// elsewhere, so the badge would go stale. `/api/model/info` is profile-scoped
|
||||
// by `fetchJSON`, so it reads the same profile this sidebar is scoped to.
|
||||
const [effectiveModel, setEffectiveModel] = useState("");
|
||||
// Whether the effective model supports reasoning effort — gates the
|
||||
// ReasoningPicker. Read from the same `/api/model/info` capabilities the
|
||||
// (currently unused) ModelInfoCard surfaces, so the dashboard exposes a
|
||||
// control to *set* the level, not just a read-only "Reasoning" badge.
|
||||
const [supportsReasoning, setSupportsReasoning] = useState(false);
|
||||
// Bumped on model change/save so ReasoningPicker re-reads the saved effort
|
||||
// (config is profile-scoped the same way the model badge is).
|
||||
const [modelRefreshKey, setModelRefreshKey] = useState(0);
|
||||
// Set after the picker saves a model and the user declines the reload: config
|
||||
// is updated but the running session keeps its model until rebuilt.
|
||||
const [modelNotice, setModelNotice] = useState<string | null>(null);
|
||||
|
|
@ -127,6 +136,9 @@ export function ChatSidebar({
|
|||
.getModelInfo()
|
||||
.then((r) => {
|
||||
if (r?.model) setEffectiveModel(String(r.model));
|
||||
setSupportsReasoning(!!r?.capabilities?.supports_reasoning);
|
||||
// Bump so ReasoningPicker re-reads the saved effort for the new model.
|
||||
setModelRefreshKey((k) => k + 1);
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort: keep the last known label rather than blanking it.
|
||||
|
|
@ -404,6 +416,20 @@ export function ChatSidebar({
|
|||
</Badge>
|
||||
</Card>
|
||||
|
||||
{supportsReasoning && (
|
||||
<Card className="py-0">
|
||||
<ReasoningPicker
|
||||
currentModel={modelName}
|
||||
refreshKey={modelRefreshKey}
|
||||
onChanged={(effort) =>
|
||||
setModelNotice(
|
||||
`Reasoning effort set to ${effort}. Run /new or refresh the page to apply it to this chat.`,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{modelNotice && (
|
||||
<Card className="flex items-start gap-2 border-warning/40 bg-warning/5 px-3 py-2 text-xs">
|
||||
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
|
||||
|
|
|
|||
123
web/src/components/ReasoningPicker.tsx
Normal file
123
web/src/components/ReasoningPicker.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* ReasoningPicker — sets the main model's reasoning effort from the dashboard
|
||||
* Chat sidebar, mirroring the desktop app's composer effort radio.
|
||||
*
|
||||
* The dashboard previously only showed a read-only "Reasoning" capability
|
||||
* badge (see ModelInfoCard) with no way to actually choose the effort level —
|
||||
* unlike the desktop app, which exposes a radio in its model menu. This closes
|
||||
* that parity gap.
|
||||
*
|
||||
* Storage: the effort persists to config.yaml at `agent.reasoning_effort`
|
||||
* (the same key the TUI's `/reasoning <level>` command and the desktop radio
|
||||
* write). We read the whole config and write it back — the established
|
||||
* single-key pattern on the dashboard (see ConfigPage) — so the value lands in
|
||||
* the config the agent boots a fresh chat from. As with the model picker, the
|
||||
* running chat session adopts the change on the next `/new` or page reload;
|
||||
* we surface that hint rather than forcing a reload here.
|
||||
*
|
||||
* Profile scoping: `/api/config` is profile-scoped by `fetchJSON` via the
|
||||
* global management profile — the same scope the sidebar's `/api/model/info`
|
||||
* badge reads from — so this writes the profile the sidebar is showing.
|
||||
*/
|
||||
|
||||
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
import { Brain } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import {
|
||||
EFFORT_OPTIONS,
|
||||
normalizeEffort,
|
||||
VALID_EFFORTS,
|
||||
} from "@/lib/reasoning-effort";
|
||||
|
||||
interface ReasoningPickerProps {
|
||||
/** Current model string from config — re-reads the saved effort when it
|
||||
* changes (a different model may have been selected). */
|
||||
currentModel: string;
|
||||
/** Bumped after the model picker saves, to re-read config in lockstep. */
|
||||
refreshKey?: number;
|
||||
/** Called after a successful change so the sidebar can show an "apply on
|
||||
* /new or reload" notice, matching the model-switch UX. */
|
||||
onChanged?: (effort: string) => void;
|
||||
}
|
||||
|
||||
export function ReasoningPicker({
|
||||
currentModel,
|
||||
refreshKey = 0,
|
||||
onChanged,
|
||||
}: ReasoningPickerProps) {
|
||||
const [effort, setEffort] = useState("medium");
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const lastFetchKeyRef = useRef("");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchKey = `${currentModel}:${refreshKey}`;
|
||||
if (fetchKey === lastFetchKeyRef.current) return;
|
||||
lastFetchKeyRef.current = fetchKey;
|
||||
void api
|
||||
.getConfig()
|
||||
.then((cfg) => {
|
||||
const agent = (cfg?.agent as Record<string, unknown> | undefined) ?? {};
|
||||
setEffort(normalizeEffort(agent.reasoning_effort));
|
||||
setLoaded(true);
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort: keep the last known value rather than blanking it.
|
||||
setLoaded(true);
|
||||
});
|
||||
}, [currentModel, refreshKey]);
|
||||
|
||||
const onSelect = useCallback(
|
||||
(next: string) => {
|
||||
if (!VALID_EFFORTS.has(next) || next === effort) return;
|
||||
const prev = effort;
|
||||
setEffort(next); // optimistic
|
||||
setSaving(true);
|
||||
// Read-modify-write the whole config — the dashboard's single-key save
|
||||
// pattern — so we never clobber sibling keys. `saveConfig` PUTs the full
|
||||
// object the agent boots from.
|
||||
void api
|
||||
.getConfig()
|
||||
.then((cfg) => {
|
||||
const base = (cfg ?? {}) as Record<string, unknown>;
|
||||
const agent =
|
||||
base.agent && typeof base.agent === "object"
|
||||
? { ...(base.agent as Record<string, unknown>) }
|
||||
: {};
|
||||
agent.reasoning_effort = next;
|
||||
return api.saveConfig({ ...base, agent });
|
||||
})
|
||||
.then(() => {
|
||||
onChanged?.(next);
|
||||
})
|
||||
.catch(() => {
|
||||
setEffort(prev); // revert on failure
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
},
|
||||
[effort, onChanged],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs">
|
||||
<div className="flex items-center gap-1.5 text-text-tertiary">
|
||||
<Brain className="h-3.5 w-3.5" />
|
||||
<span className="text-display tracking-wider">reasoning</span>
|
||||
</div>
|
||||
<Select
|
||||
className="ml-auto min-w-0"
|
||||
disabled={!loaded || saving}
|
||||
onValueChange={onSelect}
|
||||
value={effort}
|
||||
>
|
||||
{EFFORT_OPTIONS.map((opt) => (
|
||||
<SelectOption key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
web/src/lib/reasoning-effort.test.ts
Normal file
48
web/src/lib/reasoning-effort.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
EFFORT_OPTIONS,
|
||||
VALID_EFFORTS,
|
||||
normalizeEffort,
|
||||
} from "./reasoning-effort";
|
||||
|
||||
describe("normalizeEffort", () => {
|
||||
it("treats empty/unset as the Hermes default (medium)", () => {
|
||||
expect(normalizeEffort("")).toBe("medium");
|
||||
expect(normalizeEffort(null)).toBe("medium");
|
||||
expect(normalizeEffort(undefined)).toBe("medium");
|
||||
expect(normalizeEffort(" ")).toBe("medium");
|
||||
});
|
||||
|
||||
it("passes through every valid effort level", () => {
|
||||
for (const level of ["none", "minimal", "low", "medium", "high", "xhigh"]) {
|
||||
expect(normalizeEffort(level)).toBe(level);
|
||||
}
|
||||
});
|
||||
|
||||
it("is case- and whitespace-insensitive", () => {
|
||||
expect(normalizeEffort("HIGH")).toBe("high");
|
||||
expect(normalizeEffort(" XHigh ")).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("falls back to medium for unknown values", () => {
|
||||
expect(normalizeEffort("turbo")).toBe("medium");
|
||||
expect(normalizeEffort("max")).toBe("medium"); // 'max' is a label, not a value
|
||||
expect(normalizeEffort(42)).toBe("medium");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EFFORT_OPTIONS", () => {
|
||||
it("every option value is in VALID_EFFORTS (no orphan labels)", () => {
|
||||
for (const opt of EFFORT_OPTIONS) {
|
||||
expect(VALID_EFFORTS.has(opt.value)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("covers the real reasoning levels plus thinking-off", () => {
|
||||
// Invariant against hermes_constants.VALID_REASONING_EFFORTS + 'none'.
|
||||
const values = new Set(EFFORT_OPTIONS.map((o) => o.value));
|
||||
for (const level of ["none", "minimal", "low", "medium", "high", "xhigh"]) {
|
||||
expect(values.has(level)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
36
web/src/lib/reasoning-effort.ts
Normal file
36
web/src/lib/reasoning-effort.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Pure reasoning-effort helpers shared by the dashboard ReasoningPicker.
|
||||
*
|
||||
* Kept DOM-free so the node-environment vitest harness can cover the
|
||||
* resolution logic without loading React or the UI kit.
|
||||
*
|
||||
* Values mirror hermes_constants.VALID_REASONING_EFFORTS plus `none`
|
||||
* (thinking-off). An empty/unset config value means the Hermes default,
|
||||
* which is `medium`.
|
||||
*/
|
||||
|
||||
export interface EffortOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const EFFORT_OPTIONS: ReadonlyArray<EffortOption> = [
|
||||
{ value: "none", label: "Off (no thinking)" },
|
||||
{ value: "minimal", label: "Minimal" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Medium" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "xhigh", label: "Max" },
|
||||
];
|
||||
|
||||
export const VALID_EFFORTS: ReadonlySet<string> = new Set(
|
||||
EFFORT_OPTIONS.map((o) => o.value),
|
||||
);
|
||||
|
||||
/** Normalize a raw `agent.reasoning_effort` config value to a selectable
|
||||
* option. Empty/unknown → `medium` (Hermes' default when unset). */
|
||||
export function normalizeEffort(raw: unknown): string {
|
||||
const value = String(raw ?? "").trim().toLowerCase();
|
||||
if (!value) return "medium";
|
||||
return VALID_EFFORTS.has(value) ? value : "medium";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue