mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-09-01 16:51:53 +03:00
feat(webui): add first-run AI setup flow
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1193,8 +1193,25 @@
|
||||
"stop": "Stop response",
|
||||
"quotedContext": "Quoted context",
|
||||
"removeQuotedContext": "Remove quoted context",
|
||||
"modelNotConfigured": "Model not configured",
|
||||
"configureModel": "Configure model",
|
||||
"openModelSetup": "Open AI setup",
|
||||
"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,8 +1180,25 @@
|
||||
"stop": "Detener respuesta",
|
||||
"quotedContext": "Contexto citado",
|
||||
"removeQuotedContext": "Quitar contexto citado",
|
||||
"modelNotConfigured": "Modelo no configurado",
|
||||
"configureModel": "Configurar modelo",
|
||||
"openModelSetup": "Abrir configuración de IA",
|
||||
"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,8 +1179,25 @@
|
||||
"stop": "Arrêter la réponse",
|
||||
"quotedContext": "Contexte cité",
|
||||
"removeQuotedContext": "Supprimer le contexte cité",
|
||||
"modelNotConfigured": "Modèle non configuré",
|
||||
"configureModel": "Configurer le modèle",
|
||||
"openModelSetup": "Ouvrir la configuration de l’IA",
|
||||
"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,8 +1179,25 @@
|
||||
"stop": "Hentikan respons",
|
||||
"quotedContext": "Konteks kutipan",
|
||||
"removeQuotedContext": "Hapus konteks kutipan",
|
||||
"modelNotConfigured": "Model belum dikonfigurasi",
|
||||
"configureModel": "Konfigurasi model",
|
||||
"openModelSetup": "Buka penyiapan AI",
|
||||
"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,8 +1179,25 @@
|
||||
"stop": "応答を停止",
|
||||
"quotedContext": "引用したコンテキスト",
|
||||
"removeQuotedContext": "引用したコンテキストを削除",
|
||||
"modelNotConfigured": "モデルが未設定です",
|
||||
"configureModel": "モデルを設定",
|
||||
"openModelSetup": "AI 設定を開く",
|
||||
"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,8 +1179,25 @@
|
||||
"stop": "응답 중지",
|
||||
"quotedContext": "인용한 문맥",
|
||||
"removeQuotedContext": "인용한 문맥 제거",
|
||||
"modelNotConfigured": "모델이 설정되지 않음",
|
||||
"configureModel": "모델 설정",
|
||||
"openModelSetup": "AI 설정 열기",
|
||||
"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,8 +1193,25 @@
|
||||
"stop": "Parar resposta",
|
||||
"quotedContext": "Contexto citado",
|
||||
"removeQuotedContext": "Remover contexto citado",
|
||||
"modelNotConfigured": "Modelo não configurado",
|
||||
"configureModel": "Configurar modelo",
|
||||
"openModelSetup": "Abrir configuração de IA",
|
||||
"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,8 +1179,25 @@
|
||||
"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",
|
||||
"modelNotConfigured": "Chưa cấu hình mô hình",
|
||||
"configureModel": "Cấu hình mô hình",
|
||||
"openModelSetup": "Mở thiết lập AI",
|
||||
"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,8 +1192,25 @@
|
||||
"stop": "停止响应",
|
||||
"quotedContext": "引用内容",
|
||||
"removeQuotedContext": "移除引用内容",
|
||||
"modelNotConfigured": "模型未配置",
|
||||
"configureModel": "配置模型",
|
||||
"openModelSetup": "打开 AI 设置",
|
||||
"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,8 +1179,25 @@
|
||||
"stop": "停止回覆",
|
||||
"quotedContext": "引用內容",
|
||||
"removeQuotedContext": "移除引用內容",
|
||||
"modelNotConfigured": "尚未設定模型",
|
||||
"configureModel": "設定模型",
|
||||
"openModelSetup": "開啟 AI 設定",
|
||||
"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": {
|
||||
|
||||
@@ -681,7 +681,7 @@ describe("ThreadShell", () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByTitle("fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Choose your AI" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches through every named preset while preserving call-order priority", async () => {
|
||||
@@ -763,7 +763,7 @@ describe("ThreadShell", () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByTitle("fast · gpt-4 · Company Proxy")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Choose your AI" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the effective fallback model in the composer badge", async () => {
|
||||
@@ -835,7 +835,7 @@ describe("ThreadShell", () => {
|
||||
expect(screen.getByText("Default")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens model settings from the unconfigured model badge", async () => {
|
||||
it("opens first-run model setup 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;
|
||||
@@ -844,6 +844,20 @@ describe("ThreadShell", () => {
|
||||
? { ...provider, auth_type: "oauth", configured: false }
|
||||
: provider,
|
||||
);
|
||||
settings.providers.push(
|
||||
{
|
||||
name: "xai_grok",
|
||||
label: "xAI Grok",
|
||||
auth_type: "oauth",
|
||||
configured: true,
|
||||
},
|
||||
{
|
||||
name: "ollama",
|
||||
label: "Ollama",
|
||||
configured: true,
|
||||
api_base: "http://127.0.0.1:11434",
|
||||
},
|
||||
);
|
||||
const onOpenModelSettings = vi.fn();
|
||||
|
||||
render(
|
||||
@@ -860,17 +874,31 @@ describe("ThreadShell", () => {
|
||||
),
|
||||
);
|
||||
|
||||
const badge = await screen.findByRole("button", { name: "Model not configured" });
|
||||
expect(screen.getByTestId("composer-model-setup-icon")).toBeInTheDocument();
|
||||
const badge = await screen.findByRole("button", { name: "Choose your AI" });
|
||||
const setupIcon = screen.getByTestId("composer-model-setup-icon");
|
||||
expect(setupIcon).toBeInTheDocument();
|
||||
expect(setupIcon.parentElement).not.toHaveClass("border-amber-500/35");
|
||||
expect(screen.queryByTestId("composer-model-logo-openai_codex")).not.toBeInTheDocument();
|
||||
fireEvent.click(badge);
|
||||
expect(onOpenModelSettings).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByRole("dialog", { name: "Choose your AI" })).toBeInTheDocument();
|
||||
expect(screen.getAllByText("Ready")).toHaveLength(3);
|
||||
expect(onOpenModelSettings).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Message input" }), {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
|
||||
const input = screen.getByRole("textbox", { name: "Message input" });
|
||||
fireEvent.change(input, {
|
||||
target: { value: "hello" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Configure model" }));
|
||||
expect(onOpenModelSettings).toHaveBeenCalledTimes(2);
|
||||
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
||||
|
||||
expect(await screen.findByRole("dialog", { name: "Choose your AI" })).toBeInTheDocument();
|
||||
expect(input).toHaveValue("hello");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Use an API key" }));
|
||||
|
||||
expect(onOpenModelSettings).toHaveBeenCalledTimes(1);
|
||||
expect(input).toHaveValue("hello");
|
||||
await waitFor(() => expect(input).toHaveFocus());
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user