feat(webui): add first-run AI setup flow

This commit is contained in:
Xubin Ren
2026-09-02 16:25:07 +08:00
parent 042f96f6ba
commit b47f0980f1
15 changed files with 401 additions and 39 deletions
@@ -6,7 +6,7 @@ import {
type KeyboardEvent,
type PointerEvent,
} from "react";
import { Check, CircleHelp, SlidersHorizontal, Sparkles } from "lucide-react";
import { Check, SlidersHorizontal, Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import {
@@ -539,7 +539,6 @@ function PresetPill({
"composer-model-badge composer-model-pill inline-flex h-full max-w-full min-w-0 shrink-0 items-center rounded-full border border-border/55 bg-card font-medium text-foreground/70",
"w-fit",
"transition-[color,background-color,border-color,transform] duration-150 ease-out group-focus-visible:ring-2 group-focus-visible:ring-ring/45",
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
isHero ? "gap-1.5 px-2.5 text-[12px]" : "gap-2 px-3 text-[12.5px]",
offset !== undefined && "composer-model-pill-dock",
)}
@@ -595,13 +594,13 @@ function PresetProviderIcon({
data-testid={testId}
className={cn(
"grid shrink-0 place-items-center",
needsSetup && "text-amber-800 dark:text-amber-200",
needsSetup && "text-muted-foreground",
isHero ? "h-4 w-4" : "h-[18px] w-[18px]",
)}
aria-hidden
>
{needsSetup ? (
<CircleHelp className={cn(isHero ? "h-3 w-3" : "h-3.5 w-3.5")} strokeWidth={1.8} />
<Sparkles className={cn(isHero ? "h-3 w-3" : "h-3.5 w-3.5")} strokeWidth={1.8} />
) : logoUrl ? (
<img
src={logoUrl}
@@ -0,0 +1,123 @@
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";
export interface ModelSetupAvailability {
account: boolean;
apiKey: boolean;
local: boolean;
}
export type ModelSetupIntent = keyof ModelSetupAvailability;
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();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-md gap-5 p-5 sm:p-6"
onCloseAutoFocus={(event) => {
event.preventDefault();
onReturnFocus();
}}
>
<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={() => 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>
);
}
+27 -5
View File
@@ -70,6 +70,10 @@ import {
ModelPresetBadge,
type ModelPresetOption,
} from "@/components/thread/ModelPresetBadge";
import {
ModelSetupDialog,
type ModelSetupAvailability,
} from "@/components/thread/ModelSetupDialog";
import {
ACCEPT_ATTR,
MAX_ATTACHMENTS_PER_MESSAGE,
@@ -298,6 +302,7 @@ interface ThreadComposerProps {
modelProvider?: string | null;
modelProviderLabel?: string | null;
modelNeedsSetup?: boolean;
modelSetupAvailability?: ModelSetupAvailability;
fallbackModelName?: string | null;
onModelBadgeClick?: () => void;
onManageModels?: () => void;
@@ -997,6 +1002,7 @@ export function ThreadComposer({
modelProvider = null,
modelProviderLabel = null,
modelNeedsSetup = false,
modelSetupAvailability = { account: false, apiKey: false, local: false },
fallbackModelName = null,
onModelBadgeClick,
onManageModels,
@@ -1036,6 +1042,7 @@ 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);
@@ -2008,7 +2015,7 @@ export function ThreadComposer({
const submit = useCallback(() => {
if (modelNeedsSetup) {
onModelBadgeClick?.();
setModelSetupOpen(true);
return;
}
if (!canSend) return;
@@ -2116,7 +2123,6 @@ export function ThreadComposer({
isStreaming,
maxTextBytes,
modelNeedsSetup,
onModelBadgeClick,
onSend,
onStop,
onQuotedContextChange,
@@ -2127,6 +2133,15 @@ export function ThreadComposer({
value,
]);
const openModelSetup = useCallback(() => {
setModelSetupOpen(true);
}, []);
const continueModelSetup = useCallback(() => {
setModelSetupOpen(false);
onModelBadgeClick?.();
}, [onModelBadgeClick]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (showCliAppMenu) {
if (e.key === "ArrowDown") {
@@ -2548,7 +2563,7 @@ export function ThreadComposer({
needsSetup={modelNeedsSetup}
fallbackModelName={fallbackModelName}
isHero={isHero}
onClick={modelNeedsSetup ? onModelBadgeClick : undefined}
onClick={modelNeedsSetup ? openModelSetup : undefined}
/>
) : null}
{!voiceRecorder.isRecording ? <ComposerContextBadge usage={contextUsage} /> : null}
@@ -2607,10 +2622,10 @@ export function ThreadComposer({
showStopButton
? t("thread.composer.stop")
: modelNeedsSetup
? t("thread.composer.configureModel", { defaultValue: "Configure model" })
? t("thread.composer.openModelSetup", { defaultValue: "Open AI setup" })
: t("thread.composer.send")
}
onClick={showStopButton ? handleStop : modelNeedsSetup ? onModelBadgeClick : undefined}
onClick={showStopButton ? handleStop : modelNeedsSetup ? openModelSetup : undefined}
className={cn(
"thread-composer-action touch-target rounded-full transition-transform",
showStopButton
@@ -2656,6 +2671,13 @@ export function ThreadComposer({
</div>
) : null}
</div>
<ModelSetupDialog
availability={modelSetupAvailability}
open={modelSetupOpen}
onOpenChange={setModelSetupOpen}
onReturnFocus={() => textareaRef.current?.focus()}
onSelect={continueModelSetup}
/>
</form>
);
}
+21 -1
View File
@@ -14,6 +14,7 @@ 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";
@@ -382,6 +383,22 @@ 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,
@@ -961,8 +978,9 @@ export function ThreadShell({
[activeModelPreset, modelName, settings],
);
const modelBadgeLabel = modelBadge.needsSetup
? t("thread.composer.modelNotConfigured", { defaultValue: "Model not configured" })
? t("thread.composer.chooseAI", { defaultValue: "Choose your AI" })
: modelBadge.label;
const setupAvailability = useMemo(() => modelSetupAvailability(settings), [settings]);
useEffect(() => {
if (showHeroComposer && !wasShowingHeroComposerRef.current) {
setHeroGreetingKey(randomHeroGreetingKey());
@@ -1517,6 +1535,7 @@ export function ThreadShell({
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
modelNeedsSetup={modelBadge.needsSetup}
modelSetupAvailability={setupAvailability}
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
onManageModels={onOpenModelSettings}
@@ -1566,6 +1585,7 @@ export function ThreadShell({
modelProvider={modelBadge.provider}
modelProviderLabel={modelBadge.providerLabel}
modelNeedsSetup={modelBadge.needsSetup}
modelSetupAvailability={setupAvailability}
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
onManageModels={onOpenModelSettings}