Skip to content

Commit e5dc241

Browse files
committed
채팅 대화면과 제공자 연결 정리
1 parent ff46493 commit e5dc241

4 files changed

Lines changed: 191 additions & 56 deletions

File tree

editor/src/App.tsx

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -583,10 +583,11 @@ function App() {
583583

584584
setNotice({ tone: "warning", title: "제공자 로그인 대기 중", detail: "로그인 탭을 완료한 뒤 상태를 다시 확인하세요." });
585585
} catch (error) {
586+
const detail = error instanceof Error ? error.message : String(error);
586587
setNotice({
587588
tone: "error",
588589
title: "제공자 로그인 실패",
589-
detail: error instanceof Error ? error.message : String(error),
590+
detail: isProviderAuthError(detail) ? "대화 제공자 로그인을 다시 시작하세요." : detail,
590591
});
591592
} finally {
592593
setAiConnecting(false);
@@ -726,16 +727,25 @@ function App() {
726727
});
727728
} catch (error) {
728729
const detail = error instanceof Error ? error.message : String(error);
730+
const providerAuthIssue = isProviderAuthError(detail);
731+
const displayDetail = providerAuthIssue
732+
? "대화 제공자 로그인이 필요합니다. 연결을 누르고 브라우저 로그인을 완료한 뒤 다시 요청하세요."
733+
: detail;
729734
setMessages((current) => [
730735
...current,
731736
{
732737
id: `assistant-error-${Date.now()}`,
733-
role: "system",
738+
role: "assistant",
734739
tone: "error",
735-
content: detail,
740+
action: providerAuthIssue ? "connect-provider" : undefined,
741+
content: displayDetail,
736742
},
737743
]);
738-
setNotice({ tone: "error", title: "어시스턴트 사용 불가", detail });
744+
setNotice({
745+
tone: "error",
746+
title: providerAuthIssue ? "대화 제공자 연결 필요" : "어시스턴트 사용 불가",
747+
detail: displayDetail,
748+
});
739749
} finally {
740750
setAssistantLoading(false);
741751
}
@@ -993,4 +1003,16 @@ function sleep(milliseconds: number) {
9931003
});
9941004
}
9951005

1006+
function isProviderAuthError(detail: string) {
1007+
const normalized = detail.toLowerCase();
1008+
return (
1009+
normalized.includes("oauth authentication required") ||
1010+
normalized.includes("authentication expired") ||
1011+
normalized.includes("please login") ||
1012+
normalized.includes("re-login") ||
1013+
normalized.includes("no saved token") ||
1014+
normalized.includes("token refresh failed")
1015+
);
1016+
}
1017+
9961018
export default App;

editor/src/components/app/mainSurface.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,13 +84,17 @@ export function MainSurface(props: MainSurfaceProps) {
8484
if (props.surface === "chat") {
8585
return (
8686
<ChatSurface
87+
aiConnecting={props.aiConnecting}
88+
aiProfile={props.aiProfile}
89+
apiOnline={props.apiOnline}
8790
loading={props.assistantLoading}
8891
loadState={props.loadState}
8992
messages={props.messages}
9093
pendingBlocks={props.pendingBlocks}
9194
prompt={props.prompt}
9295
onAsk={props.onAsk}
9396
onAcceptPendingBlocks={props.onAcceptPendingBlocks}
97+
onConnectAi={props.onConnectAi}
9498
onPromptChange={props.onPromptChange}
9599
onRejectPendingBlocks={props.onRejectPendingBlocks}
96100
/>

editor/src/components/assistant/assistantPanel.tsx

Lines changed: 134 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import {
2+
AlertCircle,
23
Bot,
4+
LogIn,
35
Loader2,
46
RotateCcw,
57
Send,
@@ -12,7 +14,7 @@ import {
1214
PendingNotebookBar,
1315
} from "@/components/app/appPrimitives";
1416
import { Badge } from "@/components/ui/badge";
15-
import { Card, CardContent, CardHeader } from "@/components/ui/card";
17+
import { Button } from "@/components/ui/button";
1618
import { ScrollArea } from "@/components/ui/scroll-area";
1719
import { Skeleton } from "@/components/ui/skeleton";
1820
import { 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

3336
export 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

103114
export 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-
294342
function 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

300348
function 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(/^503\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

Comments
 (0)