fix(webui): route first-run model setup choices

This commit is contained in:
Xubin Ren
2026-09-02 16:25:07 +08:00
parent b47f0980f1
commit 9a4781982e
12 changed files with 329 additions and 73 deletions
@@ -1,3 +1,4 @@
import { useRef } from "react";
import { Check, Cloud, KeyRound, Laptop } from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -9,14 +10,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
export interface ModelSetupAvailability {
account: boolean;
apiKey: boolean;
local: boolean;
}
export type ModelSetupIntent = keyof ModelSetupAvailability;
import type { ModelSetupAvailability, ModelSetupIntent } from "@/lib/model-setup";
const SETUP_OPTIONS = [
{
@@ -59,14 +53,22 @@ export function ModelSetupDialog({
onSelect: (intent: ModelSetupIntent) => void;
}) {
const { t } = useTranslation();
const selectedRef = useRef(false);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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();
onReturnFocus();
if (!selectedRef.current) onReturnFocus();
selectedRef.current = false;
}}
>
<DialogHeader className="pr-7">
@@ -89,7 +91,10 @@ export function ModelSetupDialog({
key={option.intent}
type="button"
aria-label={t(option.titleKey, { defaultValue: option.title })}
onClick={() => onSelect(option.intent)}
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]",
@@ -70,10 +70,7 @@ import {
ModelPresetBadge,
type ModelPresetOption,
} from "@/components/thread/ModelPresetBadge";
import {
ModelSetupDialog,
type ModelSetupAvailability,
} from "@/components/thread/ModelSetupDialog";
import { ModelSetupDialog } from "@/components/thread/ModelSetupDialog";
import {
ACCEPT_ATTR,
MAX_ATTACHMENTS_PER_MESSAGE,
@@ -117,6 +114,7 @@ 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";
@@ -304,7 +302,7 @@ interface ThreadComposerProps {
modelNeedsSetup?: boolean;
modelSetupAvailability?: ModelSetupAvailability;
fallbackModelName?: string | null;
onModelBadgeClick?: () => void;
onModelBadgeClick?: (intent?: ModelSetupIntent) => void;
onManageModels?: () => void;
contextUsage?: ComposerContextUsage | null;
variant?: "thread" | "hero";
@@ -2137,9 +2135,9 @@ export function ThreadComposer({
setModelSetupOpen(true);
}, []);
const continueModelSetup = useCallback(() => {
const continueModelSetup = useCallback((intent: ModelSetupIntent) => {
setModelSetupOpen(false);
onModelBadgeClick?.();
onModelBadgeClick?.(intent);
}, [onModelBadgeClick]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
+13 -20
View File
@@ -14,7 +14,6 @@ import {
type ComposerContextUsage,
} from "@/components/thread/ThreadComposer";
import type { ModelPresetOption } from "@/components/thread/ModelPresetBadge";
import type { ModelSetupAvailability } from "@/components/thread/ModelSetupDialog";
import { ThreadHeader } from "@/components/thread/ThreadHeader";
import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport, type ThreadViewportHandle } from "@/components/thread/ThreadViewport";
@@ -40,6 +39,10 @@ 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,
@@ -355,6 +358,7 @@ interface ThreadShellProps {
composerPortalTarget?: HTMLElement | null;
composerActive?: boolean;
composerInputAriaLabel?: string;
focusComposerRequest?: number;
emptyComposerVariant?: "hero" | "thread";
workspaceScope?: WorkspaceScopePayload | null;
workspaceDefaultScope?: WorkspaceScopePayload | null;
@@ -363,7 +367,7 @@ interface ThreadShellProps {
workspaceError?: string | null;
onWorkspaceScopeChange?: (scope: WorkspaceScopePayload) => void;
settingsSnapshot?: SettingsPayload | null;
onOpenModelSettings?: () => void;
onOpenModelSettings?: (intent?: ModelSetupIntent) => void;
skills?: SkillSummary[];
}
@@ -383,22 +387,6 @@ interface ModelBadgeInfo {
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(
settings: SettingsPayload | null,
scopedPreset: string | null,
@@ -671,6 +659,7 @@ export function ThreadShell({
composerPortalTarget,
composerActive = true,
composerInputAriaLabel,
focusComposerRequest = 0,
emptyComposerVariant = "hero",
workspaceScope = null,
workspaceDefaultScope = null,
@@ -980,7 +969,10 @@ export function ThreadShell({
const modelBadgeLabel = modelBadge.needsSetup
? t("thread.composer.chooseAI", { defaultValue: "Choose your AI" })
: modelBadge.label;
const setupAvailability = useMemo(() => modelSetupAvailability(settings), [settings]);
const setupAvailability = useMemo(
() => modelSetupAvailability(settings?.providers),
[settings?.providers],
);
useEffect(() => {
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
setHeroGreetingKey(randomHeroGreetingKey());
@@ -1563,7 +1555,7 @@ export function ThreadShell({
transcriptionProvider={settingsSnapshot?.transcription?.provider}
ingressLimits={ingressLimits}
quotedContext={quotedContext}
focusRequest={composerFocusSignal}
focusRequest={composerFocusSignal + focusComposerRequest}
onQuotedContextChange={setQuotedContext}
/>
) : (
@@ -1611,6 +1603,7 @@ export function ThreadShell({
onWorkspaceScopeChange={onWorkspaceScopeChange}
transcriptionProvider={settingsSnapshot?.transcription?.provider}
ingressLimits={ingressLimits}
focusRequest={composerFocusSignal + focusComposerRequest}
/>
)}
</>