11import {
2+ AlertTriangle ,
23 Check ,
4+ CircleCheck ,
5+ Info ,
36 KeyRound ,
47 Laptop ,
58 Loader2 ,
69 LogIn ,
710 LogOut ,
811 Server ,
12+ type LucideIcon ,
913} from "lucide-react" ;
1014import { useMemo , useState } from "react" ;
1115
@@ -21,13 +25,14 @@ import {
2125 SheetTitle ,
2226} from "@/components/ui/sheet" ;
2327import { cn } from "@/lib/utils" ;
24- import type { AiProfile , AiProvider } from "@/types" ;
28+ import type { AiProfile , AiProvider , ProviderValidationSnapshot } from "@/types" ;
2529
2630type ProviderSettingsSheetProps = {
2731 aiConnecting : boolean ;
2832 aiProfile : AiProfile | null ;
2933 apiOnline : boolean ;
3034 open : boolean ;
35+ providerValidation : Record < string , ProviderValidationSnapshot > ;
3136 onOpenChange : ( open : boolean ) => void ;
3237 onOauthLogin : ( providerId : string ) => void ;
3338 onOauthLogout : ( providerId : string ) => void ;
@@ -66,6 +71,7 @@ export function ProviderSettingsSheet({
6671 aiProfile,
6772 apiOnline,
6873 open,
74+ providerValidation,
6975 onOpenChange,
7076 onOauthLogin,
7177 onOauthLogout,
@@ -106,6 +112,7 @@ export function ProviderSettingsSheet({
106112 provider = { provider }
107113 ready = { provider . id === activeProvider && ready }
108114 runtime = { runtime [ provider . id ?? "" ] ?? { } }
115+ validation = { providerValidation [ provider . id ?? "" ] }
109116 onOauthLogin = { onOauthLogin }
110117 onOauthLogout = { onOauthLogout }
111118 onSaveApiProvider = { onSaveApiProvider }
@@ -132,6 +139,7 @@ function ProviderCard({
132139 provider,
133140 ready,
134141 runtime,
142+ validation,
135143 onOauthLogin,
136144 onOauthLogout,
137145 onSaveApiProvider,
@@ -144,6 +152,7 @@ function ProviderCard({
144152 provider : AiProvider ;
145153 ready : boolean ;
146154 runtime : ProviderRuntime ;
155+ validation ?: ProviderValidationSnapshot ;
147156 onOauthLogin : ( providerId : string ) => void ;
148157 onOauthLogout : ( providerId : string ) => void ;
149158 onSaveApiProvider : ( providerId : string , apiKey : string , baseUrl ?: string ) => void ;
@@ -183,6 +192,15 @@ function ProviderCard({
183192 </ div >
184193 </ div >
185194
195+ < ProviderConnectionStatus
196+ active = { active }
197+ authKind = { authKind }
198+ canUseStoredSecret = { canUseStoredSecret }
199+ providerId = { providerId }
200+ ready = { ready }
201+ validation = { validation }
202+ />
203+
186204 < div className = "mt-3 space-y-2" >
187205 { authKind === "oauth" ? (
188206 < div className = "flex flex-wrap gap-2" >
@@ -305,6 +323,178 @@ function ProviderCard({
305323 ) ;
306324}
307325
326+ function ProviderConnectionStatus ( {
327+ active,
328+ authKind,
329+ canUseStoredSecret,
330+ providerId,
331+ ready,
332+ validation,
333+ } : {
334+ active : boolean ;
335+ authKind : string ;
336+ canUseStoredSecret : boolean ;
337+ providerId : string ;
338+ ready : boolean ;
339+ validation ?: ProviderValidationSnapshot ;
340+ } ) {
341+ const status = providerStatusCopy ( {
342+ active,
343+ authKind,
344+ canUseStoredSecret,
345+ providerId,
346+ ready,
347+ validation,
348+ } ) ;
349+ const StatusIcon = status . icon ;
350+
351+ return (
352+ < div
353+ className = { cn ( "mt-3 rounded-md border px-3 py-2 text-xs leading-5" , status . className ) }
354+ data-provider-fallback-state = { status . mode }
355+ data-provider-validation-status = { validation ? validation . valid ? "valid" : "invalid" : "unchecked" }
356+ >
357+ < div className = "flex items-start gap-2" >
358+ < StatusIcon className = "mt-0.5 size-3.5 shrink-0" />
359+ < div className = "min-w-0" >
360+ < div className = "font-medium tracking-normal" > { status . title } </ div >
361+ < div className = "text-muted-foreground" > { status . detail } </ div >
362+ { status . action ? (
363+ < div className = "mt-1 font-medium text-foreground" > 권장 조치: { status . action } </ div >
364+ ) : null }
365+ { status . meta ? < div className = "mt-1 font-mono text-[11px] text-muted-foreground" > { status . meta } </ div > : null }
366+ </ div >
367+ </ div >
368+ </ div >
369+ ) ;
370+ }
371+
372+ function providerStatusCopy ( {
373+ active,
374+ authKind,
375+ canUseStoredSecret,
376+ providerId,
377+ ready,
378+ validation,
379+ } : {
380+ active : boolean ;
381+ authKind : string ;
382+ canUseStoredSecret : boolean ;
383+ providerId : string ;
384+ ready : boolean ;
385+ validation ?: ProviderValidationSnapshot ;
386+ } ) : {
387+ action ?: string ;
388+ className : string ;
389+ detail : string ;
390+ icon : LucideIcon ;
391+ meta ?: string ;
392+ mode : string ;
393+ title : string ;
394+ } {
395+ if ( validation ?. valid ) {
396+ const liveActive = active && ready ;
397+ return {
398+ className : "border-emerald-500/30 bg-emerald-500/10 text-emerald-900 dark:text-emerald-100" ,
399+ detail : liveActive
400+ ? `${ validation . model ?? providerId } 응답 검증을 통과했습니다.`
401+ : `${ validation . model ?? providerId } 응답 검증을 통과했습니다. 사용으로 전환하면 이 provider를 쓸 수 있습니다.` ,
402+ icon : CircleCheck ,
403+ meta : validationMeta ( validation ) ,
404+ mode : liveActive ? "live" : "validated" ,
405+ title : liveActive ? "실제 응답 사용 중" : "응답 검증 완료" ,
406+ } ;
407+ }
408+
409+ if ( validation && ! validation . valid ) {
410+ return {
411+ action : providerActionLabel ( validation . diagnostic ?. action ) ,
412+ className : "border-amber-500/30 bg-amber-500/10 text-amber-950 dark:text-amber-100" ,
413+ detail : validation . diagnostic ?. message ?? validation . error ?? "Provider 응답을 확인하지 못했습니다." ,
414+ icon : AlertTriangle ,
415+ meta : validationMeta ( validation ) ,
416+ mode : "needs-action" ,
417+ title : "연결 확인 필요" ,
418+ } ;
419+ }
420+
421+ if ( active && ready ) {
422+ return {
423+ className : "border-sky-500/25 bg-sky-500/10 text-sky-950 dark:text-sky-100" ,
424+ detail : "프로필은 연결됨 상태입니다. 응답 검증으로 현재 모델의 실제 응답까지 확인하세요." ,
425+ icon : Info ,
426+ mode : "live-unverified" ,
427+ title : "실제 응답 확인 전" ,
428+ } ;
429+ }
430+
431+ if ( active ) {
432+ return {
433+ action : authKind === "oauth" ? "다시 로그인 필요" : authKind === "api_key" ? "키 또는 Base URL 확인" : undefined ,
434+ className : "bg-muted/40 text-muted-foreground" ,
435+ detail : "연결 전에는 기본 안내만 사용합니다. 로그인이나 키 저장 후 응답 검증을 통과해야 실제 응답을 사용합니다." ,
436+ icon : Info ,
437+ mode : "fallback" ,
438+ title : "기본 안내 모드" ,
439+ } ;
440+ }
441+
442+ if ( canUseStoredSecret ) {
443+ return {
444+ className : "bg-muted/40 text-muted-foreground" ,
445+ detail : "저장된 인증이 있습니다. 사용으로 전환한 뒤 응답 검증을 실행하세요." ,
446+ icon : Info ,
447+ mode : "candidate" ,
448+ title : "저장된 인증 있음" ,
449+ } ;
450+ }
451+
452+ return {
453+ action : authKind === "oauth" ? "브라우저 로그인" : authKind === "api_key" ? "키 저장" : undefined ,
454+ className : "bg-muted/40 text-muted-foreground" ,
455+ detail : "아직 연결되지 않았습니다. 연결 전에는 이 provider의 실제 응답을 사용하지 않습니다." ,
456+ icon : Info ,
457+ mode : "not-configured" ,
458+ title : authKind === "oauth" ? "로그인 필요" : authKind === "api_key" ? "키 필요" : "미선택" ,
459+ } ;
460+ }
461+
462+ function providerActionLabel ( action ?: string | null ) {
463+ if ( ! action ) return undefined ;
464+ const labels : Record < string , string > = {
465+ "check-network" : "네트워크 문제" ,
466+ "check-provider" : "Provider 상태 확인" ,
467+ "check-provider-compatibility" : "OAuth 호환성 점검" ,
468+ "configure-api-key" : "API 키 입력 필요" ,
469+ "configure-base-url" : "Base URL 입력 필요" ,
470+ "connect-provider" : "다시 로그인 필요" ,
471+ "relogin-provider" : "다시 로그인 필요" ,
472+ "restart-login" : "로그인 다시 시작" ,
473+ } ;
474+ return labels [ action ] ?? action ;
475+ }
476+
477+ function validationMeta ( validation : ProviderValidationSnapshot ) {
478+ const checkedAt = formatCheckedAt ( validation . checkedAt ) ;
479+ const parts = [
480+ validation . phase ? `phase=${ validation . phase } ` : null ,
481+ validation . probe ? `probe=${ validation . probe } ` : null ,
482+ validation . diagnostic ?. code ? `code=${ validation . diagnostic . code } ` : null ,
483+ checkedAt ? `checked=${ checkedAt } ` : null ,
484+ ] . filter ( Boolean ) ;
485+ return parts . length ? parts . join ( " · " ) : undefined ;
486+ }
487+
488+ function formatCheckedAt ( value ?: string ) {
489+ if ( ! value ) return undefined ;
490+ const parsed = new Date ( value ) ;
491+ if ( Number . isNaN ( parsed . getTime ( ) ) ) return undefined ;
492+ return new Intl . DateTimeFormat ( "ko-KR" , {
493+ hour : "2-digit" ,
494+ minute : "2-digit" ,
495+ } ) . format ( parsed ) ;
496+ }
497+
308498function providerCatalog ( profile : AiProfile | null ) : AiProvider [ ] {
309499 const catalog = profile ?. catalog ;
310500 if ( Array . isArray ( catalog ) ) {
0 commit comments