Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions apps/maple-research/frontend/src/components/UnifiedChat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ import {
continueChatComposerListBeforeInput
} from "@/components/chatComposerListContinuation";
import { ComposerHoverTooltip } from "@/components/chat/ComposerHoverTooltip";
import { ThinkingLevelSelector } from "@/components/chat/ThinkingLevelSelector";
import {
getStoredThinkingLevel,
resolveModelReasoning,
resolveThinkingLevel,
storeThinkingLevel,
type ThinkingLevelChoice
} from "@/services/chatThinkingLevel";
import { ModelSelector } from "@/components/ModelSelector";
import { useBillingState, useModelState, useSelectedProjectState } from "@/state/useLocalState";
import { isKnownFreePlan } from "@/billing/billingAccess";
Expand Down Expand Up @@ -1535,7 +1543,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
const isLandscapeMobile = useIsLandscapeMobile();
const isCompactLayout = isMobile || isLandscapeMobile;
const openai = useOpenAI();
const { model, hasWhisperModel } = useModelState();
const { model, hasWhisperModel, availableModels, modelAliases } = useModelState();
const { billingStatus } = useBillingState();
const { selectedProjectId, setSelectedProjectId } = useSelectedProjectState();
const os = useOpenSecret();
Expand Down Expand Up @@ -1829,6 +1837,23 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
// Web search toggle state - persisted in localStorage, billing-aware initial default
const [isWebSearchEnabled, setIsWebSearchEnabled] = useState(getInitialWebSearchEnabled);

// Thinking level - persisted in localStorage; only levels the selected
// model's catalog entry publishes are ever sent.
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevelChoice>(getStoredThinkingLevel);
const selectedModelReasoning = resolveModelReasoning(
model || DEFAULT_MODEL_ID,
availableModels,
modelAliases
);
const effectiveReasoningEffort = resolveThinkingLevel(
thinkingLevel,
selectedModelReasoning
).effort;
const handleThinkingLevelChange = useCallback((choice: ThinkingLevelChoice) => {
setThinkingLevel(choice);
storeThinkingLevel(choice);
}, []);

