mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-02 01:01:52 +03:00
fix(webui): route first-run model setup choices
This commit is contained in:
+20
-4
@@ -57,6 +57,7 @@ import {
|
|||||||
} from "@/lib/bootstrap";
|
} from "@/lib/bootstrap";
|
||||||
import { displayTitle, sortSessions } from "@/lib/chat-groups";
|
import { displayTitle, sortSessions } from "@/lib/chat-groups";
|
||||||
import { deriveTitle } from "@/lib/format";
|
import { deriveTitle } from "@/lib/format";
|
||||||
|
import type { ModelSetupIntent } from "@/lib/model-setup";
|
||||||
import { NanobotClient } from "@/lib/nanobot-client";
|
import { NanobotClient } from "@/lib/nanobot-client";
|
||||||
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||||
import type {
|
import type {
|
||||||
@@ -1053,6 +1054,8 @@ function Shell({
|
|||||||
const [temporaryChatEnabled, setTemporaryChatEnabled] = useState(false);
|
const [temporaryChatEnabled, setTemporaryChatEnabled] = useState(false);
|
||||||
const [settingsInitialSection, setSettingsInitialSection] =
|
const [settingsInitialSection, setSettingsInitialSection] =
|
||||||
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
|
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
|
||||||
|
const [modelSetupIntent, setModelSetupIntent] = useState<ModelSetupIntent | null>(null);
|
||||||
|
const [chatFocusRequest, setChatFocusRequest] = useState(0);
|
||||||
const [hostSidebarOpen, setHostSidebarOpen] =
|
const [hostSidebarOpen, setHostSidebarOpen] =
|
||||||
useState<boolean>(readSidebarOpen);
|
useState<boolean>(readSidebarOpen);
|
||||||
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
|
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
|
||||||
@@ -1981,8 +1984,12 @@ function Shell({
|
|||||||
[onSelectChat],
|
[onSelectChat],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onOpenSettings = useCallback((section: SettingsSectionKey = "overview") => {
|
const onOpenSettings = useCallback((
|
||||||
|
section: SettingsSectionKey = "overview",
|
||||||
|
setupIntent: ModelSetupIntent | null = null,
|
||||||
|
) => {
|
||||||
setSessionSearchOpen(false);
|
setSessionSearchOpen(false);
|
||||||
|
setModelSetupIntent(setupIntent);
|
||||||
navigate({ view: "settings", activeKey, settingsSection: section });
|
navigate({ view: "settings", activeKey, settingsSection: section });
|
||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
}, [activeKey, navigate]);
|
}, [activeKey, navigate]);
|
||||||
@@ -1991,8 +1998,8 @@ function Shell({
|
|||||||
void loadSettingsView();
|
void loadSettingsView();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onOpenModelSettings = useCallback(() => {
|
const onOpenModelSettings = useCallback((intent?: ModelSetupIntent) => {
|
||||||
onOpenSettings("models");
|
onOpenSettings("models", intent ?? null);
|
||||||
}, [onOpenSettings]);
|
}, [onOpenSettings]);
|
||||||
|
|
||||||
const onOpenApps = useCallback(() => {
|
const onOpenApps = useCallback(() => {
|
||||||
@@ -2015,6 +2022,7 @@ function Shell({
|
|||||||
|
|
||||||
const onSettingsSectionChange = useCallback(
|
const onSettingsSectionChange = useCallback(
|
||||||
(section: SettingsSectionKey) => {
|
(section: SettingsSectionKey) => {
|
||||||
|
setModelSetupIntent(null);
|
||||||
navigate({
|
navigate({
|
||||||
view: shellViewForSettingsSection(section),
|
view: shellViewForSettingsSection(section),
|
||||||
activeKey,
|
activeKey,
|
||||||
@@ -2025,7 +2033,9 @@ function Shell({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onBackToChat = useCallback(() => {
|
const onBackToChat = useCallback(() => {
|
||||||
|
const restoreComposerFocus = modelSetupIntent !== null;
|
||||||
setMobileSidebarOpen(false);
|
setMobileSidebarOpen(false);
|
||||||
|
setModelSetupIntent(null);
|
||||||
const nextKey = (() => {
|
const nextKey = (() => {
|
||||||
if (!activeKey) return null;
|
if (!activeKey) return null;
|
||||||
if (topicSessions.some((session) => session.key === activeKey)) return activeKey;
|
if (topicSessions.some((session) => session.key === activeKey)) return activeKey;
|
||||||
@@ -2036,7 +2046,10 @@ function Shell({
|
|||||||
activeKey: nextKey,
|
activeKey: nextKey,
|
||||||
settingsSection: "overview",
|
settingsSection: "overview",
|
||||||
});
|
});
|
||||||
}, [activeKey, navigate, topicSessions]);
|
if (restoreComposerFocus) {
|
||||||
|
setChatFocusRequest((value) => value + 1);
|
||||||
|
}
|
||||||
|
}, [activeKey, modelSetupIntent, navigate, topicSessions]);
|
||||||
|
|
||||||
const onRestart = useCallback(() => {
|
const onRestart = useCallback(() => {
|
||||||
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
||||||
@@ -2792,6 +2805,7 @@ function Shell({
|
|||||||
onWorkspaceScopeChange={applyWorkspaceScope}
|
onWorkspaceScopeChange={applyWorkspaceScope}
|
||||||
settingsSnapshot={settingsSnapshot}
|
settingsSnapshot={settingsSnapshot}
|
||||||
onOpenModelSettings={onOpenModelSettings}
|
onOpenModelSettings={onOpenModelSettings}
|
||||||
|
focusComposerRequest={chatFocusRequest}
|
||||||
skills={skills}
|
skills={skills}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -2850,6 +2864,7 @@ function Shell({
|
|||||||
}}
|
}}
|
||||||
settingsSnapshot={settingsSnapshot}
|
settingsSnapshot={settingsSnapshot}
|
||||||
onOpenModelSettings={onOpenModelSettings}
|
onOpenModelSettings={onOpenModelSettings}
|
||||||
|
focusComposerRequest={context.active ? chatFocusRequest : 0}
|
||||||
skills={skills}
|
skills={skills}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -2863,6 +2878,7 @@ function Shell({
|
|||||||
theme={theme}
|
theme={theme}
|
||||||
initialSection={settingsInitialSection}
|
initialSection={settingsInitialSection}
|
||||||
initialSettings={settingsSnapshot}
|
initialSettings={settingsSnapshot}
|
||||||
|
modelSetupIntent={modelSetupIntent}
|
||||||
showSidebar={view === "settings"}
|
showSidebar={view === "settings"}
|
||||||
onToggleTheme={toggle}
|
onToggleTheme={toggle}
|
||||||
onBackToChat={onBackToChat}
|
onBackToChat={onBackToChat}
|
||||||
|
|||||||
@@ -31,10 +31,12 @@ import { ChannelsSettings } from "@/components/settings/system/ChannelsSettings"
|
|||||||
import { RuntimeSettings } from "@/components/settings/system/RuntimeSettings";
|
import { RuntimeSettings } from "@/components/settings/system/RuntimeSettings";
|
||||||
import type { SettingsController } from "@/components/settings/useSettingsController";
|
import type { SettingsController } from "@/components/settings/useSettingsController";
|
||||||
import type { SkillSummary } from "@/lib/types";
|
import type { SkillSummary } from "@/lib/types";
|
||||||
|
import type { ModelSetupIntent } from "@/lib/model-setup";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
controller: SettingsController;
|
controller: SettingsController;
|
||||||
|
modelSetupIntent: ModelSetupIntent | null;
|
||||||
theme: "light" | "dark";
|
theme: "light" | "dark";
|
||||||
showSidebar: boolean;
|
showSidebar: boolean;
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
@@ -47,6 +49,7 @@ interface SettingsPageProps {
|
|||||||
|
|
||||||
export function SettingsPage({
|
export function SettingsPage({
|
||||||
controller,
|
controller,
|
||||||
|
modelSetupIntent,
|
||||||
theme,
|
theme,
|
||||||
showSidebar,
|
showSidebar,
|
||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
@@ -283,6 +286,7 @@ export function SettingsPage({
|
|||||||
providerSaving={providerSaving}
|
providerSaving={providerSaving}
|
||||||
showBrandLogos={localPrefs.brandLogos}
|
showBrandLogos={localPrefs.brandLogos}
|
||||||
remoteBrowserAccess={remoteBrowserAccess}
|
remoteBrowserAccess={remoteBrowserAccess}
|
||||||
|
setupIntent={modelSetupIntent}
|
||||||
onToggleProvider={handleToggleProvider}
|
onToggleProvider={handleToggleProvider}
|
||||||
onToggleProviderKey={toggleProviderKeyVisibility}
|
onToggleProviderKey={toggleProviderKeyVisibility}
|
||||||
onToggleProviderKeyEditing={toggleProviderKeyEditing}
|
onToggleProviderKeyEditing={toggleProviderKeyEditing}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { SettingsPage } from "@/components/settings/SettingsPage";
|
import { SettingsPage } from "@/components/settings/SettingsPage";
|
||||||
import type { SettingsSectionKey } from "@/components/settings/contracts";
|
import type { SettingsSectionKey } from "@/components/settings/contracts";
|
||||||
import { useSettingsController } from "@/components/settings/useSettingsController";
|
import { useSettingsController } from "@/components/settings/useSettingsController";
|
||||||
|
import type { ModelSetupIntent } from "@/lib/model-setup";
|
||||||
import type { SettingsPayload, SkillSummary } from "@/lib/types";
|
import type { SettingsPayload, SkillSummary } from "@/lib/types";
|
||||||
|
|
||||||
export type { SettingsSectionKey } from "@/components/settings/contracts";
|
export type { SettingsSectionKey } from "@/components/settings/contracts";
|
||||||
@@ -9,6 +10,7 @@ interface SettingsViewProps {
|
|||||||
theme: "light" | "dark";
|
theme: "light" | "dark";
|
||||||
initialSection?: SettingsSectionKey;
|
initialSection?: SettingsSectionKey;
|
||||||
initialSettings?: SettingsPayload | null;
|
initialSettings?: SettingsPayload | null;
|
||||||
|
modelSetupIntent?: ModelSetupIntent | null;
|
||||||
showSidebar?: boolean;
|
showSidebar?: boolean;
|
||||||
onToggleTheme: () => void;
|
onToggleTheme: () => void;
|
||||||
onBackToChat: () => void;
|
onBackToChat: () => void;
|
||||||
@@ -27,6 +29,7 @@ export function SettingsView({
|
|||||||
theme,
|
theme,
|
||||||
initialSection = "overview",
|
initialSection = "overview",
|
||||||
initialSettings = null,
|
initialSettings = null,
|
||||||
|
modelSetupIntent = null,
|
||||||
showSidebar = true,
|
showSidebar = true,
|
||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
onBackToChat,
|
onBackToChat,
|
||||||
@@ -53,6 +56,7 @@ export function SettingsView({
|
|||||||
return (
|
return (
|
||||||
<SettingsPage
|
<SettingsPage
|
||||||
controller={controller}
|
controller={controller}
|
||||||
|
modelSetupIntent={modelSetupIntent}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
showSidebar={showSidebar}
|
showSidebar={showSidebar}
|
||||||
onToggleTheme={onToggleTheme}
|
onToggleTheme={onToggleTheme}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
@@ -42,6 +42,10 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||||
import { providerBrand } from "@/lib/provider-brand";
|
import { providerBrand } from "@/lib/provider-brand";
|
||||||
|
import {
|
||||||
|
providerMatchesModelSetupIntent,
|
||||||
|
type ModelSetupIntent,
|
||||||
|
} from "@/lib/model-setup";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type {
|
import type {
|
||||||
NanobotFeaturesPayload,
|
NanobotFeaturesPayload,
|
||||||
@@ -232,12 +236,17 @@ const OPENAI_API_TYPE_OPTIONS: Array<{ value: ProviderApiType; label: string }>
|
|||||||
{ value: "responses", label: "Responses" },
|
{ value: "responses", label: "Responses" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
|
const LOCAL_PROVIDER_ORDER = new Map(
|
||||||
["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [
|
["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [
|
||||||
name,
|
name,
|
||||||
index,
|
index,
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
const MODEL_SETUP_TITLE_KEYS: Record<ModelSetupIntent, string> = {
|
||||||
|
account: "thread.composer.modelSetup.account.title",
|
||||||
|
apiKey: "thread.composer.modelSetup.apiKey.title",
|
||||||
|
local: "thread.composer.modelSetup.local.title",
|
||||||
|
};
|
||||||
|
|
||||||
export function ProviderOAuthLoginDialog({
|
export function ProviderOAuthLoginDialog({
|
||||||
flow,
|
flow,
|
||||||
@@ -655,6 +664,7 @@ export function ProvidersSettings({
|
|||||||
providerSaving,
|
providerSaving,
|
||||||
showBrandLogos,
|
showBrandLogos,
|
||||||
remoteBrowserAccess,
|
remoteBrowserAccess,
|
||||||
|
setupIntent,
|
||||||
onToggleProvider,
|
onToggleProvider,
|
||||||
onToggleProviderKey,
|
onToggleProviderKey,
|
||||||
onToggleProviderKeyEditing,
|
onToggleProviderKeyEditing,
|
||||||
@@ -678,6 +688,7 @@ export function ProvidersSettings({
|
|||||||
providerSaving: string | null;
|
providerSaving: string | null;
|
||||||
showBrandLogos: boolean;
|
showBrandLogos: boolean;
|
||||||
remoteBrowserAccess: boolean;
|
remoteBrowserAccess: boolean;
|
||||||
|
setupIntent?: ModelSetupIntent | null;
|
||||||
onToggleProvider: (provider: string) => void;
|
onToggleProvider: (provider: string) => void;
|
||||||
onToggleProviderKey: (provider: string) => void;
|
onToggleProviderKey: (provider: string) => void;
|
||||||
onToggleProviderKeyEditing: (provider: string) => void;
|
onToggleProviderKeyEditing: (provider: string) => void;
|
||||||
@@ -697,23 +708,57 @@ export function ProvidersSettings({
|
|||||||
const [customProviderDraft, setCustomProviderDraft] = useState<CustomProviderDraft>(
|
const [customProviderDraft, setCustomProviderDraft] = useState<CustomProviderDraft>(
|
||||||
emptyCustomProviderDraft,
|
emptyCustomProviderDraft,
|
||||||
);
|
);
|
||||||
|
const sectionRef = useRef<HTMLElement>(null);
|
||||||
|
const [setupPickerOpen, setSetupPickerOpen] = useState(false);
|
||||||
const configuredProviders = settings.providers.filter((provider) => provider.configured);
|
const configuredProviders = settings.providers.filter((provider) => provider.configured);
|
||||||
const unconfiguredProviders = useMemo(
|
const unconfiguredProviders = useMemo(
|
||||||
() =>
|
() =>
|
||||||
orderUnconfiguredProviders(
|
orderProviderPickerOptions(
|
||||||
settings.providers.filter(
|
settings.providers.filter(
|
||||||
(provider) => !provider.configured && provider.name !== "custom",
|
(provider) => !provider.configured && provider.name !== "custom",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
[settings.providers],
|
[settings.providers],
|
||||||
);
|
);
|
||||||
|
const providerPickerOptions = useMemo(
|
||||||
|
() => setupIntent
|
||||||
|
? orderProviderPickerOptions(
|
||||||
|
settings.providers.filter(
|
||||||
|
(provider) =>
|
||||||
|
provider.name !== "custom"
|
||||||
|
&& providerMatchesModelSetupIntent(provider, setupIntent),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: unconfiguredProviders,
|
||||||
|
[settings.providers, setupIntent, unconfiguredProviders],
|
||||||
|
);
|
||||||
const selectedUnconfiguredProvider =
|
const selectedUnconfiguredProvider =
|
||||||
unconfiguredProviders.find((provider) => provider.name === expandedProvider) ?? null;
|
unconfiguredProviders.find((provider) => provider.name === expandedProvider) ?? null;
|
||||||
const customProviderSaving = providerSaving === CUSTOM_PROVIDER_CREATION_KEY;
|
const customProviderSaving = providerSaving === CUSTOM_PROVIDER_CREATION_KEY;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!setupIntent) {
|
||||||
|
setSetupPickerOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const frame = window.requestAnimationFrame(() => {
|
||||||
|
sectionRef.current?.scrollIntoView?.({ block: "start" });
|
||||||
|
setSetupPickerOpen(true);
|
||||||
|
});
|
||||||
|
return () => window.cancelAnimationFrame(frame);
|
||||||
|
}, [setupIntent]);
|
||||||
const toggleProvider = (providerName: string) => {
|
const toggleProvider = (providerName: string) => {
|
||||||
setCreatingCustomProvider(false);
|
setCreatingCustomProvider(false);
|
||||||
onToggleProvider(providerName);
|
onToggleProvider(providerName);
|
||||||
};
|
};
|
||||||
|
const chooseProvider = (providerName: string) => {
|
||||||
|
setCreatingCustomProvider(false);
|
||||||
|
if (expandedProvider !== providerName) onToggleProvider(providerName);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
document.getElementById(`settings-provider-${providerName}`)?.scrollIntoView?.({
|
||||||
|
block: "start",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
const beginCustomProviderCreation = () => {
|
const beginCustomProviderCreation = () => {
|
||||||
if (expandedProvider) onToggleProvider(expandedProvider);
|
if (expandedProvider) onToggleProvider(expandedProvider);
|
||||||
setCustomProviderDraft(emptyCustomProviderDraft());
|
setCustomProviderDraft(emptyCustomProviderDraft());
|
||||||
@@ -776,7 +821,11 @@ export function ProvidersSettings({
|
|||||||
? (nanobotFeatures?.features ?? []).find((feature) => feature.name === supportName)
|
? (nanobotFeatures?.features ?? []).find((feature) => feature.name === supportName)
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<div key={provider.name} className="divide-y divide-border/45">
|
<div
|
||||||
|
key={provider.name}
|
||||||
|
id={`settings-provider-${provider.name}`}
|
||||||
|
className="divide-y divide-border/45"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
@@ -1222,7 +1271,7 @@ export function ProvidersSettings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<section>
|
<section ref={sectionRef}>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
{tx("settings.providers.title", "Model providers")}
|
{tx("settings.providers.title", "Model providers")}
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
@@ -1233,7 +1282,12 @@ export function ProvidersSettings({
|
|||||||
: null}
|
: null}
|
||||||
{customProviderForm}
|
{customProviderForm}
|
||||||
{!expandedProvider && !creatingCustomProvider ? (
|
{!expandedProvider && !creatingCustomProvider ? (
|
||||||
<DropdownMenu modal={false}>
|
<DropdownMenu
|
||||||
|
modal={false}
|
||||||
|
{...(setupIntent
|
||||||
|
? { open: setupPickerOpen, onOpenChange: setSetupPickerOpen }
|
||||||
|
: {})}
|
||||||
|
>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1244,7 +1298,9 @@ export function ProvidersSettings({
|
|||||||
<Plus className="h-5 w-5" aria-hidden />
|
<Plus className="h-5 w-5" aria-hidden />
|
||||||
</span>
|
</span>
|
||||||
<span className="truncate text-[15px] font-semibold text-foreground">
|
<span className="truncate text-[15px] font-semibold text-foreground">
|
||||||
{tx(
|
{setupIntent
|
||||||
|
? t(MODEL_SETUP_TITLE_KEYS[setupIntent])
|
||||||
|
: tx(
|
||||||
"settings.providers.addOwnProvider",
|
"settings.providers.addOwnProvider",
|
||||||
"Add your own model provider",
|
"Add your own model provider",
|
||||||
)}
|
)}
|
||||||
@@ -1261,6 +1317,7 @@ export function ProvidersSettings({
|
|||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
className="max-h-[24rem] w-[380px] max-w-[calc(100vw-2rem)] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
|
className="max-h-[24rem] w-[380px] max-w-[calc(100vw-2rem)] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
|
||||||
>
|
>
|
||||||
|
{!setupIntent || setupIntent === "apiKey" ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onSelect={beginCustomProviderCreation}
|
onSelect={beginCustomProviderCreation}
|
||||||
className="flex min-h-[54px] cursor-default items-center gap-3 px-2.5 py-2 focus:bg-muted/85 focus:text-foreground"
|
className="flex min-h-[54px] cursor-default items-center gap-3 px-2.5 py-2 focus:bg-muted/85 focus:text-foreground"
|
||||||
@@ -1270,16 +1327,15 @@ export function ProvidersSettings({
|
|||||||
{tx("settings.providers.customProvider", "Custom provider")}
|
{tx("settings.providers.customProvider", "Custom provider")}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{unconfiguredProviders.length > 0 ? <DropdownMenuSeparator /> : null}
|
) : null}
|
||||||
{unconfiguredProviders.map((provider) => (
|
{providerPickerOptions.length > 0
|
||||||
|
&& (!setupIntent || setupIntent === "apiKey")
|
||||||
|
? <DropdownMenuSeparator />
|
||||||
|
: null}
|
||||||
|
{providerPickerOptions.map((provider) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={provider.name}
|
key={provider.name}
|
||||||
onSelect={() => {
|
onSelect={() => chooseProvider(provider.name)}
|
||||||
setCreatingCustomProvider(false);
|
|
||||||
if (expandedProvider !== provider.name) {
|
|
||||||
onToggleProvider(provider.name);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex min-h-[54px] cursor-default items-center gap-3 px-2.5 py-2 focus:bg-muted/85 focus:text-foreground"
|
className="flex min-h-[54px] cursor-default items-center gap-3 px-2.5 py-2 focus:bg-muted/85 focus:text-foreground"
|
||||||
>
|
>
|
||||||
<ProviderIcon
|
<ProviderIcon
|
||||||
@@ -1300,7 +1356,7 @@ export function ProvidersSettings({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderUnconfiguredProviders(
|
function orderProviderPickerOptions(
|
||||||
providers: SettingsPayload["providers"],
|
providers: SettingsPayload["providers"],
|
||||||
): SettingsPayload["providers"] {
|
): SettingsPayload["providers"] {
|
||||||
return providers
|
return providers
|
||||||
@@ -1313,7 +1369,7 @@ function orderUnconfiguredProviders(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function providerVisibilityRank(provider: SettingsPayload["providers"][number]): number {
|
function providerVisibilityRank(provider: SettingsPayload["providers"][number]): number {
|
||||||
const localRank = LOCAL_UNCONFIGURED_PROVIDER_ORDER.get(provider.name);
|
const localRank = LOCAL_PROVIDER_ORDER.get(provider.name);
|
||||||
if (localRank !== undefined) return localRank;
|
if (localRank !== undefined) return localRank;
|
||||||
if ((provider.api_key_required ?? true) === false) return 100;
|
if ((provider.api_key_required ?? true) === false) return 100;
|
||||||
return 200;
|
return 200;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
import { Check, Cloud, KeyRound, Laptop } from "lucide-react";
|
import { Check, Cloud, KeyRound, Laptop } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@@ -9,14 +10,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { ModelSetupAvailability, ModelSetupIntent } from "@/lib/model-setup";
|
||||||
export interface ModelSetupAvailability {
|
|
||||||
account: boolean;
|
|
||||||
apiKey: boolean;
|
|
||||||
local: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ModelSetupIntent = keyof ModelSetupAvailability;
|
|
||||||
|
|
||||||
const SETUP_OPTIONS = [
|
const SETUP_OPTIONS = [
|
||||||
{
|
{
|
||||||
@@ -59,14 +53,22 @@ export function ModelSetupDialog({
|
|||||||
onSelect: (intent: ModelSetupIntent) => void;
|
onSelect: (intent: ModelSetupIntent) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const selectedRef = useRef(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(nextOpen) => {
|
||||||
|
if (nextOpen) selectedRef.current = false;
|
||||||
|
onOpenChange(nextOpen);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-w-md gap-5 p-5 sm:p-6"
|
className="max-w-md gap-5 p-5 sm:p-6"
|
||||||
onCloseAutoFocus={(event) => {
|
onCloseAutoFocus={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
onReturnFocus();
|
if (!selectedRef.current) onReturnFocus();
|
||||||
|
selectedRef.current = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogHeader className="pr-7">
|
<DialogHeader className="pr-7">
|
||||||
@@ -89,7 +91,10 @@ export function ModelSetupDialog({
|
|||||||
key={option.intent}
|
key={option.intent}
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t(option.titleKey, { defaultValue: option.title })}
|
aria-label={t(option.titleKey, { defaultValue: option.title })}
|
||||||
onClick={() => onSelect(option.intent)}
|
onClick={() => {
|
||||||
|
selectedRef.current = true;
|
||||||
|
onSelect(option.intent);
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group flex min-h-[68px] w-full items-center gap-3 rounded-control border border-border/55 bg-background px-3.5 py-3 text-left",
|
"group flex min-h-[68px] w-full items-center gap-3 rounded-control border border-border/55 bg-background px-3.5 py-3 text-left",
|
||||||
"transition-[background-color,border-color,transform] duration-150 ease-out hover:border-border hover:bg-muted/45 active:scale-[0.99]",
|
"transition-[background-color,border-color,transform] duration-150 ease-out hover:border-border hover:bg-muted/45 active:scale-[0.99]",
|
||||||
|
|||||||
@@ -70,10 +70,7 @@ import {
|
|||||||
ModelPresetBadge,
|
ModelPresetBadge,
|
||||||
type ModelPresetOption,
|
type ModelPresetOption,
|
||||||
} from "@/components/thread/ModelPresetBadge";
|
} from "@/components/thread/ModelPresetBadge";
|
||||||
import {
|
import { ModelSetupDialog } from "@/components/thread/ModelSetupDialog";
|
||||||
ModelSetupDialog,
|
|
||||||
type ModelSetupAvailability,
|
|
||||||
} from "@/components/thread/ModelSetupDialog";
|
|
||||||
import {
|
import {
|
||||||
ACCEPT_ATTR,
|
ACCEPT_ATTR,
|
||||||
MAX_ATTACHMENTS_PER_MESSAGE,
|
MAX_ATTACHMENTS_PER_MESSAGE,
|
||||||
@@ -117,6 +114,7 @@ import {
|
|||||||
} from "@/lib/session-drag";
|
} from "@/lib/session-drag";
|
||||||
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
||||||
import { formatCompactTokenCount } from "@/lib/format";
|
import { formatCompactTokenCount } from "@/lib/format";
|
||||||
|
import type { ModelSetupAvailability, ModelSetupIntent } from "@/lib/model-setup";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const VOICE_SHORTCUT_CODE = "KeyD";
|
const VOICE_SHORTCUT_CODE = "KeyD";
|
||||||
@@ -304,7 +302,7 @@ interface ThreadComposerProps {
|
|||||||
modelNeedsSetup?: boolean;
|
modelNeedsSetup?: boolean;
|
||||||
modelSetupAvailability?: ModelSetupAvailability;
|
modelSetupAvailability?: ModelSetupAvailability;
|
||||||
fallbackModelName?: string | null;
|
fallbackModelName?: string | null;
|
||||||
onModelBadgeClick?: () => void;
|
onModelBadgeClick?: (intent?: ModelSetupIntent) => void;
|
||||||
onManageModels?: () => void;
|
onManageModels?: () => void;
|
||||||
contextUsage?: ComposerContextUsage | null;
|
contextUsage?: ComposerContextUsage | null;
|
||||||
variant?: "thread" | "hero";
|
variant?: "thread" | "hero";
|
||||||
@@ -2137,9 +2135,9 @@ export function ThreadComposer({
|
|||||||
setModelSetupOpen(true);
|
setModelSetupOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const continueModelSetup = useCallback(() => {
|
const continueModelSetup = useCallback((intent: ModelSetupIntent) => {
|
||||||
setModelSetupOpen(false);
|
setModelSetupOpen(false);
|
||||||
onModelBadgeClick?.();
|
onModelBadgeClick?.(intent);
|
||||||
}, [onModelBadgeClick]);
|
}, [onModelBadgeClick]);
|
||||||
|
|
||||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
type ComposerContextUsage,
|
type ComposerContextUsage,
|
||||||
} from "@/components/thread/ThreadComposer";
|
} from "@/components/thread/ThreadComposer";
|
||||||
import type { ModelPresetOption } from "@/components/thread/ModelPresetBadge";
|
import type { ModelPresetOption } from "@/components/thread/ModelPresetBadge";
|
||||||
import type { ModelSetupAvailability } from "@/components/thread/ModelSetupDialog";
|
|
||||||
import { ThreadHeader } from "@/components/thread/ThreadHeader";
|
import { ThreadHeader } from "@/components/thread/ThreadHeader";
|
||||||
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
|
||||||
import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport";
|
import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport";
|
||||||
@@ -40,6 +39,10 @@ import {
|
|||||||
} from "@/lib/mcp-preset-events";
|
} from "@/lib/mcp-preset-events";
|
||||||
import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
|
import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
|
||||||
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
||||||
|
import {
|
||||||
|
modelSetupAvailability,
|
||||||
|
type ModelSetupIntent,
|
||||||
|
} from "@/lib/model-setup";
|
||||||
import type {
|
import type {
|
||||||
ChatSummary,
|
ChatSummary,
|
||||||
SettingsPayload,
|
SettingsPayload,
|
||||||
@@ -355,6 +358,7 @@ interface ThreadShellProps {
|
|||||||
composerPortalTarget?: HTMLElement | null;
|
composerPortalTarget?: HTMLElement | null;
|
||||||
composerActive?: boolean;
|
composerActive?: boolean;
|
||||||
composerInputAriaLabel?: string;
|
composerInputAriaLabel?: string;
|
||||||
|
focusComposerRequest?: number;
|
||||||
emptyComposerVariant?: "hero" | "thread";
|
emptyComposerVariant?: "hero" | "thread";
|
||||||
workspaceScope?: WorkspaceScopePayload | null;
|
workspaceScope?: WorkspaceScopePayload | null;
|
||||||
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
||||||
@@ -363,7 +367,7 @@ interface ThreadShellProps {
|
|||||||
workspaceError?: string | null;
|
workspaceError?: string | null;
|
||||||
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
||||||
settingsSnapshot?: SettingsPayload | null;
|
settingsSnapshot?: SettingsPayload | null;
|
||||||
onOpenModelSettings?: () => void;
|
onOpenModelSettings?: (intent?: ModelSetupIntent) => void;
|
||||||
skills?: SkillSummary[];
|
skills?: SkillSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,22 +387,6 @@ interface ModelBadgeInfo {
|
|||||||
needsSetup: boolean;
|
needsSetup: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOCAL_MODEL_PROVIDERS = new Set(["atomic_chat", "lm_studio", "ollama", "vllm"]);
|
|
||||||
|
|
||||||
function modelSetupAvailability(settings: SettingsPayload | null): ModelSetupAvailability {
|
|
||||||
const configured = settings?.providers.filter((provider) => provider.configured) ?? [];
|
|
||||||
const isLocal = (provider: SettingsPayload["providers"][number]) => {
|
|
||||||
if (LOCAL_MODEL_PROVIDERS.has(provider.name)) return true;
|
|
||||||
const apiBase = provider.api_base?.trim().toLowerCase() ?? "";
|
|
||||||
return apiBase.includes("localhost") || apiBase.includes("127.0.0.1") || apiBase.includes("[::1]");
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
account: configured.some((provider) => provider.auth_type === "oauth"),
|
|
||||||
apiKey: configured.some((provider) => provider.auth_type !== "oauth" && !isLocal(provider)),
|
|
||||||
local: configured.some(isLocal),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function modelPresetForBadge(
|
function modelPresetForBadge(
|
||||||
settings: SettingsPayload | null,
|
settings: SettingsPayload | null,
|
||||||
scopedPreset: string | null,
|
scopedPreset: string | null,
|
||||||
@@ -671,6 +659,7 @@ export function ThreadShell({
|
|||||||
composerPortalTarget,
|
composerPortalTarget,
|
||||||
composerActive = true,
|
composerActive = true,
|
||||||
composerInputAriaLabel,
|
composerInputAriaLabel,
|
||||||
|
focusComposerRequest = 0,
|
||||||
emptyComposerVariant = "hero",
|
emptyComposerVariant = "hero",
|
||||||
workspaceScope = null,
|
workspaceScope = null,
|
||||||
workspaceDefaultScope = null,
|
workspaceDefaultScope = null,
|
||||||
@@ -980,7 +969,10 @@ export function ThreadShell({
|
|||||||
const modelBadgeLabel = modelBadge.needsSetup
|
const modelBadgeLabel = modelBadge.needsSetup
|
||||||
? t("thread.composer.chooseAI", { defaultValue: "Choose your AI" })
|
? t("thread.composer.chooseAI", { defaultValue: "Choose your AI" })
|
||||||
: modelBadge.label;
|
: modelBadge.label;
|
||||||
const setupAvailability = useMemo(() => modelSetupAvailability(settings), [settings]);
|
const setupAvailability = useMemo(
|
||||||
|
() => modelSetupAvailability(settings?.providers),
|
||||||
|
[settings?.providers],
|
||||||
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
|
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
|
||||||
setHeroGreetingKey(randomHeroGreetingKey());
|
setHeroGreetingKey(randomHeroGreetingKey());
|
||||||
@@ -1563,7 +1555,7 @@ export function ThreadShell({
|
|||||||
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
||||||
ingressLimits={ingressLimits}
|
ingressLimits={ingressLimits}
|
||||||
quotedContext={quotedContext}
|
quotedContext={quotedContext}
|
||||||
focusRequest={composerFocusSignal}
|
focusRequest={composerFocusSignal + focusComposerRequest}
|
||||||
onQuotedContextChange={setQuotedContext}
|
onQuotedContextChange={setQuotedContext}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -1611,6 +1603,7 @@ export function ThreadShell({
|
|||||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||||
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
||||||
ingressLimits={ingressLimits}
|
ingressLimits={ingressLimits}
|
||||||
|
focusRequest={composerFocusSignal + focusComposerRequest}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { SettingsPayload } from "@/lib/types";
|
||||||
|
|
||||||
|
export type ModelSetupIntent = "account" | "apiKey" | "local";
|
||||||
|
|
||||||
|
export type ModelSetupAvailability = Record<ModelSetupIntent, boolean>;
|
||||||
|
|
||||||
|
type Provider = SettingsPayload["providers"][number];
|
||||||
|
|
||||||
|
const LOCAL_MODEL_PROVIDERS = new Set([
|
||||||
|
"atomic_chat",
|
||||||
|
"lm_studio",
|
||||||
|
"ollama",
|
||||||
|
"ovms",
|
||||||
|
"vllm",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isLocalModelProvider(provider: Provider): boolean {
|
||||||
|
if (LOCAL_MODEL_PROVIDERS.has(provider.name)) return true;
|
||||||
|
const apiBase = provider.api_base?.trim().toLowerCase() ?? "";
|
||||||
|
return (
|
||||||
|
apiBase.includes("localhost")
|
||||||
|
|| apiBase.includes("127.0.0.1")
|
||||||
|
|| apiBase.includes("[::1]")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelSetupIntentForProvider(provider: Provider): ModelSetupIntent {
|
||||||
|
if (provider.auth_type === "oauth") return "account";
|
||||||
|
return isLocalModelProvider(provider) ? "local" : "apiKey";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function providerMatchesModelSetupIntent(
|
||||||
|
provider: Provider,
|
||||||
|
intent: ModelSetupIntent,
|
||||||
|
): boolean {
|
||||||
|
return modelSetupIntentForProvider(provider) === intent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modelSetupAvailability(
|
||||||
|
providers: SettingsPayload["providers"] | null | undefined,
|
||||||
|
): ModelSetupAvailability {
|
||||||
|
const configured = providers?.filter((provider) => provider.configured) ?? [];
|
||||||
|
return {
|
||||||
|
account: configured.some((provider) => modelSetupIntentForProvider(provider) === "account"),
|
||||||
|
apiKey: configured.some((provider) => modelSetupIntentForProvider(provider) === "apiKey"),
|
||||||
|
local: configured.some((provider) => modelSetupIntentForProvider(provider) === "local"),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -481,6 +481,67 @@ describe("App layout", () => {
|
|||||||
expect(screen.getByRole("heading", { level: 1, name: "Settings" })).toBeInTheDocument();
|
expect(screen.getByRole("heading", { level: 1, name: "Settings" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries a first-run setup path into settings and restores composer focus", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const base = baseSettingsPayload();
|
||||||
|
mockFetchRoutes({
|
||||||
|
"/api/settings": {
|
||||||
|
...base,
|
||||||
|
agent: {
|
||||||
|
...base.agent,
|
||||||
|
model: "",
|
||||||
|
resolved_provider: "",
|
||||||
|
has_api_key: false,
|
||||||
|
model_preset: "",
|
||||||
|
},
|
||||||
|
model_presets: [],
|
||||||
|
model_call_order: [],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
name: "openai_codex",
|
||||||
|
label: "OpenAI Codex",
|
||||||
|
configured: false,
|
||||||
|
auth_type: "oauth",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deepseek",
|
||||||
|
label: "DeepSeek",
|
||||||
|
configured: false,
|
||||||
|
auth_type: "api_key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ollama",
|
||||||
|
label: "Ollama",
|
||||||
|
configured: false,
|
||||||
|
api_base: "http://127.0.0.1:11434",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<App />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Choose your AI" }));
|
||||||
|
await user.click(await screen.findByRole("button", { name: "Run locally" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByRole("navigation", { name: "Settings sections" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
const providerMenu = await screen.findByRole("menu");
|
||||||
|
expect(within(providerMenu).getByRole("menuitem", { name: "Ollama" }))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
expect(within(providerMenu).queryByRole("menuitem", { name: "OpenAI Codex" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
expect(within(providerMenu).queryByRole("menuitem", { name: "DeepSeek" }))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const composer = screen.getByRole("textbox", { name: "Message input" });
|
||||||
|
expect(composer).not.toHaveFocus();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Back to chat" }));
|
||||||
|
await waitFor(() => expect(composer).toHaveFocus());
|
||||||
|
});
|
||||||
|
|
||||||
it("places Automations after Skills in the main sidebar", async () => {
|
it("places Automations after Skills in the main sidebar", async () => {
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,48 @@ async function chooseProviderToConfigure(label: string) {
|
|||||||
describe("Settings providers", () => {
|
describe("Settings providers", () => {
|
||||||
installSettingsViewTestHooks();
|
installSettingsViewTestHooks();
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["account", "OpenAI Codex", ["DeepSeek", "Ollama", "Custom provider"]],
|
||||||
|
["apiKey", "DeepSeek", ["OpenAI Codex", "Ollama"]],
|
||||||
|
["local", "Ollama", ["OpenAI Codex", "DeepSeek", "Custom provider"]],
|
||||||
|
] as const)("opens the %s first-run provider path", async (intent, expected, excluded) => {
|
||||||
|
const base = settingsPayload();
|
||||||
|
const payload: SettingsPayload = {
|
||||||
|
...base,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
name: "openai_codex",
|
||||||
|
label: "OpenAI Codex",
|
||||||
|
configured: false,
|
||||||
|
auth_type: "oauth",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deepseek",
|
||||||
|
label: "DeepSeek",
|
||||||
|
configured: false,
|
||||||
|
auth_type: "api_key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ollama",
|
||||||
|
label: "Ollama",
|
||||||
|
configured: false,
|
||||||
|
api_base: "http://127.0.0.1:11434",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
renderSettingsView({
|
||||||
|
initialSection: "models",
|
||||||
|
initialSettings: payload,
|
||||||
|
modelSetupIntent: intent,
|
||||||
|
});
|
||||||
|
|
||||||
|
const menu = await screen.findByRole("menu");
|
||||||
|
expect(within(menu).getByRole("menuitem", { name: expected })).toBeInTheDocument();
|
||||||
|
for (const label of excluded) {
|
||||||
|
expect(within(menu).queryByRole("menuitem", { name: label })).not.toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("signs in to the xAI Grok provider", async () => {
|
it("signs in to the xAI Grok provider", async () => {
|
||||||
const base = settingsPayload();
|
const base = settingsPayload();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import { afterEach, beforeEach, vi } from "vitest";
|
import { afterEach, beforeEach, vi } from "vitest";
|
||||||
import { SettingsView } from "@/components/settings/SettingsView";
|
import { SettingsView } from "@/components/settings/SettingsView";
|
||||||
import { ClientProvider } from "@/providers/ClientProvider";
|
import { ClientProvider } from "@/providers/ClientProvider";
|
||||||
|
import type { ModelSetupIntent } from "@/lib/model-setup";
|
||||||
import type { SettingsPayload } from "@/lib/types";
|
import type { SettingsPayload } from "@/lib/types";
|
||||||
|
|
||||||
export const requestMutationMock = vi.fn();
|
export const requestMutationMock = vi.fn();
|
||||||
@@ -137,6 +138,7 @@ export function renderSettingsView(
|
|||||||
| "browser"
|
| "browser"
|
||||||
| "runtime";
|
| "runtime";
|
||||||
initialSettings?: SettingsPayload;
|
initialSettings?: SettingsPayload;
|
||||||
|
modelSetupIntent?: ModelSetupIntent;
|
||||||
showSidebar?: boolean;
|
showSidebar?: boolean;
|
||||||
onBackToChat?: () => void;
|
onBackToChat?: () => void;
|
||||||
onSettingsChange?: (payload: SettingsPayload) => void;
|
onSettingsChange?: (payload: SettingsPayload) => void;
|
||||||
@@ -149,6 +151,7 @@ export function renderSettingsView(
|
|||||||
theme="light"
|
theme="light"
|
||||||
initialSection={options.initialSection ?? "apps"}
|
initialSection={options.initialSection ?? "apps"}
|
||||||
initialSettings={options.initialSettings}
|
initialSettings={options.initialSettings}
|
||||||
|
modelSetupIntent={options.modelSetupIntent}
|
||||||
showSidebar={options.showSidebar}
|
showSidebar={options.showSidebar}
|
||||||
onToggleTheme={() => {}}
|
onToggleTheme={() => {}}
|
||||||
onBackToChat={options.onBackToChat ?? (() => {})}
|
onBackToChat={options.onBackToChat ?? (() => {})}
|
||||||
|
|||||||
@@ -887,6 +887,7 @@ describe("ThreadShell", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||||
|
|
||||||
const input = screen.getByRole("textbox", { name: "Message input" });
|
const input = screen.getByRole("textbox", { name: "Message input" });
|
||||||
|
await waitFor(() => expect(input).toHaveFocus());
|
||||||
fireEvent.change(input, {
|
fireEvent.change(input, {
|
||||||
target: { value: "hello" },
|
target: { value: "hello" },
|
||||||
});
|
});
|
||||||
@@ -896,12 +897,37 @@ describe("ThreadShell", () => {
|
|||||||
expect(input).toHaveValue("hello");
|
expect(input).toHaveValue("hello");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Use an API key" }));
|
fireEvent.click(screen.getByRole("button", { name: "Use an API key" }));
|
||||||
|
|
||||||
expect(onOpenModelSettings).toHaveBeenCalledTimes(1);
|
expect(onOpenModelSettings).toHaveBeenCalledWith("apiKey");
|
||||||
expect(input).toHaveValue("hello");
|
expect(input).toHaveValue("hello");
|
||||||
await waitFor(() => expect(input).toHaveFocus());
|
expect(input).not.toHaveFocus();
|
||||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("focuses the composer when returning from model setup", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
const settings = modelSettings("openai-codex/gpt-5.1-codex", "openai_codex");
|
||||||
|
settings.agent.has_api_key = false;
|
||||||
|
const view = (focusComposerRequest: number) => wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("setup-focus")}
|
||||||
|
title="Setup focus"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
settingsSnapshot={settings}
|
||||||
|
focusComposerRequest={focusComposerRequest}
|
||||||
|
/>,
|
||||||
|
"openai-codex/gpt-5.1-codex",
|
||||||
|
);
|
||||||
|
const { rerender } = render(view(0));
|
||||||
|
const input = await screen.findByRole("textbox", { name: "Message input" });
|
||||||
|
input.blur();
|
||||||
|
expect(input).not.toHaveFocus();
|
||||||
|
|
||||||
|
rerender(view(1));
|
||||||
|
|
||||||
|
await waitFor(() => expect(input).toHaveFocus());
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps image generation controls out of the composer", async () => {
|
it("keeps image generation controls out of the composer", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
const disabledSettings = modelSettings("deepseek-v4-pro", "deepseek");
|
const disabledSettings = modelSettings("deepseek-v4-pro", "deepseek");
|
||||||
|
|||||||
Reference in New Issue
Block a user