Skip to content

Commit 64fcc10

Browse files
authored
#614 AI 조교 힌트 본문 구조화 및 단계 UI 개선 (#735)
- 힌트 내용을 단계 배지·진단/힌트/점검 섹션으로 구조화 렌더링 - 점검 포인트를 강조 콜아웃으로 표시 - 단계 진행 스테퍼 및 첫 진입 빈 상태 안내 추가 - 미사용 CSS 정리
1 parent 03ea29f commit 64fcc10

1 file changed

Lines changed: 236 additions & 14 deletions

File tree

  • frontend/src/pages/oj/views/problem/problemSolving/problemSolvingComponent

frontend/src/pages/oj/views/problem/problemSolving/problemSolvingComponent/BottomDrag.vue

Lines changed: 236 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,33 @@
4949

5050
<div ref="terminalBody" class="terminal-body">
5151
<div v-if="tab === 'ai'" class="ai-chat">
52-
<div v-for="(msg, i) in messages" :key="i" class="chat-row">
52+
<!-- 단계 진행 스테퍼 -->
53+
<div v-if="!isAdminRole" class="hint-stepper">
54+
<div class="step-track">
55+
<span
56+
v-for="n in 5"
57+
:key="n"
58+
class="step-dot"
59+
:class="{ done: n <= problemHintsUsed }"
60+
></span>
61+
</div>
62+
<span class="step-label">{{ problemHintsUsed }}/5 단계</span>
63+
</div>
64+
65+
<!-- 첫 진입 온보딩 -->
66+
<div v-if="messages.length === 0 && !isLoading" class="ai-empty">
67+
<div class="ai-empty-avatar">
68+
<img src="@/assets/images/AIAssistant.svg" alt="AI" />
69+
</div>
70+
<p class="ai-empty-title">아직 받은 힌트가 없어요</p>
71+
<p class="ai-empty-sub">
72+
막히는 부분이 있으면 아래 'AI조교 힌트받기'를 눌러보세요. 1단계부터
73+
시작해 단계가 올라갈수록 더 구체적으로 도와드립니다.
74+
</p>
75+
</div>
76+
77+
<!-- 메시지 -->
78+
<div v-for="(msg, i) in renderMessages" :key="i" class="chat-row">
5379
<div class="avatar" :class="{ thinking: msg.thinking }">
5480
<img src="@/assets/images/AIAssistant.svg" alt="AI" />
5581
</div>
@@ -58,12 +84,26 @@
5884
생각 중...
5985
</div>
6086

61-
<div
62-
v-else
63-
class="bubble"
64-
:class="{ error: msg.error }"
65-
v-text="msg.text"
66-
></div>
87+
<div v-else-if="msg.error" class="bubble error">{{ msg.text }}</div>
88+
89+
<div v-else class="bubble hint-bubble">
90+
<div v-if="msg.parsed.level" class="hint-level-badge">
91+
{{ msg.parsed.level }}단계
92+
</div>
93+
94+
<template v-if="msg.parsed.sections.length">
95+
<div
96+
v-for="(sec, si) in msg.parsed.sections"
97+
:key="si"
98+
class="hint-section"
99+
:class="sectionClass(sec.label)"
100+
>
101+
<div class="hint-section-label">{{ sec.label }}</div>
102+
<div class="hint-section-text">{{ sec.text }}</div>
103+
</div>
104+
</template>
105+
<div v-else class="hint-body">{{ msg.parsed.body }}</div>
106+
</div>
67107
</div>
68108
</div>
69109