// Fullscreen mode for power users - persisted in localStorage
const [isFullscreen, setIsFullscreen] = useState(() => {
return localStorage.getItem("chatFullscreen") === "true";
Expand Down Expand Up @@ -4983,6 +5008,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
messageId: uuidv4(),
model: model || DEFAULT_MODEL_ID,
webSearchEnabled: isWebSearchEnabled,
reasoningEffort: effectiveReasoningEffort,
createdMs: Date.now()
};
const composerAtSubmit = chatComposerWithInputOverride(startSnapshot.composer, overrideInput);
Expand Down Expand Up @@ -5212,7 +5238,14 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
metadata: { internal_message_id: localMessageId },
stream: true,
store: true,
...(item.webSearchEnabled && { tools: [{ type: "web_search" }] })
...(item.webSearchEnabled && { tools: [{ type: "web_search" }] }),
// The catalog decides which efforts a model accepts; openai 5.x's
// ReasoningEffort type predates `none`, `xhigh` and `max`.
...(item.reasoningEffort && {
reasoning: { effort: item.reasoningEffort } as NonNullable<
ResponseCreateParamsStreaming["reasoning"]
>
})
};
const stream = await withInferenceCapacityRetry(
(maxInferenceSends) => {
Expand Down Expand Up @@ -5602,6 +5635,7 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
isRuntimeSelected,
isCompactLayout,
isWebSearchEnabled,
effectiveReasoningEffort,
model,
openai,
processStreamingResponse,
Expand Down Expand Up @@ -5916,6 +5950,13 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
<div className="flex min-w-0 flex-wrap items-center gap-1.5 sm:gap-2">
<ModelSelector />

<ThinkingLevelSelector
reasoning={selectedModelReasoning}
value={thinkingLevel}
onChange={handleThinkingLevelChange}
disabled={isRecordingForActive}
/>

<ConversationProjectPicker
selectedProjectId={draftProjectId}
onSelect={setDraftProjectId}
Expand Down Expand Up @@ -6172,6 +6213,13 @@ export function UnifiedChat({ isVisible = true }: { isVisible?: boolean }) {
<div className="flex min-w-0 flex-wrap items-center gap-1.5 sm:gap-2">
<ModelSelector />

<ThinkingLevelSelector
reasoning={selectedModelReasoning}
value={thinkingLevel}
onChange={handleThinkingLevelChange}
disabled={isRecordingForActive}
/>

<ComposerHoverTooltip
label={`Web search ${isWebSearchEnabled ? "enabled" : "disabled"}`}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { BrainCog, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import { ComposerHoverTooltip } from "@/components/chat/ComposerHoverTooltip";
import type { ModelReasoning } from "@/state/LocalStateContextDef";
import {
resolveThinkingLevel,
thinkingLevelLabel,
type ThinkingLevelChoice
} from "@/services/chatThinkingLevel";

export type ThinkingLevelSelectorProps = {
/** The selected model's catalog reasoning controls; hides the control when absent. */
reasoning: ModelReasoning | null | undefined;
value: ThinkingLevelChoice;
onChange: (choice: ThinkingLevelChoice) => void;
disabled?: boolean;
};

/**
* Composer control for the thinking level sent with each message. The options
* come from the catalog, so a model only ever receives a level it accepts.
*/
export function ThinkingLevelSelector({
reasoning,
value,
onChange,
disabled
}: ThinkingLevelSelectorProps) {
const { options, effective } = resolveThinkingLevel(value, reasoning);
if (options.length === 0) return null;

const currentLabel = thinkingLevelLabel(effective);
const isDefault = effective === "default";

return (
<DropdownMenu modal={false}>
<ComposerHoverTooltip label={`Thinking: ${currentLabel}`}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled}
className="h-8 gap-1 px-2 text-[hsl(var(--maple-secondary-700))] hover:bg-[hsl(var(--maple-primary-container))] hover:text-[hsl(var(--maple-secondary-700))]"
data-testid="thinking-level-button"
aria-label={`Thinking level: ${currentLabel}. Click to change.`}
>
<BrainCog
className={`h-4 w-4 ${isDefault ? "" : "text-[hsl(var(--maple-primary))]"}`}
/>
{!isDefault && <span className="text-xs font-medium">{currentLabel}</span>}
</Button>
</DropdownMenuTrigger>
</ComposerHoverTooltip>
<DropdownMenuContent align="start" className="w-52">
{options.map((option) => (
<DropdownMenuItem
key={option.value}
onClick={() => onChange(option.value)}
className="flex items-center justify-between gap-2"
data-testid={`thinking-level-option-${option.value}`}
>
<span className="flex flex-col">
<span>{option.label}</span>
{option.description && (
<span className="text-xs text-muted-foreground">{option.description}</span>
)}
</span>
{option.value === effective && <Check className="h-4 w-4 shrink-0" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
queuedMessageEditStillPresent as sharedQueuedMessageEditStillPresent,
type QueuedMessageEdit
} from "./composerQueue";
import type { ReasoningEffort } from "@/state/LocalStateContextDef";

export const MAX_CHAT_QUEUED_MESSAGES = 16;
export const MAX_CHAT_QUEUED_TEXT_BYTES = 32 * 1024;
Expand All @@ -27,6 +28,8 @@ export type ChatQueuedMessage = {
draftProjectId: string | null;
model: string;
webSearchEnabled: boolean;
/** The thinking level chosen at submit time, if the model offered one. */
reasoningEffort?: ReasoningEffort;
createdMs: number;
};

Expand All @@ -39,7 +42,7 @@ export type ChatComposerQueueState = {

export type ChatQueuedMessageMetadata = Pick<
ChatQueuedMessage,
"queueId" | "messageId" | "model" | "webSearchEnabled" | "createdMs"
"queueId" | "messageId" | "model" | "webSearchEnabled" | "reasoningEffort" | "createdMs"
>;

export type ChatComposerDraft = {
Expand Down
133 changes: 133 additions & 0 deletions apps/maple-research/frontend/src/services/chatThinkingLevel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, expect, test } from "bun:test";
import type {
ModelReasoning,
OpenSecretModel,
OpenSecretModelAlias
} from "@/state/LocalStateContextDef";
import {
getStoredThinkingLevel,
resolveModelReasoning,
resolveThinkingLevel,
storeThinkingLevel,
thinkingLevelLabel,
thinkingLevelOptions
} from "./chatThinkingLevel";

const GLM: ModelReasoning = {
mandatory: true,
default_enabled: true,
supported_efforts: ["max", "high", "low"],
default_effort: "max"
};
const KIMI: ModelReasoning = {
mandatory: false,
default_enabled: true,
supported_efforts: ["max", "high", "low"],
default_effort: "max"
};
const GPT_OSS: ModelReasoning = {
mandatory: true,
default_enabled: true,
supported_efforts: ["high", "medium", "low"],
default_effort: "medium"
};
const GEMMA: ModelReasoning = { mandatory: false, default_enabled: false };

function values(reasoning: ModelReasoning | null | undefined): string[] {
return thinkingLevelOptions(reasoning).map((option) => option.value);
}

describe("thinkingLevelOptions", () => {
test("hides the control for models that never reason", () => {
expect(values(undefined)).toEqual([]);
expect(values(null)).toEqual([]);
});

test("lists default, then off where allowed, then efforts from low to high", () => {
expect(values(GLM)).toEqual(["default", "low", "high", "max"]);
expect(values(KIMI)).toEqual(["default", "none", "low", "high", "max"]);
expect(values(GPT_OSS)).toEqual(["default", "low", "medium", "high"]);
expect(values(GEMMA)).toEqual(["default", "none"]);
});

test("describes the default with the model's own default effort", () => {
expect(thinkingLevelOptions(GLM)[0].description).toBe("Model default (max)");
expect(thinkingLevelOptions(GEMMA)[0].description).toBeUndefined();
expect(thinkingLevelLabel("xhigh")).toBe("Extra high");
expect(thinkingLevelLabel("none")).toBe("Off");
expect(thinkingLevelLabel("default")).toBe("Default");
});
});

describe("resolveThinkingLevel", () => {
test("sends only levels the selected model publishes", () => {
expect(resolveThinkingLevel("low", GLM).effort).toBe("low");
expect(resolveThinkingLevel("default", GLM).effort).toBeUndefined();
// A stored medium came from another model; GLM falls back to its default.
const medium = resolveThinkingLevel("medium", GLM);
expect(medium.effective).toBe("default");
expect(medium.effort).toBeUndefined();
// Off is only offered where reasoning is not mandatory.
expect(resolveThinkingLevel("none", GLM).effort).toBeUndefined();
expect(resolveThinkingLevel("none", KIMI).effort).toBe("none");
expect(resolveThinkingLevel("none", GEMMA).effort).toBe("none");
expect(resolveThinkingLevel("high", GEMMA).effective).toBe("default");
});

test("never sends an effort for a model without reasoning", () => {
const resolved = resolveThinkingLevel("max", null);
expect(resolved.options).toEqual([]);
expect(resolved.effective).toBe("default");
expect(resolved.effort).toBeUndefined();
});
});

describe("resolveModelReasoning", () => {
const models: OpenSecretModel[] = [
{ id: "glm-5-3", object: "model", created: 0, owned_by: "opensecret", reasoning: GLM },
{ id: "llama3-3-70b", object: "model", created: 0, owned_by: "opensecret" },
{ id: "old-server", object: "model", created: 0, owned_by: "opensecret", reasoning: null }
];
const aliases: OpenSecretModelAlias[] = [
{
id: "auto:quick",
label: "Quick",
short_name: "Quick",
description: "",
target_model: "glm-5-3-flash",
reasoning: GLM
}
];

test("prefers the alias entry, then the model entry", () => {
expect(resolveModelReasoning("auto:quick", models, aliases)).toEqual(GLM);
expect(resolveModelReasoning("glm-5-3", models, aliases)).toEqual(GLM);
});

test("is undefined for unknown or metadata-less models and null when published as null", () => {
expect(resolveModelReasoning("llama3-3-70b", models, aliases)).toBeUndefined();
expect(resolveModelReasoning("missing", models, aliases)).toBeUndefined();
expect(resolveModelReasoning("old-server", models, aliases)).toBeNull();
});
});

describe("stored thinking level", () => {
function memoryStorage(initial: Record<string, string> = {}) {
const store = new Map(Object.entries(initial));
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
}
};
}

test("round-trips valid choices and ignores anything else", () => {
const storage = memoryStorage();
expect(getStoredThinkingLevel(storage)).toBe("default");
storeThinkingLevel("high", storage);
expect(getStoredThinkingLevel(storage)).toBe("high");
expect(getStoredThinkingLevel(memoryStorage({ chatThinkingLevel: "ultra" }))).toBe("default");
expect(getStoredThinkingLevel(null)).toBe("default");
});
});
Loading
Loading