mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-02 01:01:52 +03:00
refactor(webui): open models directly for first-run setup
This commit is contained in:
+4
-20
@@ -57,7 +57,6 @@ import {
|
||||
} from "@/lib/bootstrap";
|
||||
import { displayTitle, sortSessions } from "@/lib/chat-groups";
|
||||
import { deriveTitle } from "@/lib/format";
|
||||
import type { ModelSetupIntent } from "@/lib/model-setup";
|
||||
import { NanobotClient } from "@/lib/nanobot-client";
|
||||
import { ClientProvider, useClient } from "@/providers/ClientProvider";
|
||||
import type {
|
||||
@@ -1054,8 +1053,6 @@ function Shell({
|
||||
const [temporaryChatEnabled, setTemporaryChatEnabled] = useState(false);
|
||||
const [settingsInitialSection, setSettingsInitialSection] =
|
||||
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
|
||||
const [modelSetupIntent, setModelSetupIntent] = useState<ModelSetupIntent | null>(null);
|
||||
const [chatFocusRequest, setChatFocusRequest] = useState(0);
|
||||
const [hostSidebarOpen, setHostSidebarOpen] =
|
||||
useState<boolean>(readSidebarOpen);
|
||||
const [hostSidebarPreviewOpen, setHostSidebarPreviewOpen] = useState(false);
|
||||
@@ -1984,12 +1981,8 @@ function Shell({
|
||||
[onSelectChat],
|
||||
);
|
||||
|
||||
const onOpenSettings = useCallback((
|
||||
section: SettingsSectionKey = "overview",
|
||||
setupIntent: ModelSetupIntent | null = null,
|
||||
) => {
|
||||
const onOpenSettings = useCallback((section: SettingsSectionKey = "overview") => {
|
||||
setSessionSearchOpen(false);
|
||||
setModelSetupIntent(setupIntent);
|
||||
navigate({ view: "settings", activeKey, settingsSection: section });
|
||||
setMobileSidebarOpen(false);
|
||||
}, [activeKey, navigate]);
|
||||
@@ -1998,8 +1991,8 @@ function Shell({
|
||||
void loadSettingsView();
|
||||
}, []);
|
||||
|
||||
const onOpenModelSettings = useCallback((intent?: ModelSetupIntent) => {
|
||||
onOpenSettings("models", intent ?? null);
|
||||
const onOpenModelSettings = useCallback(() => {
|
||||
onOpenSettings("models");
|
||||
}, [onOpenSettings]);
|
||||
|
||||
const onOpenApps = useCallback(() => {
|
||||
@@ -2022,7 +2015,6 @@ function Shell({
|
||||
|
||||
const onSettingsSectionChange = useCallback(
|
||||
(section: SettingsSectionKey) => {
|
||||
setModelSetupIntent(null);
|
||||
navigate({
|
||||
view: shellViewForSettingsSection(section),
|
||||
activeKey,
|
||||
@@ -2033,9 +2025,7 @@ function Shell({
|
||||
);
|
||||
|
||||
const onBackToChat = useCallback(() => {
|
||||
const restoreComposerFocus = modelSetupIntent !== null;
|
||||
setMobileSidebarOpen(false);
|
||||
setModelSetupIntent(null);
|
||||
const nextKey = (() => {
|
||||
if (!activeKey) return null;
|
||||
if (topicSessions.some((session) => session.key === activeKey)) return activeKey;
|
||||
@@ -2046,10 +2036,7 @@ function Shell({
|
||||
activeKey: nextKey,
|
||||
settingsSection: "overview",
|
||||
});
|
||||
if (restoreComposerFocus) {
|
||||
setChatFocusRequest((value) => value + 1);
|
||||
}
|
||||
}, [activeKey, modelSetupIntent, navigate, topicSessions]);
|
||||
}, [activeKey, navigate, topicSessions]);
|
||||
|
||||
const onRestart = useCallback(() => {
|
||||
const chatId = activeSession?.chatId ?? client.defaultChatId;
|
||||
@@ -2805,7 +2792,6 @@ function Shell({
|
||||
onWorkspaceScopeChange={applyWorkspaceScope}
|
||||
settingsSnapshot={settingsSnapshot}
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
focusComposerRequest={chatFocusRequest}
|
||||
skills={skills}
|
||||
/>
|
||||
);
|
||||
@@ -2864,7 +2850,6 @@ function Shell({
|
||||
}}
|
||||
settingsSnapshot={settingsSnapshot}
|
||||
onOpenModelSettings={onOpenModelSettings}
|
||||
focusComposerRequest={context.active ? chatFocusRequest : 0}
|
||||
skills={skills}
|
||||
/>
|
||||
);
|
||||
@@ -2878,7 +2863,6 @@ function Shell({
|
||||
theme={theme}
|
||||
initialSection={settingsInitialSection}
|
||||
initialSettings={settingsSnapshot}
|
||||
modelSetupIntent={modelSetupIntent}
|
||||
showSidebar={view === "settings"}
|
||||
onToggleTheme={toggle}
|
||||
onBackToChat={onBackToChat}
|
||||
|
||||
@@ -31,12 +31,10 @@ import { ChannelsSettings } from "@/components/settings/system/ChannelsSettings"
|
||||
import { RuntimeSettings } from "@/components/settings/system/RuntimeSettings";
|
||||
import type { SettingsController } from "@/components/settings/useSettingsController";
|
||||
import type { SkillSummary } from "@/lib/types";
|
||||
import type { ModelSetupIntent } from "@/lib/model-setup";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SettingsPageProps {
|
||||
controller: SettingsController;
|
||||
modelSetupIntent: ModelSetupIntent | null;
|
||||
theme: "light" | "dark";
|
||||
showSidebar: boolean;
|
||||
onToggleTheme: () => void;
|
||||
@@ -49,7 +47,6 @@ interface SettingsPageProps {
|
||||
|
||||
export function SettingsPage({
|
||||
controller,
|
||||
modelSetupIntent,
|
||||
theme,
|
||||
showSidebar,
|
||||
onToggleTheme,
|
||||
@@ -286,7 +283,6 @@ export function SettingsPage({
|
||||
providerSaving={providerSaving}
|
||||
showBrandLogos={localPrefs.brandLogos}
|
||||
remoteBrowserAccess={remoteBrowserAccess}
|
||||
setupIntent={modelSetupIntent}
|
||||
onToggleProvider={handleToggleProvider}
|
||||
onToggleProviderKey={toggleProviderKeyVisibility}
|
||||
onToggleProviderKeyEditing={toggleProviderKeyEditing}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { SettingsPage } from "@/components/settings/SettingsPage";
|
||||
import type { SettingsSectionKey } from "@/components/settings/contracts";
|
||||
import { useSettingsController } from "@/components/settings/useSettingsController";
|
||||
import type { ModelSetupIntent } from "@/lib/model-setup";
|
||||
import type { SettingsPayload, SkillSummary } from "@/lib/types";
|
||||
|
||||
export type { SettingsSectionKey } from "@/components/settings/contracts";
|
||||
@@ -10,7 +9,6 @@ interface SettingsViewProps {
|
||||
theme: "light" | "dark";
|
||||
initialSection?: SettingsSectionKey;
|
||||
initialSettings?: SettingsPayload | null;
|
||||
modelSetupIntent?: ModelSetupIntent | null;
|
||||
showSidebar?: boolean;
|
||||
onToggleTheme: () => void;
|
||||
onBackToChat: () => void;
|
||||
@@ -29,7 +27,6 @@ export function SettingsView({
|
||||
theme,
|
||||
initialSection = "overview",
|
||||
initialSettings = null,
|
||||
modelSetupIntent = null,
|
||||
showSidebar = true,
|
||||
onToggleTheme,
|
||||
onBackToChat,
|
||||
@@ -56,7 +53,6 @@ export function SettingsView({
|
||||
return (
|
||||
<SettingsPage
|
||||
controller={controller}
|
||||
modelSetupIntent={modelSetupIntent}
|
||||
theme={theme}
|
||||
showSidebar={showSidebar}
|
||||
onToggleTheme={onToggleTheme}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
Clipboard,
|
||||
@@ -42,10 +42,6 @@ import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
||||
import { providerBrand } from "@/lib/provider-brand";
|
||||
import {
|
||||
providerMatchesModelSetupIntent,
|
||||
type ModelSetupIntent,
|
||||
} from "@/lib/model-setup";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
NanobotFeaturesPayload,
|
||||
@@ -236,17 +232,12 @@ const OPENAI_API_TYPE_OPTIONS: Array<{ value: ProviderApiType; label: string }>
|
||||
{ value: "responses", label: "Responses" },
|
||||
];
|
||||
|
||||
const LOCAL_PROVIDER_ORDER = new Map(
|
||||
const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
|
||||
["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [
|
||||
name,
|
||||
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({
|
||||
flow,
|
||||
@@ -664,7 +655,6 @@ export function ProvidersSettings({
|
||||
providerSaving,
|
||||
showBrandLogos,
|
||||
remoteBrowserAccess,
|
||||
setupIntent,
|
||||
onToggleProvider,
|
||||
onToggleProviderKey,
|
||||
onToggleProviderKeyEditing,
|
||||
@@ -688,7 +678,6 @@ export function ProvidersSettings({
|
||||
providerSaving: string | null;
|
||||
showBrandLogos: boolean;
|
||||
remoteBrowserAccess: boolean;
|
||||
setupIntent?: ModelSetupIntent | null;
|
||||
onToggleProvider: (provider: string) => void;
|
||||
onToggleProviderKey: (provider: string) => void;
|
||||
onToggleProviderKeyEditing: (provider: string) => void;
|
||||
@@ -708,57 +697,23 @@ export function ProvidersSettings({
|
||||
const [customProviderDraft, setCustomProviderDraft] = useState<CustomProviderDraft>(
|
||||
emptyCustomProviderDraft,
|
||||
);
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const [setupPickerOpen, setSetupPickerOpen] = useState(false);
|
||||
const configuredProviders = settings.providers.filter((provider) => provider.configured);
|
||||
const unconfiguredProviders = useMemo(
|
||||
() =>
|
||||
orderProviderPickerOptions(
|
||||
orderUnconfiguredProviders(
|
||||
settings.providers.filter(
|
||||
(provider) => !provider.configured && provider.name !== "custom",
|
||||
),
|
||||
),
|
||||
[settings.providers],
|
||||
);
|
||||
const providerPickerOptions = useMemo(
|
||||
() => setupIntent
|
||||
? orderProviderPickerOptions(
|
||||
settings.providers.filter(
|
||||
(provider) =>
|
||||
provider.name !== "custom"
|
||||
&& providerMatchesModelSetupIntent(provider, setupIntent),
|
||||
),
|
||||
)
|
||||
: unconfiguredProviders,
|
||||
[settings.providers, setupIntent, unconfiguredProviders],
|
||||
);
|
||||
const selectedUnconfiguredProvider =
|
||||
unconfiguredProviders.find((provider) => provider.name === expandedProvider) ?? null;
|
||||
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) => {
|
||||
setCreatingCustomProvider(false);
|
||||
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 = () => {
|
||||
if (expandedProvider) onToggleProvider(expandedProvider);
|
||||
setCustomProviderDraft(emptyCustomProviderDraft());
|
||||
@@ -821,11 +776,7 @@ export function ProvidersSettings({
|
||||
? (nanobotFeatures?.features ?? []).find((feature) => feature.name === supportName)
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={provider.name}
|
||||
id={`settings-provider-${provider.name}`}
|
||||
className="divide-y divide-border/45"
|
||||
>
|
||||
<div key={provider.name} className="divide-y divide-border/45">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
@@ -1271,7 +1222,7 @@ export function ProvidersSettings({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<section ref={sectionRef}>
|
||||
<section>
|
||||
<SettingsSectionTitle>
|
||||
{tx("settings.providers.title", "Model providers")}
|
||||
</SettingsSectionTitle>
|
||||
@@ -1282,12 +1233,7 @@ export function ProvidersSettings({
|
||||
: null}
|
||||
{customProviderForm}
|
||||
{!expandedProvider && !creatingCustomProvider ? (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
{...(setupIntent
|
||||
? { open: setupPickerOpen, onOpenChange: setSetupPickerOpen }
|
||||
: {})}
|
||||
>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1298,12 +1244,10 @@ export function ProvidersSettings({
|
||||
<Plus className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
<span className="truncate text-[15px] font-semibold text-foreground">
|
||||
{setupIntent
|
||||
? t(MODEL_SETUP_TITLE_KEYS[setupIntent])
|
||||
: tx(
|
||||
"settings.providers.addOwnProvider",
|
||||
"Add your own model provider",
|
||||
)}
|
||||
{tx(
|
||||
"settings.providers.addOwnProvider",
|
||||
"Add your own model provider",
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
@@ -1317,25 +1261,25 @@ export function ProvidersSettings({
|
||||
sideOffset={8}
|
||||
className="max-h-[24rem] w-[380px] max-w-[calc(100vw-2rem)] overflow-y-auto scrollbar-thin scrollbar-track-transparent"
|
||||
>
|
||||
{!setupIntent || setupIntent === "apiKey" ? (
|
||||
<DropdownMenuItem
|
||||
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"
|
||||
>
|
||||
<ProviderIcon provider="custom" showBrandLogos={showBrandLogos} />
|
||||
<span className="truncate text-[13px] font-medium">
|
||||
{tx("settings.providers.customProvider", "Custom provider")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{providerPickerOptions.length > 0
|
||||
&& (!setupIntent || setupIntent === "apiKey")
|
||||
? <DropdownMenuSeparator />
|
||||
: null}
|
||||
{providerPickerOptions.map((provider) => (
|
||||
<DropdownMenuItem
|
||||
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"
|
||||
>
|
||||
<ProviderIcon provider="custom" showBrandLogos={showBrandLogos} />
|
||||
<span className="truncate text-[13px] font-medium">
|
||||
{tx("settings.providers.customProvider", "Custom provider")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
{unconfiguredProviders.length > 0 ? <DropdownMenuSeparator /> : null}
|
||||
{unconfiguredProviders.map((provider) => (
|
||||
<DropdownMenuItem
|
||||
key={provider.name}
|
||||
onSelect={() => chooseProvider(provider.name)}
|
||||
onSelect={() => {
|
||||
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"
|
||||
>
|
||||
<ProviderIcon
|
||||
@@ -1356,7 +1300,7 @@ export function ProvidersSettings({
|
||||
);
|
||||
}
|
||||
|
||||
function orderProviderPickerOptions(
|
||||
function orderUnconfiguredProviders(
|
||||
providers: SettingsPayload["providers"],
|
||||
): SettingsPayload["providers"] {
|
||||
return providers
|
||||
@@ -1369,7 +1313,7 @@ function orderProviderPickerOptions(
|
||||
}
|
||||
|
||||
function providerVisibilityRank(provider: SettingsPayload["providers"][number]): number {
|
||||
const localRank = LOCAL_PROVIDER_ORDER.get(provider.name);
|
||||
const localRank = LOCAL_UNCONFIGURED_PROVIDER_ORDER.get(provider.name);
|
||||
if (localRank !== undefined) return localRank;
|
||||
if ((provider.api_key_required ?? true) === false) return 100;
|
||||
return 200;
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { useRef } from "react";
|
||||
import { Check, Cloud, KeyRound, Laptop } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ModelSetupAvailability, ModelSetupIntent } from "@/lib/model-setup";
|
||||
|
||||
const SETUP_OPTIONS = [
|
||||
{
|
||||
intent: "account",
|
||||
icon: Cloud,
|
||||
titleKey: "thread.composer.modelSetup.account.title",
|
||||
title: "Connect an account",
|
||||
descriptionKey: "thread.composer.modelSetup.account.description",
|
||||
description: "Use a supported AI subscription.",
|
||||
},
|
||||
{
|
||||
intent: "apiKey",
|
||||
icon: KeyRound,
|
||||
titleKey: "thread.composer.modelSetup.apiKey.title",
|
||||
title: "Use an API key",
|
||||
descriptionKey: "thread.composer.modelSetup.apiKey.description",
|
||||
description: "Bring a key from your preferred provider.",
|
||||
},
|
||||
{
|
||||
intent: "local",
|
||||
icon: Laptop,
|
||||
titleKey: "thread.composer.modelSetup.local.title",
|
||||
title: "Run locally",
|
||||
descriptionKey: "thread.composer.modelSetup.local.description",
|
||||
description: "Connect Ollama, LM Studio, or vLLM.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function ModelSetupDialog({
|
||||
availability,
|
||||
open,
|
||||
onOpenChange,
|
||||
onReturnFocus,
|
||||
onSelect,
|
||||
}: {
|
||||
availability: ModelSetupAvailability;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onReturnFocus: () => void;
|
||||
onSelect: (intent: ModelSetupIntent) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const selectedRef = useRef(false);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen) selectedRef.current = false;
|
||||
onOpenChange(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-md gap-5 p-5 sm:p-6"
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
if (!selectedRef.current) onReturnFocus();
|
||||
selectedRef.current = false;
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="pr-7">
|
||||
<DialogTitle className="text-[18px] leading-6">
|
||||
{t("thread.composer.modelSetup.title", { defaultValue: "Choose your AI" })}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="leading-5">
|
||||
{t("thread.composer.modelSetup.description", {
|
||||
defaultValue: "Pick a starting point. You can change models at any time.",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-2">
|
||||
{SETUP_OPTIONS.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const ready = availability[option.intent];
|
||||
return (
|
||||
<button
|
||||
key={option.intent}
|
||||
type="button"
|
||||
aria-label={t(option.titleKey, { defaultValue: option.title })}
|
||||
onClick={() => {
|
||||
selectedRef.current = true;
|
||||
onSelect(option.intent);
|
||||
}}
|
||||
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",
|
||||
"transition-[background-color,border-color,transform] duration-150 ease-out hover:border-border hover:bg-muted/45 active:scale-[0.99]",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45",
|
||||
)}
|
||||
>
|
||||
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-full bg-muted/70 text-foreground/75 transition-colors group-hover:bg-background">
|
||||
<Icon className="h-[17px] w-[17px]" strokeWidth={1.8} aria-hidden />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[14px] font-semibold leading-5 text-foreground">
|
||||
{t(option.titleKey, { defaultValue: option.title })}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[12px] leading-[18px] text-muted-foreground">
|
||||
{t(option.descriptionKey, { defaultValue: option.description })}
|
||||
</span>
|
||||
</span>
|
||||
{ready ? (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-emerald-500/10 px-2 py-1 text-[11px] font-medium text-emerald-700 dark:text-emerald-300">
|
||||
<Check className="h-3 w-3" strokeWidth={2.2} aria-hidden />
|
||||
{t("thread.composer.modelSetup.ready", { defaultValue: "Ready" })}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -70,7 +70,6 @@ import {
|
||||
ModelPresetBadge,
|
||||
type ModelPresetOption,
|
||||
} from "@/components/thread/ModelPresetBadge";
|
||||
import { ModelSetupDialog } from "@/components/thread/ModelSetupDialog";
|
||||
import {
|
||||
ACCEPT_ATTR,
|
||||
MAX_ATTACHMENTS_PER_MESSAGE,
|
||||
@@ -114,7 +113,6 @@ import {
|
||||
} from "@/lib/session-drag";
|
||||
import { formatQuotedUserMessage } from "@/lib/user-message-quote";
|
||||
import { formatCompactTokenCount } from "@/lib/format";
|
||||
import type { ModelSetupAvailability, ModelSetupIntent } from "@/lib/model-setup";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const VOICE_SHORTCUT_CODE = "KeyD";
|
||||
@@ -300,9 +298,8 @@ interface ThreadComposerProps {
|
||||
modelProvider?: string | null;
|
||||
modelProviderLabel?: string | null;
|
||||
modelNeedsSetup?: boolean;
|
||||
modelSetupAvailability?: ModelSetupAvailability;
|
||||
fallbackModelName?: string | null;
|
||||
onModelBadgeClick?: (intent?: ModelSetupIntent) => void;
|
||||
onModelBadgeClick?: () => void;
|
||||
onManageModels?: () => void;
|
||||
contextUsage?: ComposerContextUsage | null;
|
||||
variant?: "thread" | "hero";
|
||||
@@ -1000,7 +997,6 @@ export function ThreadComposer({
|
||||
modelProvider = null,
|
||||
modelProviderLabel = null,
|
||||
modelNeedsSetup = false,
|
||||
modelSetupAvailability = { account: false, apiKey: false, local: false },
|
||||
fallbackModelName = null,
|
||||
onModelBadgeClick,
|
||||
onManageModels,
|
||||
@@ -1040,7 +1036,6 @@ export function ThreadComposer({
|
||||
} | null>(null);
|
||||
const [inlineError, setInlineError] = useState<string | null>(null);
|
||||
const [sendPending, setSendPending] = useState(false);
|
||||
const [modelSetupOpen, setModelSetupOpen] = useState(false);
|
||||
const interactionDisabled = !!disabled || sendPending;
|
||||
const [voiceErrorFading, setVoiceErrorFading] = useState(false);
|
||||
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
|
||||
@@ -2013,7 +2008,7 @@ export function ThreadComposer({
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if (modelNeedsSetup) {
|
||||
setModelSetupOpen(true);
|
||||
onModelBadgeClick?.();
|
||||
return;
|
||||
}
|
||||
if (!canSend) return;
|
||||
@@ -2121,6 +2116,7 @@ export function ThreadComposer({
|
||||
isStreaming,
|
||||
maxTextBytes,
|
||||
modelNeedsSetup,
|
||||
onModelBadgeClick,
|
||||
onSend,
|
||||
onStop,
|
||||
onQuotedContextChange,
|
||||
@@ -2131,15 +2127,6 @@ export function ThreadComposer({
|
||||
value,
|
||||
]);
|
||||
|
||||
const openModelSetup = useCallback(() => {
|
||||
setModelSetupOpen(true);
|
||||
}, []);
|
||||
|
||||
const continueModelSetup = useCallback((intent: ModelSetupIntent) => {
|
||||
setModelSetupOpen(false);
|
||||
onModelBadgeClick?.(intent);
|
||||
}, [onModelBadgeClick]);
|
||||
|
||||
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (showCliAppMenu) {
|
||||
if (e.key === "ArrowDown") {
|
||||
@@ -2561,7 +2548,7 @@ export function ThreadComposer({
|
||||
needsSetup={modelNeedsSetup}
|
||||
fallbackModelName={fallbackModelName}
|
||||
isHero={isHero}
|
||||
onClick={modelNeedsSetup ? openModelSetup : undefined}
|
||||
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{!voiceRecorder.isRecording ? <ComposerContextBadge usage={contextUsage} /> : null}
|
||||
@@ -2620,10 +2607,10 @@ export function ThreadComposer({
|
||||
showStopButton
|
||||
? t("thread.composer.stop")
|
||||
: modelNeedsSetup
|
||||
? t("thread.composer.openModelSetup", { defaultValue: "Open AI setup" })
|
||||
? t("thread.composer.configureModel", { defaultValue: "Configure model" })
|
||||
: t("thread.composer.send")
|
||||
}
|
||||
onClick={showStopButton ? handleStop : modelNeedsSetup ? openModelSetup : undefined}
|
||||
onClick={showStopButton ? handleStop : modelNeedsSetup ? onModelBadgeClick : undefined}
|
||||
className={cn(
|
||||
"thread-composer-action touch-target rounded-full transition-transform",
|
||||
showStopButton
|
||||
@@ -2669,13 +2656,6 @@ export function ThreadComposer({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<ModelSetupDialog
|
||||
availability={modelSetupAvailability}
|
||||
open={modelSetupOpen}
|
||||
onOpenChange={setModelSetupOpen}
|
||||
onReturnFocus={() => textareaRef.current?.focus()}
|
||||
onSelect={continueModelSetup}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,10 +39,6 @@ import {
|
||||
} from "@/lib/mcp-preset-events";
|
||||
import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
|
||||
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
|
||||
import {
|
||||
modelSetupAvailability,
|
||||
type ModelSetupIntent,
|
||||
} from "@/lib/model-setup";
|
||||
import type {
|
||||
ChatSummary,
|
||||
SettingsPayload,
|
||||
@@ -358,7 +354,6 @@ interface ThreadShellProps {
|
||||
composerPortalTarget?: HTMLElement | null;
|
||||
composerActive?: boolean;
|
||||
composerInputAriaLabel?: string;
|
||||
focusComposerRequest?: number;
|
||||
emptyComposerVariant?: "hero" | "thread";
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
||||
@@ -367,7 +362,7 @@ interface ThreadShellProps {
|
||||
workspaceError?: string | null;
|
||||
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
|
||||
settingsSnapshot?: SettingsPayload | null;
|
||||
onOpenModelSettings?: (intent?: ModelSetupIntent) => void;
|
||||
onOpenModelSettings?: () => void;
|
||||
skills?: SkillSummary[];
|
||||
}
|
||||
|
||||
@@ -659,7 +654,6 @@ export function ThreadShell({
|
||||
composerPortalTarget,
|
||||
composerActive = true,
|
||||
composerInputAriaLabel,
|
||||
focusComposerRequest = 0,
|
||||
emptyComposerVariant = "hero",
|
||||
workspaceScope = null,
|
||||
workspaceDefaultScope = null,
|
||||
@@ -969,10 +963,6 @@ export function ThreadShell({
|
||||
const modelBadgeLabel = modelBadge.needsSetup
|
||||
? t("thread.composer.chooseAI", { defaultValue: "Choose your AI" })
|
||||
: modelBadge.label;
|
||||
const setupAvailability = useMemo(
|
||||
() => modelSetupAvailability(settings?.providers),
|
||||
[settings?.providers],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
|
||||
setHeroGreetingKey(randomHeroGreetingKey());
|
||||
@@ -1527,7 +1517,6 @@ export function ThreadShell({
|
||||
modelProvider={modelBadge.provider}
|
||||
modelProviderLabel={modelBadge.providerLabel}
|
||||
modelNeedsSetup={modelBadge.needsSetup}
|
||||
modelSetupAvailability={setupAvailability}
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
onManageModels={onOpenModelSettings}
|
||||
@@ -1555,7 +1544,7 @@ export function ThreadShell({
|
||||
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
||||
ingressLimits={ingressLimits}
|
||||
quotedContext={quotedContext}
|
||||
focusRequest={composerFocusSignal + focusComposerRequest}
|
||||
focusRequest={composerFocusSignal}
|
||||
onQuotedContextChange={setQuotedContext}
|
||||
/>
|
||||
) : (
|
||||
@@ -1577,7 +1566,6 @@ export function ThreadShell({
|
||||
modelProvider={modelBadge.provider}
|
||||
modelProviderLabel={modelBadge.providerLabel}
|
||||
modelNeedsSetup={modelBadge.needsSetup}
|
||||
modelSetupAvailability={setupAvailability}
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
onManageModels={onOpenModelSettings}
|
||||
@@ -1603,7 +1591,6 @@ export function ThreadShell({
|
||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
||||
ingressLimits={ingressLimits}
|
||||
focusRequest={composerFocusSignal + focusComposerRequest}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1193,25 +1193,9 @@
|
||||
"stop": "Stop response",
|
||||
"quotedContext": "Quoted context",
|
||||
"removeQuotedContext": "Remove quoted context",
|
||||
"openModelSetup": "Open AI setup",
|
||||
"modelNotConfigured": "Model not configured",
|
||||
"configureModel": "Configure model",
|
||||
"chooseAI": "Choose your AI",
|
||||
"modelSetup": {
|
||||
"title": "Choose your AI",
|
||||
"description": "Pick a starting point. You can change models at any time.",
|
||||
"ready": "Ready",
|
||||
"account": {
|
||||
"title": "Connect an account",
|
||||
"description": "Use a supported AI subscription."
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "Use an API key",
|
||||
"description": "Bring a key from your preferred provider."
|
||||
},
|
||||
"local": {
|
||||
"title": "Run locally",
|
||||
"description": "Connect Ollama, LM Studio, or vLLM."
|
||||
}
|
||||
},
|
||||
"switchModel": "Switch model for this chat",
|
||||
"manageModels": "Manage models",
|
||||
"context": {
|
||||
|
||||
@@ -1180,25 +1180,9 @@
|
||||
"stop": "Detener respuesta",
|
||||
"quotedContext": "Contexto citado",
|
||||
"removeQuotedContext": "Quitar contexto citado",
|
||||
"openModelSetup": "Abrir configuración de IA",
|
||||
"modelNotConfigured": "Modelo no configurado",
|
||||
"configureModel": "Configurar modelo",
|
||||
"chooseAI": "Elige tu IA",
|
||||
"modelSetup": {
|
||||
"title": "Elige tu IA",
|
||||
"description": "Elige cómo empezar. Puedes cambiar de modelo en cualquier momento.",
|
||||
"ready": "Listo",
|
||||
"account": {
|
||||
"title": "Conectar una cuenta",
|
||||
"description": "Usa una suscripción de IA compatible."
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "Usar una clave API",
|
||||
"description": "Usa una clave de tu proveedor preferido."
|
||||
},
|
||||
"local": {
|
||||
"title": "Ejecutar localmente",
|
||||
"description": "Conecta Ollama, LM Studio o vLLM."
|
||||
}
|
||||
},
|
||||
"switchModel": "Cambiar el modelo de este chat",
|
||||
"manageModels": "Gestionar modelos",
|
||||
"context": {
|
||||
|
||||
@@ -1179,25 +1179,9 @@
|
||||
"stop": "Arrêter la réponse",
|
||||
"quotedContext": "Contexte cité",
|
||||
"removeQuotedContext": "Supprimer le contexte cité",
|
||||
"openModelSetup": "Ouvrir la configuration de l’IA",
|
||||
"modelNotConfigured": "Modèle non configuré",
|
||||
"configureModel": "Configurer le modèle",
|
||||
"chooseAI": "Choisissez votre IA",
|
||||
"modelSetup": {
|
||||
"title": "Choisissez votre IA",
|
||||
"description": "Choisissez un point de départ. Vous pourrez changer de modèle à tout moment.",
|
||||
"ready": "Prêt",
|
||||
"account": {
|
||||
"title": "Connecter un compte",
|
||||
"description": "Utilisez un abonnement IA compatible."
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "Utiliser une clé API",
|
||||
"description": "Utilisez la clé du fournisseur de votre choix."
|
||||
},
|
||||
"local": {
|
||||
"title": "Exécuter localement",
|
||||
"description": "Connectez Ollama, LM Studio ou vLLM."
|
||||
}
|
||||
},
|
||||
"switchModel": "Changer le modèle de cette conversation",
|
||||
"manageModels": "Gérer les modèles",
|
||||
"context": {
|
||||
|
||||
@@ -1179,25 +1179,9 @@
|
||||
"stop": "Hentikan respons",
|
||||
"quotedContext": "Konteks kutipan",
|
||||
"removeQuotedContext": "Hapus konteks kutipan",
|
||||
"openModelSetup": "Buka penyiapan AI",
|
||||
"modelNotConfigured": "Model belum dikonfigurasi",
|
||||
"configureModel": "Konfigurasi model",
|
||||
"chooseAI": "Pilih AI Anda",
|
||||
"modelSetup": {
|
||||
"title": "Pilih AI Anda",
|
||||
"description": "Pilih cara memulai. Anda dapat mengganti model kapan saja.",
|
||||
"ready": "Siap",
|
||||
"account": {
|
||||
"title": "Hubungkan akun",
|
||||
"description": "Gunakan langganan AI yang didukung."
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "Gunakan kunci API",
|
||||
"description": "Gunakan kunci dari penyedia pilihan Anda."
|
||||
},
|
||||
"local": {
|
||||
"title": "Jalankan secara lokal",
|
||||
"description": "Hubungkan Ollama, LM Studio, atau vLLM."
|
||||
}
|
||||
},
|
||||
"switchModel": "Ganti model untuk percakapan ini",
|
||||
"manageModels": "Kelola model",
|
||||
"context": {
|
||||
|
||||
@@ -1179,25 +1179,9 @@
|
||||
"stop": "応答を停止",
|
||||
"quotedContext": "引用したコンテキスト",
|
||||
"removeQuotedContext": "引用したコンテキストを削除",
|
||||
"openModelSetup": "AI 設定を開く",
|
||||
"modelNotConfigured": "モデルが未設定です",
|
||||
"configureModel": "モデルを設定",
|
||||
"chooseAI": "AI を選択",
|
||||
"modelSetup": {
|
||||
"title": "AI を選択",
|
||||
"description": "開始方法を選んでください。モデルはいつでも変更できます。",
|
||||
"ready": "準備完了",
|
||||
"account": {
|
||||
"title": "アカウントを接続",
|
||||
"description": "対応する AI サブスクリプションを使用します。"
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API キーを使用",
|
||||
"description": "お好みのプロバイダーのキーを使用します。"
|
||||
},
|
||||
"local": {
|
||||
"title": "ローカルで実行",
|
||||
"description": "Ollama、LM Studio、vLLM に接続します。"
|
||||
}
|
||||
},
|
||||
"switchModel": "この会話で使うモデルを切り替える",
|
||||
"manageModels": "モデルを管理",
|
||||
"context": {
|
||||
|
||||
@@ -1179,25 +1179,9 @@
|
||||
"stop": "응답 중지",
|
||||
"quotedContext": "인용한 문맥",
|
||||
"removeQuotedContext": "인용한 문맥 제거",
|
||||
"openModelSetup": "AI 설정 열기",
|
||||
"modelNotConfigured": "모델이 설정되지 않음",
|
||||
"configureModel": "모델 설정",
|
||||
"chooseAI": "AI 선택",
|
||||
"modelSetup": {
|
||||
"title": "AI 선택",
|
||||
"description": "시작 방법을 선택하세요. 모델은 언제든 변경할 수 있습니다.",
|
||||
"ready": "준비됨",
|
||||
"account": {
|
||||
"title": "계정 연결",
|
||||
"description": "지원되는 AI 구독을 사용합니다."
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "API 키 사용",
|
||||
"description": "선호하는 제공업체의 키를 사용합니다."
|
||||
},
|
||||
"local": {
|
||||
"title": "로컬에서 실행",
|
||||
"description": "Ollama, LM Studio 또는 vLLM에 연결합니다."
|
||||
}
|
||||
},
|
||||
"switchModel": "이 대화에서 사용할 모델 전환",
|
||||
"manageModels": "모델 관리",
|
||||
"context": {
|
||||
|
||||
@@ -1193,25 +1193,9 @@
|
||||
"stop": "Parar resposta",
|
||||
"quotedContext": "Contexto citado",
|
||||
"removeQuotedContext": "Remover contexto citado",
|
||||
"openModelSetup": "Abrir configuração de IA",
|
||||
"modelNotConfigured": "Modelo não configurado",
|
||||
"configureModel": "Configurar modelo",
|
||||
"chooseAI": "Escolha sua IA",
|
||||
"modelSetup": {
|
||||
"title": "Escolha sua IA",
|
||||
"description": "Escolha como começar. Você pode trocar de modelo a qualquer momento.",
|
||||
"ready": "Pronto",
|
||||
"account": {
|
||||
"title": "Conectar uma conta",
|
||||
"description": "Use uma assinatura de IA compatível."
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "Usar uma chave de API",
|
||||
"description": "Use uma chave do seu provedor preferido."
|
||||
},
|
||||
"local": {
|
||||
"title": "Executar localmente",
|
||||
"description": "Conecte o Ollama, LM Studio ou vLLM."
|
||||
}
|
||||
},
|
||||
"switchModel": "Alternar o modelo desta conversa",
|
||||
"manageModels": "Gerenciar modelos",
|
||||
"context": {
|
||||
|
||||
@@ -1179,25 +1179,9 @@
|
||||
"stop": "Dừng phản hồi",
|
||||
"quotedContext": "Ngữ cảnh được trích dẫn",
|
||||
"removeQuotedContext": "Xóa ngữ cảnh được trích dẫn",
|
||||
"openModelSetup": "Mở thiết lập AI",
|
||||
"modelNotConfigured": "Chưa cấu hình mô hình",
|
||||
"configureModel": "Cấu hình mô hình",
|
||||
"chooseAI": "Chọn AI của bạn",
|
||||
"modelSetup": {
|
||||
"title": "Chọn AI của bạn",
|
||||
"description": "Chọn cách bắt đầu. Bạn có thể đổi mô hình bất cứ lúc nào.",
|
||||
"ready": "Sẵn sàng",
|
||||
"account": {
|
||||
"title": "Kết nối tài khoản",
|
||||
"description": "Dùng gói đăng ký AI được hỗ trợ."
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "Dùng khóa API",
|
||||
"description": "Dùng khóa từ nhà cung cấp bạn chọn."
|
||||
},
|
||||
"local": {
|
||||
"title": "Chạy cục bộ",
|
||||
"description": "Kết nối Ollama, LM Studio hoặc vLLM."
|
||||
}
|
||||
},
|
||||
"switchModel": "Chuyển mô hình cho cuộc trò chuyện này",
|
||||
"manageModels": "Quản lý mô hình",
|
||||
"context": {
|
||||
|
||||
@@ -1192,25 +1192,9 @@
|
||||
"stop": "停止响应",
|
||||
"quotedContext": "引用内容",
|
||||
"removeQuotedContext": "移除引用内容",
|
||||
"openModelSetup": "打开 AI 设置",
|
||||
"modelNotConfigured": "模型未配置",
|
||||
"configureModel": "配置模型",
|
||||
"chooseAI": "选择你的 AI",
|
||||
"modelSetup": {
|
||||
"title": "选择你的 AI",
|
||||
"description": "选择一种开始方式,之后可随时更换模型。",
|
||||
"ready": "已就绪",
|
||||
"account": {
|
||||
"title": "连接账户",
|
||||
"description": "使用支持的 AI 订阅。"
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "使用 API 密钥",
|
||||
"description": "使用你偏好的服务商密钥。"
|
||||
},
|
||||
"local": {
|
||||
"title": "在本地运行",
|
||||
"description": "连接 Ollama、LM Studio 或 vLLM。"
|
||||
}
|
||||
},
|
||||
"switchModel": "切换本次对话所用模型",
|
||||
"manageModels": "管理模型预设",
|
||||
"context": {
|
||||
|
||||
@@ -1179,25 +1179,9 @@
|
||||
"stop": "停止回覆",
|
||||
"quotedContext": "引用內容",
|
||||
"removeQuotedContext": "移除引用內容",
|
||||
"openModelSetup": "開啟 AI 設定",
|
||||
"modelNotConfigured": "尚未設定模型",
|
||||
"configureModel": "設定模型",
|
||||
"chooseAI": "選擇你的 AI",
|
||||
"modelSetup": {
|
||||
"title": "選擇你的 AI",
|
||||
"description": "選擇一種開始方式,之後可隨時更換模型。",
|
||||
"ready": "已就緒",
|
||||
"account": {
|
||||
"title": "連結帳戶",
|
||||
"description": "使用支援的 AI 訂閱。"
|
||||
},
|
||||
"apiKey": {
|
||||
"title": "使用 API 金鑰",
|
||||
"description": "使用你偏好的服務商金鑰。"
|
||||
},
|
||||
"local": {
|
||||
"title": "在本機執行",
|
||||
"description": "連結 Ollama、LM Studio 或 vLLM。"
|
||||
}
|
||||
},
|
||||
"switchModel": "切換此對話使用的模型",
|
||||
"manageModels": "管理模型預設",
|
||||
"context": {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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,7 +481,7 @@ describe("App layout", () => {
|
||||
expect(screen.getByRole("heading", { level: 1, name: "Settings" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("carries a first-run setup path into settings and restores composer focus", async () => {
|
||||
it("opens the full Models settings directly from the first-run prompt", async () => {
|
||||
const user = userEvent.setup();
|
||||
const base = baseSettingsPayload();
|
||||
mockFetchRoutes({
|
||||
@@ -496,26 +496,6 @@ describe("App layout", () => {
|
||||
},
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -523,23 +503,14 @@ describe("App layout", () => {
|
||||
|
||||
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" }))
|
||||
expect(screen.getByText("Model providers")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add your own model provider" }))
|
||||
.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());
|
||||
expect(screen.queryByRole("dialog", { name: "Choose your AI" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("places Automations after Skills in the main sidebar", async () => {
|
||||
|
||||
@@ -14,48 +14,6 @@ async function chooseProviderToConfigure(label: string) {
|
||||
describe("Settings providers", () => {
|
||||
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 () => {
|
||||
const base = settingsPayload();
|
||||
|
||||
@@ -3,7 +3,6 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { SettingsView } from "@/components/settings/SettingsView";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
import type { ModelSetupIntent } from "@/lib/model-setup";
|
||||
import type { SettingsPayload } from "@/lib/types";
|
||||
|
||||
export const requestMutationMock = vi.fn();
|
||||
@@ -138,7 +137,6 @@ export function renderSettingsView(
|
||||
| "browser"
|
||||
| "runtime";
|
||||
initialSettings?: SettingsPayload;
|
||||
modelSetupIntent?: ModelSetupIntent;
|
||||
showSidebar?: boolean;
|
||||
onBackToChat?: () => void;
|
||||
onSettingsChange?: (payload: SettingsPayload) => void;
|
||||
@@ -151,7 +149,6 @@ export function renderSettingsView(
|
||||
theme="light"
|
||||
initialSection={options.initialSection ?? "apps"}
|
||||
initialSettings={options.initialSettings}
|
||||
modelSetupIntent={options.modelSetupIntent}
|
||||
showSidebar={options.showSidebar}
|
||||
onToggleTheme={() => {}}
|
||||
onBackToChat={options.onBackToChat ?? (() => {})}
|
||||
|
||||
@@ -835,7 +835,7 @@ describe("ThreadShell", () => {
|
||||
expect(screen.getByText("Default")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens first-run model setup without clearing the draft", async () => {
|
||||
it("opens model settings directly without clearing the draft", async () => {
|
||||
const client = makeClient();
|
||||
const settings = modelSettings("openai-codex/gpt-5.1-codex", "openai_codex");
|
||||
settings.agent.has_api_key = false;
|
||||
@@ -882,55 +882,26 @@ describe("ThreadShell", () => {
|
||||
expect(screen.getByTestId("composer-model-setup-label")).toHaveTextContent("Choose your AI");
|
||||
expect(badge).not.toHaveClass("border-amber-500/35");
|
||||
expect(screen.queryByTestId("composer-model-logo-openai_codex")).not.toBeInTheDocument();
|
||||
fireEvent.click(badge);
|
||||
expect(await screen.findByRole("dialog", { name: "Choose your AI" })).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Ready")).toHaveLength(3);
|
||||
expect(onOpenModelSettings).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
const input = screen.getByRole("textbox", { name: "Message input" });
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
fireEvent.change(input, {
|
||||
target: { value: "hello" },
|
||||
});
|
||||
fireEvent.click(badge);
|
||||
|
||||
expect(screen.queryByRole("dialog", { name: "Choose your AI" })).not.toBeInTheDocument();
|
||||
expect(onOpenModelSettings).toHaveBeenCalledTimes(1);
|
||||
expect(input).toHaveValue("hello");
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
|
||||
onOpenModelSettings.mockClear();
|
||||
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
||||
|
||||
expect(await screen.findByRole("dialog", { name: "Choose your AI" })).toBeInTheDocument();
|
||||
expect(onOpenModelSettings).toHaveBeenCalledTimes(1);
|
||||
expect(input).toHaveValue("hello");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use an API key" }));
|
||||
|
||||
expect(onOpenModelSettings).toHaveBeenCalledWith("apiKey");
|
||||
expect(input).toHaveValue("hello");
|
||||
expect(input).not.toHaveFocus();
|
||||
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 () => {
|
||||
const client = makeClient();
|
||||
const disabledSettings = modelSettings("deepseek-v4-pro", "deepseek");
|
||||
|
||||
Reference in New Issue
Block a user