11import {
2+ AlertCircle ,
23 Bot ,
4+ LogIn ,
35 Loader2 ,
46 RotateCcw ,
57 Send ,
@@ -12,7 +14,7 @@ import {
1214 PendingNotebookBar ,
1315} from "@/components/app/appPrimitives" ;
1416import { Badge } from "@/components/ui/badge" ;
15- import { Card , CardContent , CardHeader } from "@/components/ui/card " ;
17+ import { Button } from "@/components/ui/button " ;
1618import { ScrollArea } from "@/components/ui/scroll-area" ;
1719import { Skeleton } from "@/components/ui/skeleton" ;
1820import { Textarea } from "@/components/ui/textarea" ;
@@ -28,6 +30,7 @@ export type AssistantMessage = {
2830 model ?: string | null ;
2931 toolCalls ?: AiToolCall [ ] ;
3032 tone ?: "default" | "warning" | "error" ;
33+ action ?: "connect-provider" ;
3134} ;
3235
3336export function TeacherPanel ( {
@@ -85,7 +88,15 @@ export function TeacherPanel({
8588 />
8689 </ div >
8790
88- < AssistantMessages appLoading = { false } loading = { loading } messages = { messages } />
91+ < AssistantMessages
92+ aiConnecting = { aiConnecting }
93+ aiProfile = { aiProfile }
94+ apiOnline = { apiOnline }
95+ appLoading = { false }
96+ loading = { loading }
97+ messages = { messages }
98+ onConnectAi = { onConnectAi }
99+ />
89100
90101 < AssistantComposer
91102 className = "mt-3"
@@ -101,54 +112,90 @@ export function TeacherPanel({
101112}
102113
103114export function AssistantMessages ( {
115+ aiConnecting = false ,
116+ aiProfile = null ,
117+ apiOnline = false ,
104118 appLoading,
105119 loading,
106120 messages,
121+ onConnectAi,
107122} : {
123+ aiConnecting ?: boolean ;
124+ aiProfile ?: AiProfile | null ;
125+ apiOnline ?: boolean ;
108126 appLoading : boolean ;
109127 loading : boolean ;
110128 messages : AssistantMessage [ ] ;
129+ onConnectAi ?: ( ) => void ;
111130} ) {
112131 return (
113- < ScrollArea className = "min-h-0" >
114- < div className = "space-y-3 pr-3" >
132+ < ScrollArea className = "h-full min-h-0" >
133+ < div className = "space-y-1 pr-3" >
115134 { appLoading ? (
116135 < LoadingState title = "Codaro 여는 중" detail = "노트북, 커리큘럼, 자동화 상태를 불러오고 있습니다." />
117136 ) : messages . length ? (
118- messages . map ( ( message ) => (
119- < Card
120- className = { cn (
121- message . role === "user" && "bg-muted/30" ,
122- message . tone === "error" && "bg-destructive/10" ,
123- message . tone === "warning" && "bg-muted/40" ,
124- ) }
125- key = { message . id }
126- >
127- < CardHeader className = "pb-2" >
128- < div className = "flex flex-wrap items-center gap-2" >
129- < Badge variant = { message . role === "assistant" ? "secondary" : "outline" } >
130- { roleLabel ( message . role ) }
131- </ Badge >
132- </ div >
133- </ CardHeader >
134- < CardContent >
135- < div className = "whitespace-pre-wrap text-sm leading-6 text-muted-foreground" > { message . content } </ div >
136- </ CardContent >
137- </ Card >
138- ) )
137+ messages . map ( ( message ) => {
138+ const needsProvider = message . action === "connect-provider" || isProviderAuthMessage ( message . content ) ;
139+ return (
140+ < div
141+ className = { cn (
142+ "group flex px-1 py-2" ,
143+ message . role === "user" ? "justify-end" : "justify-start" ,
144+ ) }
145+ key = { message . id }
146+ >
147+ { message . role === "user" ? (
148+ < div className = "max-w-[75%] rounded-2xl bg-muted/60 px-4 py-2.5 text-sm leading-6 whitespace-pre-wrap break-words text-foreground" >
149+ { message . content }
150+ </ div >
151+ ) : (
152+ < div className = "w-full max-w-3xl space-y-2 text-sm leading-6 text-foreground" >
153+ { message . tone === "error" ? (
154+ < div className = "flex items-start gap-2 text-destructive" >
155+ < AlertCircle className = "mt-1 size-4 shrink-0" />
156+ < div className = "min-w-0" >
157+ < div className = "whitespace-pre-wrap break-words" >
158+ { cleanAssistantMessage ( message . content ) }
159+ </ div >
160+ { needsProvider ? (
161+ < ProviderConnectAction
162+ aiConnecting = { aiConnecting }
163+ apiOnline = { apiOnline }
164+ onConnectAi = { onConnectAi }
165+ />
166+ ) : null }
167+ </ div >
168+ </ div >
169+ ) : (
170+ < >
171+ < div className = "whitespace-pre-wrap break-words text-muted-foreground" >
172+ { cleanAssistantMessage ( message . content ) }
173+ </ div >
174+ { needsProvider && ! aiProfileReady ( aiProfile ) ? (
175+ < ProviderConnectAction
176+ aiConnecting = { aiConnecting }
177+ apiOnline = { apiOnline }
178+ onConnectAi = { onConnectAi }
179+ />
180+ ) : null }
181+ </ >
182+ ) }
183+ </ div >
184+ ) }
185+ </ div >
186+ ) ;
187+ } )
139188 ) : (
140189 < EmptyState
141190 detail = "Codaro에게 다음 설명, 실습 셀, 검증, 자동화를 만들어 달라고 요청하세요."
142191 title = "채팅에서 시작"
143192 />
144193 ) }
145194 { loading ? (
146- < Card className = "bg-muted/30" >
147- < CardContent className = "flex items-center gap-2 p-4 text-sm text-muted-foreground" >
148- < Loader2 className = "size-4 animate-spin" />
149- 다음 노트북 단계를 만드는 중입니다.
150- </ CardContent >
151- </ Card >
195+ < div className = "flex items-center gap-2 px-1 py-3 text-sm text-muted-foreground" >
196+ < Loader2 className = "size-4 animate-spin" />
197+ 응답 작성 중
198+ </ div >
152199 ) : null }
153200 </ div >
154201 </ ScrollArea >
@@ -270,7 +317,14 @@ function AssistantHeader({
270317 ) }
271318 </ div >
272319 < div className = "flex shrink-0 items-center gap-2" >
273- { compact ? null : (
320+ { compact ? (
321+ apiOnline && ! aiProfileReady ( aiProfile ) ? (
322+ < Button className = "h-8 gap-1.5 px-2 text-xs" disabled = { aiConnecting } size = "sm" variant = "outline" onClick = { onConnectAi } >
323+ { aiConnecting ? < Loader2 className = "size-3.5 animate-spin" /> : < LogIn className = "size-3.5" /> }
324+ 연결
325+ </ Button >
326+ ) : null
327+ ) : (
274328 < >
275329 < IconButton disabled = { aiConnecting } label = "제공자 연결" onClick = { onConnectAi } >
276330 { aiConnecting ? < Loader2 className = "animate-spin" /> : < Bot /> }
@@ -285,12 +339,6 @@ function AssistantHeader({
285339 ) ;
286340}
287341
288- function roleLabel ( role : AssistantMessage [ "role" ] ) {
289- if ( role === "assistant" ) return "어시스턴트" ;
290- if ( role === "user" ) return "나" ;
291- return "시스템" ;
292- }
293-
294342function assistantStatusText ( apiOnline : boolean , profile : AiProfile | null ) {
295343 if ( ! apiOnline ) return "기본 안내 모드입니다." ;
296344 if ( ! aiProfileReady ( profile ) ) return "대화 제공자를 연결하면 실제 응답을 사용할 수 있습니다." ;
@@ -299,18 +347,53 @@ function assistantStatusText(apiOnline: boolean, profile: AiProfile | null) {
299347
300348function LoadingState ( { title, detail } : { title : string ; detail : string } ) {
301349 return (
302- < Card className = "bg-muted/20" >
303- < CardContent className = "space-y-3 p-4" >
304- < div className = "flex items-center gap-2 text-sm font-medium" >
305- < Loader2 className = "size-4 animate-spin text-muted-foreground" />
306- { title }
307- </ div >
308- < div className = "space-y-2" >
309- < Skeleton className = "h-3 w-3/4" />
310- < Skeleton className = "h-3 w-1/2" />
311- </ div >
312- < div className = "text-xs text-muted-foreground" > { detail } </ div >
313- </ CardContent >
314- </ Card >
350+ < div className = "space-y-3 rounded-md border bg-muted/20 p-4" >
351+ < div className = "flex items-center gap-2 text-sm font-medium" >
352+ < Loader2 className = "size-4 animate-spin text-muted-foreground" />
353+ { title }
354+ </ div >
355+ < div className = "space-y-2" >
356+ < Skeleton className = "h-3 w-3/4" />
357+ < Skeleton className = "h-3 w-1/2" />
358+ </ div >
359+ < div className = "text-xs text-muted-foreground" > { detail } </ div >
360+ </ div >
361+ ) ;
362+ }
363+
364+ function isProviderAuthMessage ( content : string ) {
365+ const normalized = content . toLowerCase ( ) ;
366+ return (
367+ normalized . includes ( "oauth authentication required" ) ||
368+ normalized . includes ( "please login" ) ||
369+ normalized . includes ( "authentication expired" ) ||
370+ normalized . includes ( "re-login" ) ||
371+ normalized . includes ( "대화 제공자 로그인이 필요" )
372+ ) ;
373+ }
374+
375+ function cleanAssistantMessage ( content : string ) {
376+ return isProviderAuthMessage ( content )
377+ ? "대화 제공자 로그인이 필요합니다. 연결을 누르고 브라우저 로그인을 완료한 뒤 다시 요청하세요."
378+ : content . replace ( / ^ 5 0 3 \s + / , "" ) ;
379+ }
380+
381+ function ProviderConnectAction ( {
382+ aiConnecting,
383+ apiOnline,
384+ onConnectAi,
385+ } : {
386+ aiConnecting : boolean ;
387+ apiOnline : boolean ;
388+ onConnectAi ?: ( ) => void ;
389+ } ) {
390+ if ( ! apiOnline ) {
391+ return < div className = "mt-2 text-xs text-muted-foreground" > 서버 세션이 열리면 제공자를 연결할 수 있습니다.</ div > ;
392+ }
393+ return (
394+ < Button className = "mt-2 h-8 gap-1.5 px-3 text-xs" disabled = { aiConnecting || ! onConnectAi } size = "sm" onClick = { onConnectAi } >
395+ { aiConnecting ? < Loader2 className = "size-3.5 animate-spin" /> : < LogIn className = "size-3.5" /> }
396+ 대화 제공자 연결
397+ </ Button >
315398 ) ;
316399}
0 commit comments