@@ -114,6 +154,14 @@ export default {
114154
if (this.isAdminRole) return false // 관리자는 소진 개념이 없음
115155
return this.problemHintsRemaining <= 0
116156
},
157+
158+
// 각 메시지 텍스트를 단계 배지 + 섹션(진단/힌트/점검) 구조로 파싱
159+
renderMessages() {
160+
return this.messages.map((m) => ({
161+
...m,
162+
parsed: m.thinking || m.error ? null : this.parseHint(m.text),
163+
}))
164+
},
117165
},
118166
119167
methods: {
@@ -242,6 +290,44 @@ export default {
242290
this.eventSource = null
243291
},
244292
293+
// "[N단계]" + "▸ 라벨\n본문" 형식을 구조로 파싱 (스트리밍 중 부분 텍스트도 안전)
294+
parseHint(text) {
295+
const result = { level: null, sections: [], body: "" }
296+
if (!text) return result
297+
let rest = text
298+
const lv = rest.match(/^\s*\[(\d+)단계\]\s*/)
299+
if (lv) {
300+
result.level = Number(lv[1])
301+
rest = rest.slice(lv[0].length)
302+
}
303+
if (rest.indexOf("") !== -1) {
304+
rest
305+
.split("")
306+
.map((s) => s.trim())
307+
.filter((s) => s)
308+
.forEach((part) => {
309+
const nl = part.indexOf("\n")
310+
if (nl === -1) {
311+
result.sections.push({ label: part.trim(), text: "" })
312+
} else {
313+
result.sections.push({
314+
label: part.slice(0, nl).trim(),
315+
text: part.slice(nl + 1).trim(),
316+
})
317+
}
318+
})
319+
} else {
320+
result.body = rest.trim()
321+
}
322+
return result
323+
},
324+
325+
sectionClass(label) {
326+
if (label.indexOf("진단") !== -1) return "sec-diagnosis"
327+
if (label.indexOf("점검") !== -1) return "sec-check"
328+
return "sec-hint"
329+
},
330+
245331
startDrag() {
246332
this.isDragging = true
247333
},
@@ -436,7 +522,7 @@ export default {
436522
.ai-chat {
437523
display: flex;
438524
flex-direction: column;
439-
gap: 20px;
525+
gap: 16px;
440526
}
441527
442528
.chat-row {
@@ -446,15 +532,15 @@ export default {
446532
}
447533
448534
.avatar {
449-
width: 52px;
450-
height: 52px;
535+
width: 38px;
536+
height: 38px;
451537
flex-shrink: 0;
452538
background-color: var(--ai-assistant-avatar-bg);
453539
border-radius: 50%;
454540
display: flex;
455541
align-items: center;
456542
justify-content: center;
457-
padding: 10px;
543+
padding: 8px;
458544
}
459545
460546
.avatar img {
@@ -484,12 +570,148 @@ export default {
484570
font-style: italic;
485571
}
486572
487-
.result-message {
488-
color: var(--ai-assistant-muted-text-color);
573+
/* 단계 진행 스테퍼 */
574+
.hint-stepper {
575+
display: flex;
576+
align-items: center;
577+
gap: 10px;
578+
margin-bottom: 2px;
579+
}
580+
581+
.step-track {
582+
display: flex;
583+
gap: 4px;
584+
flex: 1;
585+
}
586+
587+
.step-dot {
588+
flex: 1;
589+
height: 4px;
590+
border-radius: 2px;
591+
background: var(--ai-assistant-header-border-color);
592+
}
593+
594+
.step-dot.done {
595+
background: #5b64ed;
596+
}
597+
598+
.step-label {
599+
font-size: 11px;
600+
font-weight: 600;
601+
color: var(--ai-assistant-subtle-text-color);
602+
white-space: nowrap;
603+
}
604+
605+
/* 첫 진입 온보딩 */
606+
.ai-empty {
607+
display: flex;
608+
flex-direction: column;
609+
align-items: center;
610+
text-align: center;
611+
gap: 6px;
612+
padding: 16px 24px 8px;
613+
}
614+
615+
.ai-empty-avatar {
616+
width: 48px;
617+
height: 48px;
618+
border-radius: 50%;
619+
background-color: var(--ai-assistant-avatar-bg);
620+
display: flex;
621+
align-items: center;
622+
justify-content: center;
623+
padding: 11px;
624+
margin-bottom: 2px;
625+
}
626+
627+
.ai-empty-avatar img {
628+
width: 100%;
629+
height: 100%;
630+
filter: var(--ai-assistant-avatar-icon-filter);
631+
}
632+
633+
.ai-empty-title {
634+
margin: 0;
635+
font-size: 14px;
636+
font-weight: 700;
637+
color: var(--ai-assistant-bubble-text-color);
638+
}
639+
640+
.ai-empty-sub {
641+
margin: 0;
642+
max-width: 340px;
643+
font-size: 12.5px;
644+
line-height: 1.6;
645+
color: var(--ai-assistant-subtle-text-color);
646+
}
647+
648+
/* 힌트 말풍선: 구조 요소 사이 여백이 pre-wrap으로 빈 줄처럼 보이지 않도록 normal */
649+
.hint-bubble {
650+
white-space: normal;
651+
}
652+
653+
/* 단계 배지 */
654+
.hint-level-badge {
655+
display: inline-block;
656+
font-size: 11px;
657+
font-weight: 700;
658+
color: #fff;
659+
background: #5b64ed;
660+
padding: 2px 10px;
661+
border-radius: 99px;
662+
margin-bottom: 10px;
663+
}
664+
665+
/* 힌트 섹션 (진단 / 힌트 / 점검 포인트) */
666+
.hint-section {
667+
margin-bottom: 12px;
668+
}
669+
670+
.hint-section:last-child {
671+
margin-bottom: 0;
672+
}
673+
674+
.hint-section-label {
675+
font-size: 11px;
676+
font-weight: 700;
677+
letter-spacing: 0.02em;
678+
margin-bottom: 4px;
679+
color: var(--ai-assistant-subtle-text-color);
680+
}
681+
682+
.hint-section-text {
489683
font-size: 14px;
684+
line-height: 1.6;
685+
white-space: pre-wrap;
490686
}
491687
492-
.empty-message {
688+
.sec-diagnosis .hint-section-label {
689+
color: #c2751a;
690+
}
691+
692+
.sec-hint .hint-section-label {
693+
color: #5b64ed;
694+
}
695+
696+
/* 점검 포인트 = 강조 콜아웃 */
697+
.sec-check {
698+
background: rgba(91, 100, 237, 0.07);
699+
border-left: 3px solid #5b64ed;
700+
padding: 8px 12px;
701+
border-radius: 0 8px 8px 0;
702+
}
703+
704+
.sec-check .hint-section-label {
705+
color: #5b64ed;
706+
}
707+
708+
.hint-body {
709+
font-size: 14px;
710+
line-height: 1.6;
711+
white-space: pre-wrap;
712+
}
713+
714+
.result-message {
493715
color: var(--ai-assistant-muted-text-color);
494716
font-size: 14px;
495717
}

0 commit comments

Comments
 (0)