feat: AI 학습 도우미 위젯 + 피드백 초안 영역 확대
- 우측 하단 플로팅 AI 학습 도우미. 학생이 막힐 때 질문하면 로컬 LLM (qwen3:8b, 빠른 대화형)이 정답 대신 힌트·설명으로 답한다. LlmAssistant + POST /api/assistant/ask + HelperWidget. - 멘토 피드백 textarea min-height 200px — AI 초안 여러 문장이 한눈에.
This commit is contained in:
parent
a209bb1d80
commit
8276870252
@ -0,0 +1,113 @@
|
||||
package dev.awesomedev.mirim.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 화면 우측 하단 "AI 학습 도우미"의 두뇌. 학생 질문을 로컬 LLM에게 넘겨 답을 받아 온다.
|
||||
*
|
||||
* 학습 포인트 ① — 왜 gpt-oss가 아니라 qwen3:8b인가.
|
||||
* 멘토 피드백 초안({@link LlmFeedbackDrafter})은 정확도가 생명이라 느려도 gpt-oss를 쓴다.
|
||||
* 하지만 학습 도우미는 학생과 실시간으로 주고받는 대화라 "빠른 응답"이 더 중요하다.
|
||||
* 평가 결과, 힌트·개념 설명 같은 대화형 태스크는 qwen3:8b(40 tok/s)로도 충분했다.
|
||||
* "무엇이 중요한가"에 따라 같은 로컬 LLM이라도 모델을 다르게 고른다.
|
||||
*
|
||||
* 학습 포인트 ② — 부가 기능이 본 기능을 죽이면 안 된다({@link TelegramNotifier}와 같은 원칙).
|
||||
* 모델이 없거나 느려도 학습 자체는 굴러가야 한다. 실패하면 예외를 던지지 않고
|
||||
* "지금은 도우미를 쓸 수 없어요" 안내 문구를 돌려준다. base-url이 비면 아예 꺼진다.
|
||||
*
|
||||
* 호출 규칙은 LlmFeedbackDrafter와 동일하다: /api/chat, num_ctx, non-stream.
|
||||
*/
|
||||
@Service
|
||||
public class LlmAssistant {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LlmAssistant.class);
|
||||
|
||||
private static final String SYSTEM = """
|
||||
너는 어썸데브 학습 플랫폼의 AI 학습 도우미다. 프로그래밍을 배우는 수습생의 질문에
|
||||
친절하고 정확하게 한국어로 답한다. 개념 설명은 충실히 하되, 지금 풀고 있는 코딩 문제의
|
||||
완성된 정답 코드를 통째로 주지는 마라 — 학생이 스스로 풀도록 방향과 힌트를 준다.
|
||||
답은 간결하게, 꼭 필요하면 짧은 예시 코드만 곁들인다.""";
|
||||
|
||||
private final String baseUrl;
|
||||
private final String model;
|
||||
private final int numCtx;
|
||||
private final RestClient restClient;
|
||||
|
||||
public LlmAssistant(
|
||||
@Value("${llm.base-url:}") String baseUrl,
|
||||
@Value("${llm.assistant-model:qwen3:8b}") String model,
|
||||
@Value("${llm.num-ctx:16384}") int numCtx) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.model = model;
|
||||
this.numCtx = numCtx;
|
||||
this.restClient = RestClient.builder()
|
||||
.baseUrl(baseUrl.isBlank() ? "http://127.0.0.1:11434" : baseUrl)
|
||||
.requestFactory(timeouts(Duration.ofSeconds(5), Duration.ofMinutes(3)))
|
||||
.build();
|
||||
if (!isEnabled()) {
|
||||
log.info("AI 학습 도우미 비활성 상태입니다 (llm.base-url 미설정).");
|
||||
}
|
||||
}
|
||||
|
||||
private static org.springframework.http.client.ClientHttpRequestFactory timeouts(
|
||||
Duration connect, Duration read) {
|
||||
var f = new org.springframework.http.client.SimpleClientHttpRequestFactory();
|
||||
f.setConnectTimeout((int) connect.toMillis());
|
||||
f.setReadTimeout((int) read.toMillis());
|
||||
return f;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return !baseUrl.isBlank();
|
||||
}
|
||||
|
||||
/** 학생 질문에 대한 답변. 실패하거나 비활성이면 안내 문구를 돌려준다(예외 안 던짐). */
|
||||
public String ask(String question) {
|
||||
if (question == null || question.isBlank()) {
|
||||
return "무엇이 궁금한지 적어 주세요.";
|
||||
}
|
||||
if (!isEnabled()) {
|
||||
return "지금은 학습 도우미를 쓸 수 없어요. 잠시 후 다시 시도해 주세요.";
|
||||
}
|
||||
try {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> body = restClient.post()
|
||||
.uri("/api/chat")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(Map.of(
|
||||
"model", model,
|
||||
"stream", false,
|
||||
"think", false,
|
||||
"messages", List.of(
|
||||
Map.of("role", "system", "content", SYSTEM),
|
||||
Map.of("role", "user", "content", question)),
|
||||
"options", Map.of("num_ctx", numCtx, "temperature", 0.3)))
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
|
||||
if (body == null) return fallback();
|
||||
Object message = body.get("message");
|
||||
if (!(message instanceof Map<?, ?> m)) return fallback();
|
||||
Object content = m.get("content");
|
||||
String text = content == null ? "" : content.toString().trim();
|
||||
return text.isBlank() ? fallback() : text;
|
||||
} catch (Exception e) {
|
||||
log.warn("AI 학습 도우미 응답 실패(무시하고 계속): {}", e.getClass().getSimpleName());
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
|
||||
private String fallback() {
|
||||
return "지금은 답을 만들지 못했어요. 잠시 후 다시 물어봐 주세요.";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
package dev.awesomedev.mirim.web;
|
||||
|
||||
import dev.awesomedev.mirim.service.LlmAssistant;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 이 파일이 하는 일:
|
||||
* 화면 우측 하단 "AI 학습 도우미"의 질문을 받는 API. 로그인한 사용자만 쓴다
|
||||
* (SecurityConfig가 /api/** 를 인증 뒤로 둔다). 실제 답은 {@link LlmAssistant}가 만든다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/assistant")
|
||||
public class AssistantController {
|
||||
|
||||
private final LlmAssistant assistant;
|
||||
|
||||
public AssistantController(LlmAssistant assistant) {
|
||||
this.assistant = assistant;
|
||||
}
|
||||
|
||||
public record AskRequest(String question) {
|
||||
}
|
||||
|
||||
public record AskResponse(String answer, boolean enabled) {
|
||||
}
|
||||
|
||||
/** POST /api/assistant/ask — 질문 하나에 답 하나. 대화 맥락은 아직 안 쌓는다(단발). */
|
||||
@PostMapping("/ask")
|
||||
public AskResponse ask(@RequestBody AskRequest req) {
|
||||
return new AskResponse(assistant.ask(req.question()), assistant.isEnabled());
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ import { LEVEL_COURSES } from './levelCatalog';
|
||||
import { TrackProvider, useTrack, TRACK_OPTIONS } from './trackContext';
|
||||
import CourseShell from './components/CourseShell';
|
||||
import ScrollToTop from './components/ScrollToTop';
|
||||
import HelperWidget from './components/HelperWidget';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import SignupPage from './pages/SignupPage';
|
||||
import FindAccountPage from './pages/FindAccountPage';
|
||||
@ -165,6 +166,8 @@ function Layout() {
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
</main>
|
||||
{/* 화면 우측 하단 AI 학습 도우미 — 로그인 후 모든 페이지에 뜬다. */}
|
||||
<HelperWidget />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
86
frontend/src/components/HelperWidget.jsx
Normal file
86
frontend/src/components/HelperWidget.jsx
Normal file
@ -0,0 +1,86 @@
|
||||
// 이 파일이 하는 일: 화면 우측 하단에 떠 있는 "AI 학습 도우미" 챗 위젯.
|
||||
// 학생이 막힐 때 질문하면 로컬 LLM(qwen3:8b)이 힌트·설명으로 답한다.
|
||||
//
|
||||
// 학습 포인트: 플로팅 버튼(FAB)은 position: fixed로 스크롤과 무관하게 화면에 고정한다.
|
||||
// 패널은 버튼 위로 펼쳐지고, 열림 상태(open)만 컴포넌트가 기억하면 된다.
|
||||
// 대화는 아직 "단발"이다 — 한 질문에 한 답. 맥락을 쌓으려면 이전 메시지를 함께 보내야 한다.
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { MessageCircle, X, Send } from 'lucide-react';
|
||||
import client from '../api/client';
|
||||
|
||||
export default function HelperWidget() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [msgs, setMsgs] = useState([]); // { role: 'user' | 'ai', text }
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const bodyRef = useRef(null);
|
||||
|
||||
// 새 메시지가 오면 대화창을 맨 아래로 스크롤한다.
|
||||
useEffect(() => {
|
||||
if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
|
||||
}, [msgs, loading]);
|
||||
|
||||
async function send(e) {
|
||||
e.preventDefault();
|
||||
const q = input.trim();
|
||||
if (!q || loading) return;
|
||||
setMsgs((m) => [...m, { role: 'user', text: q }]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await client.post('/assistant/ask', { question: q });
|
||||
setMsgs((m) => [...m, { role: 'ai', text: res.data.answer }]);
|
||||
} catch {
|
||||
setMsgs((m) => [...m, { role: 'ai', text: '문제가 생겼어요. 잠시 후 다시 물어봐 주세요.' }]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && (
|
||||
<div className="helper-panel" role="dialog" aria-label="AI 학습 도우미">
|
||||
<div className="helper-head">
|
||||
<span>✨ AI 학습 도우미</span>
|
||||
<button className="helper-x" onClick={() => setOpen(false)} aria-label="닫기">
|
||||
<X size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="helper-body" ref={bodyRef}>
|
||||
{msgs.length === 0 && (
|
||||
<p className="helper-welcome">
|
||||
막히는 부분이나 궁금한 개념을 물어보세요.<br />
|
||||
정답을 통째로 주진 않지만, 스스로 풀도록 힌트를 드려요.
|
||||
</p>
|
||||
)}
|
||||
{msgs.map((m, i) => (
|
||||
<div key={i} className={`helper-msg helper-${m.role}`}>
|
||||
{m.text}
|
||||
</div>
|
||||
))}
|
||||
{loading && <div className="helper-msg helper-ai helper-thinking">생각 중…</div>}
|
||||
</div>
|
||||
<form className="helper-input" onSubmit={send}>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="질문을 입력하세요"
|
||||
aria-label="질문 입력"
|
||||
/>
|
||||
<button type="submit" className="helper-send" disabled={loading} aria-label="보내기">
|
||||
<Send size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="helper-fab"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-label={open ? 'AI 학습 도우미 닫기' : 'AI 학습 도우미 열기'}
|
||||
>
|
||||
{open ? <X size={24} strokeWidth={1.5} /> : <MessageCircle size={24} strokeWidth={1.5} />}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -378,6 +378,14 @@ a {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* textarea는 여러 줄을 다루니 기본 높이를 넉넉히 주고, 사용자가 세로로 늘릴 수 있게 한다.
|
||||
특히 AI 초안처럼 여러 문장이 들어오는 경우 한눈에 보이도록. */
|
||||
.textarea {
|
||||
min-height: 200px;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.input:focus, .textarea:focus {
|
||||
outline: 2px solid var(--primary-soft);
|
||||
border-color: var(--primary);
|
||||
@ -1467,3 +1475,115 @@ inline-code, .icode {
|
||||
.doc-shadow-host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── AI 학습 도우미 (우측 하단 플로팅 챗) ───────────────────────────── */
|
||||
.helper-fab {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
|
||||
z-index: 1000;
|
||||
}
|
||||
.helper-fab:hover { filter: brightness(1.06); }
|
||||
|
||||
.helper-panel {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 92px;
|
||||
width: 360px;
|
||||
max-width: calc(100vw - 32px);
|
||||
height: 480px;
|
||||
max-height: calc(100vh - 140px);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 1000;
|
||||
}
|
||||
.helper-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-weight: 600;
|
||||
}
|
||||
.helper-x {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
}
|
||||
.helper-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.helper-welcome {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.helper-msg {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
max-width: 85%;
|
||||
}
|
||||
.helper-user {
|
||||
align-self: flex-end;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
.helper-ai {
|
||||
align-self: flex-start;
|
||||
background: var(--primary-soft);
|
||||
color: var(--ink);
|
||||
}
|
||||
.helper-thinking { color: var(--muted); }
|
||||
.helper-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.helper-input input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
.helper-send {
|
||||
border: none;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.helper-send:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user