-
-
-
-
-
마음 채팅
+
+
마음 채팅
@@ -58,13 +94,11 @@ export default function AppSidebar({
새 대화
-
과거 대화
-
관련 부서 연결
@@ -72,38 +106,77 @@ export default function AppSidebar({
- {/* 수정 예정 */}
설정
- 준비중
+ {/* 글자 크기 */}
글자 크기
-
+ setFontPx((v) => Math.max(12, Number(v) - 1))}
+ aria-label="글자 작게"
+ >
작게
- 14px
-
+
+ {fontPx}px
+
+ setFontPx((v) => Math.min(24, Number(v) + 1))}
+ aria-label="글자 크게"
+ >
크게
-
-
-
-
음성 출력 볼륨
setFontPx(Number(e.target.value))}
+ className="w-full"
+ aria-label="글자 크기 슬라이더"
/>
-
80% (미구현)
+
+
+ {/* 음성 출력 볼륨 */}
+
+
+
+ 음성 출력 볼륨
+
+
+
setVolume(Number(e.target.value) / 100)}
+ className="w-full"
+ aria-label="음성 볼륨 슬라이더"
+ />
+
+
+
+
+
{Math.round(volume * 100)}%
diff --git a/ssh/src/pages/Chatbot/components/chat/ChatBubble.tsx b/ssh/src/pages/Chatbot/components/chat/ChatBubble.tsx
new file mode 100644
index 0000000..e1c461a
--- /dev/null
+++ b/ssh/src/pages/Chatbot/components/chat/ChatBubble.tsx
@@ -0,0 +1,125 @@
+import { memo } from 'react';
+import { cn } from '@/lib/utils'; // shadcn 유틸(없으면 className join 함수로 대체)
+import logo2 from '@/assets/logo2.png';
+import { CircleUser } from 'lucide-react';
+
+export type ChatRole = 'user' | 'assistant';
+
+export type ChatBubbleProps = {
+ role: ChatRole;
+ text: string;
+ time?: string; // "오후 3:21" 같은 표시용
+ showAvatar?: boolean; // 첫 말풍선에만 아바타
+ showTail?: boolean; // 묶음의 마지막 말풍선에만 꼬리
+ stackPosition?: 'single' | 'top' | 'mid' | 'bottom';
+ audioUrl?: string;
+ videoUrl?: string;
+ fontPx?: number; // 글자 크기 동기화
+ volume?: number;
+};
+
+function formatText(t: string) {
+ return t.replace(/\n/g, '\n');
+}
+
+export const ChatBubble = memo(function ChatBubble({
+ role,
+ text,
+ time,
+ showAvatar = false,
+ showTail = true,
+ stackPosition = 'single',
+ audioUrl,
+ videoUrl,
+ fontPx = 16,
+ volume = 0.8,
+}: ChatBubbleProps) {
+ const isUser = role === 'user';
+
+ return (
+
+ {/* 왼쪽 아바타 (assistant) */}
+ {!isUser && (
+
+ {showAvatar ? (
+
+
+
+ ) : (
+
+ )}
+
+ )}
+
+ {/* 말풍선 + 시간 */}
+
+
+ {formatText(text)}
+
+ {videoUrl && (
+
+ )}
+
+ {audioUrl && (
+
{
+ const el = e.currentTarget as HTMLAudioElement;
+ el.volume = Math.max(0, Math.min(1, volume));
+ }}
+ />
+ )}
+
+
+ {/* 시간 */}
+ {time && (
+
{time}
+ )}
+
+
+ {/* 오른쪽 아바타 (user) */}
+ {isUser && (
+
+ {showAvatar ? (
+
+
+
+ ) : (
+
+ )}
+
+ )}
+
+ );
+});
diff --git a/ssh/src/pages/Chatbot/components/chat/ChatDateDivider.tsx b/ssh/src/pages/Chatbot/components/chat/ChatDateDivider.tsx
new file mode 100644
index 0000000..a19c070
--- /dev/null
+++ b/ssh/src/pages/Chatbot/components/chat/ChatDateDivider.tsx
@@ -0,0 +1,9 @@
+export default function ChatDateDivider({ date }: { date: string }) {
+ return (
+
+
+ {date}
+
+
+ );
+}
diff --git a/ssh/src/pages/Chatbot/components/chat/chat-utils.ts b/ssh/src/pages/Chatbot/components/chat/chat-utils.ts
new file mode 100644
index 0000000..147fb7a
--- /dev/null
+++ b/ssh/src/pages/Chatbot/components/chat/chat-utils.ts
@@ -0,0 +1,58 @@
+import type { ChatMessage } from '@/pages/Chatbot/components/chat/types';
+
+export type DecoratedMsg = ChatMessage & {
+ time?: string;
+ showAvatar?: boolean;
+ showTail?: boolean;
+ stackPosition?: 'single' | 'top' | 'mid' | 'bottom';
+ dateKey: string;
+};
+
+const sameSender = (a: ChatMessage, b: ChatMessage) => a.role === b.role;
+
+export function decorateMessages(
+ items: ChatMessage[],
+ opts?: { locale?: string },
+): Array<{ type: 'date'; dateKey: string } | { type: 'msg'; item: DecoratedMsg }> {
+ const out: Array<{ type: 'date'; dateKey: string } | { type: 'msg'; item: DecoratedMsg }> = [];
+ const locale = opts?.locale ?? 'ko-KR';
+
+ const fmtTime = () =>
+ new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit' }).format(new Date());
+ const fmtDateKey = (d: Date) =>
+ new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(d);
+
+ let prevDateKey = '';
+
+ items.forEach((m, i) => {
+ const dateKey = fmtDateKey(new Date()); // TODO: 실제 메시지 시간으로 교체
+ if (dateKey !== prevDateKey) {
+ out.push({ type: 'date', dateKey });
+ prevDateKey = dateKey;
+ }
+
+ const prev = items[i - 1];
+ const next = items[i + 1];
+ const isTop = !prev || !sameSender(prev, m);
+ const isBottom = !next || !sameSender(next, m);
+ const stackPosition =
+ isTop && isBottom ? 'single' : isTop ? 'top' : isBottom ? 'bottom' : 'mid';
+
+ const showAvatar = isBottom;
+ const showTail = isBottom;
+
+ out.push({
+ type: 'msg',
+ item: {
+ ...m,
+ time: fmtTime(),
+ showAvatar,
+ showTail,
+ stackPosition,
+ dateKey,
+ },
+ });
+ });
+
+ return out;
+}
diff --git a/ssh/src/pages/Chatbot/components/chat/types.ts b/ssh/src/pages/Chatbot/components/chat/types.ts
new file mode 100644
index 0000000..39fda6c
--- /dev/null
+++ b/ssh/src/pages/Chatbot/components/chat/types.ts
@@ -0,0 +1,10 @@
+// 공통 채팅 타입 (모든 컴포넌트가 이걸 import)
+export type ChatRole = 'user' | 'assistant';
+
+export type ChatMessage = {
+ role: ChatRole;
+ text: string;
+ audioUrl?: string;
+ videoUrl?: string;
+ // 필요하면 createdAt?: number; 등 추가
+};
diff --git a/ssh/src/pages/Chatbot/components/chatbot.tsx b/ssh/src/pages/Chatbot/components/chatbot.tsx
index 50e4cb0..3c1b4cf 100644
--- a/ssh/src/pages/Chatbot/components/chatbot.tsx
+++ b/ssh/src/pages/Chatbot/components/chatbot.tsx
@@ -1,4 +1,4 @@
-import { useState } from 'react';
+import { useEffect, useRef, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { ROUTING_PATH } from '@/routes/path.constants';
import { Button } from '@/components/ui/button';
@@ -18,7 +18,6 @@ import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { SidebarProvider } from '@/components/ui/sidebar';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import logo2 from '@/assets/logo2.png';
-
import {
ArrowLeft,
Bell,
@@ -29,15 +28,472 @@ import {
PanelLeftOpen,
Phone,
Send,
+ Loader2,
} from 'lucide-react';
import AppSidebar from './appsidebar';
+import { ChatBubble } from '@/pages/Chatbot/components/chat/ChatBubble';
+import ChatDateDivider from '@/pages/Chatbot/components/chat/ChatDateDivider';
+import { decorateMessages } from '@/pages/Chatbot/components/chat/chat-utils';
+import type { ChatMessage } from '@/pages/Chatbot/components/chat/types';
+
+// 16k 변환 유틸
+import { blobToWav16kMono } from '@/pages/Donation/sections/Features/Register/utils/audio.ts';
+
+type SpeechApiResponse = {
+ userMessageText: string;
+ chatbotMessageText: string;
+ chatbotAudio: string | null;
+ actionType: string | null;
+ actionData: any | null;
+ conversationContext: string | null;
+};
+
+type StartApiSuccess = {
+ success: true;
+ data: {
+ greetingText?: string;
+ options?: Array<{ text: string; message: string }>;
+ };
+ error: null;
+};
+
+type MessageApiSuccess = {
+ success: true;
+ data: {
+ responseText: string;
+ actionType?: 'SPEAK' | 'HIGHLIGHT_ELEMENT' | 'SHOW_VIDEO';
+ actionData?: any;
+ conversationContext?: string | null;
+ };
+ error: null;
+};
+
+type MessageApiError = {
+ success: false;
+ data: null;
+ error: { message?: string; status?: number } | null;
+};
+
export default function Chatbot() {
const navigate = useNavigate();
const [value, setValue] = useState('');
const [desktopOpen, setDesktopOpen] = useState(true);
const [mobileOpen, setMobileOpen] = useState(false);
+ // 음성 녹음/업로드 관련
+ const [isRecording, setIsRecording] = useState(false);
+ const [seconds, setSeconds] = useState(0);
+ const [uploading, setUploading] = useState(false);
+
+ const mediaRef = useRef
(null);
+ const chunksRef = useRef([]);
+ const timerRef = useRef(null);
+ const streamRef = useRef(null);
+
+ // 대화 상태
+ const [conversationContext, setConversationContext] = useState(null);
+ const [messages, setMessages] = useState([]);
+ const objectUrlsRef = useRef([]);
+ const audioRefs = useRef>({});
+
+ // start API
+ const [greetingText, setGreetingText] = useState('');
+ const [quickSelections, setQuickSelections] = useState>(
+ [],
+ );
+ const [startLoading, setStartLoading] = useState(false);
+
+ // 글자 크기 & 볼륨 (로컬 보존)
+ const [fontPx, setFontPx] = useState(() => {
+ const saved = localStorage.getItem('chat.fontPx');
+ return saved ? Number(saved) : 16;
+ });
+ const [volume, setVolume] = useState(() => {
+ const saved = localStorage.getItem('chat.volume');
+ return saved ? Number(saved) : 0.8; // 0~1
+ });
+
+ // Web Speech TTS
+ const speechRef = useRef(null);
+ const speak = (text: string) => {
+ if (!text) return;
+ try {
+ if (typeof window.speechSynthesis === 'undefined') return;
+ // 진행 중인 발화 취소
+ window.speechSynthesis.cancel();
+ const u = new SpeechSynthesisUtterance(text);
+ u.volume = Math.max(0, Math.min(1, volume));
+ u.lang = 'ko-KR';
+ u.rate = 1.0;
+ u.pitch = 1.0;
+ speechRef.current = u;
+ window.speechSynthesis.speak(u);
+ } catch (e) {
+ console.warn('TTS 실패:', e);
+ }
+ };
+
+ // 마운트/언마운트
+ useEffect(() => {
+ // 언마운트 시 TTS/리소스 정리
+ return () => {
+ try {
+ window.speechSynthesis?.cancel();
+ } catch {}
+ objectUrlsRef.current.forEach((u) => URL.revokeObjectURL(u));
+ objectUrlsRef.current = [];
+ stopTimer();
+ stopTracks();
+ };
+ }, []);
+
+ // start API 호출
+ useEffect(() => {
+ (async () => {
+ try {
+ setStartLoading(true);
+ const res = await fetch('/api/public/chat/start', {
+ method: 'GET',
+ credentials: 'include',
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data: StartApiSuccess = await res.json();
+
+ if (data?.success && data.data) {
+ setGreetingText(
+ data.data.greetingText ??
+ '안녕하세요. 무엇을 도와드릴까요? 아래에서 항목을 선택하거나 메시지를 입력해 주세요.',
+ );
+
+ const raw = Array.isArray(data.data.options) ? data.data.options : [];
+ const normalized = raw
+ .map((it) => {
+ const text = String(it?.text ?? '').trim();
+ const msgRaw = typeof it?.message === 'string' ? it.message : '';
+ const message = (msgRaw || text).trim();
+ return { text, message };
+ })
+ .filter((it) => it.text.length > 0 && it.message.length > 0);
+
+ setQuickSelections(normalized);
+ }
+ } catch (e) {
+ console.error(e);
+ // 폴백
+ setGreetingText(
+ "안녕하세요, 어르신. 디지털 금융 동반자 '마음 잇는 목소리'입니다. 아래에서 원하시는 서비스를 선택하시거나, 편하게 말씀해주세요.",
+ );
+
+ setQuickSelections([
+ { text: '주변 ATM 찾기 안내', message: 'ATM은 어떻게 찾아요?' },
+ { text: '보이스피싱 진단 안내', message: '보이스피싱은 어떻게 확인해요?' },
+ { text: '유산 기부 방법 안내', message: '유산 기부는 어떻게 해요?' },
+ ]);
+ } finally {
+ setStartLoading(false);
+ }
+ })();
+ }, []);
+
+ // 로컬스토리지 동기화
+ useEffect(() => localStorage.setItem('chat.fontPx', String(fontPx)), [fontPx]);
+ useEffect(() => localStorage.setItem('chat.volume', String(volume)), [volume]);
+
+ // 볼륨 변경 시 에 반영
+ useEffect(() => {
+ Object.values(audioRefs.current).forEach((el) => {
+ if (el) el.volume = volume;
+ });
+ }, [volume]);
+
+ // MediaRecorder util
+ const pickMime = () => {
+ const MR = (window as any).MediaRecorder;
+ if (!MR) return '';
+ if (MR.isTypeSupported('audio/webm')) return 'audio/webm';
+ if (MR.isTypeSupported('audio/mp4')) return 'audio/mp4';
+ if (MR.isTypeSupported('audio/ogg')) return 'audio/ogg';
+ return '';
+ };
+ const startTimer = () => {
+ stopTimer();
+ timerRef.current = window.setInterval(() => setSeconds((s) => s + 1), 1000);
+ };
+ const stopTimer = () => {
+ if (timerRef.current) {
+ window.clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ };
+ const stopTracks = () => {
+ streamRef.current?.getTracks().forEach((t) => t.stop());
+ streamRef.current = null;
+ };
+
+ // Base64 -> Blob URL
+ const base64ToUrl = (base64: string, mime = 'audio/mpeg') => {
+ try {
+ const byteStr = atob(base64);
+ const bytes = new Uint8Array(byteStr.length);
+ for (let i = 0; i < byteStr.length; i++) bytes[i] = byteStr.charCodeAt(i);
+ const blob = new Blob([bytes], { type: mime });
+ const url = URL.createObjectURL(blob);
+ objectUrlsRef.current.push(url);
+ return url;
+ } catch {
+ return null;
+ }
+ };
+
+ // ===== 액션 처리기 =====
+ const performAction = (
+ actionType: string,
+ actionData: any,
+ patch: (x: Partial) => void,
+ ) => {
+ switch (actionType) {
+ case 'HIGHLIGHT_ELEMENT': {
+ const sel = actionData?.elementId as string | undefined;
+ if (sel) {
+ const el = document.querySelector(sel) as HTMLElement | null;
+ if (el) {
+ el.classList.add('ring-4', 'ring-primary', 'ring-offset-2', 'rounded-md');
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ setTimeout(() => {
+ el.classList.remove('ring-4', 'ring-primary', 'ring-offset-2', 'rounded-md');
+ }, 2000);
+ }
+ }
+ break;
+ }
+ case 'SHOW_VIDEO': {
+ const url = actionData?.videoUrl as string | undefined;
+ if (url) patch({ videoUrl: url });
+ break;
+ }
+ case 'SPEAK': {
+ // speak는 sendTextMessage에서 next.text로 호출
+ break;
+ }
+ default:
+ break;
+ }
+ };
+
+ // ===== 텍스트 메시지 전송 =====
+ const sendTextMessage = async (userText: string) => {
+ const msg = (userText ?? '').trim();
+ if (!msg) return;
+
+ // 1) 사용자 메시지 먼저 화면에 출력
+ setMessages((prev) => [...prev, { role: 'user', text: msg }]);
+
+ try {
+ const payload: Record = { userMessage: msg };
+ if (typeof conversationContext === 'string' && conversationContext.length > 0) {
+ payload.conversationContext = conversationContext;
+ }
+
+ const res = await fetch('/api/public/chat/message', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify(payload),
+ credentials: 'include',
+ });
+
+ const raw = await res.text();
+
+ // 2) 타입 유니온으로 파싱
+ let json: MessageApiSuccess | MessageApiError | null = null;
+ try {
+ json = raw ? (JSON.parse(raw) as MessageApiSuccess | MessageApiError) : null;
+ } catch {
+ // JSON 파싱 실패
+ }
+
+ // 3) HTTP 오류 처리
+ if (!res.ok) {
+ const detail = (json as MessageApiError)?.error?.message ?? raw ?? `HTTP ${res.status}`;
+ throw new Error(detail);
+ }
+
+ // 4) 성공 응답 체크
+ if (!json?.success || !json.data) {
+ throw new Error(`Unexpected response: ${raw?.slice(0, 300)}`);
+ }
+
+ // 5) 데이터 구조 분해
+ const { responseText, actionType, actionData, conversationContext: newCtx } = json.data;
+
+ let next: ChatMessage = { role: 'assistant', text: responseText || '' };
+
+ // 6) 액션 처리 (SHOW_VIDEO 등)
+ if (actionType) {
+ performAction(actionType, actionData, (patch) => {
+ next = { ...next, ...patch };
+ });
+ }
+
+ // 7) SPEAK 액션이면 즉시 TTS 실행
+ if (actionType === 'SPEAK') {
+ speak(next.text);
+ }
+
+ // 8) 메시지 출력
+ setMessages((prev) => [...prev, next]);
+
+ // 9) 컨텍스트 갱신
+ if (typeof newCtx === 'string') {
+ setConversationContext(newCtx || null);
+ }
+ } catch (e: any) {
+ console.error('[chat/message] failed:', e);
+ setMessages((prev) => [
+ ...prev,
+ {
+ role: 'assistant',
+ text: `서버가 요청을 처리하지 못했습니다.\n상세: ${e?.message ?? e}`,
+ },
+ ]);
+ }
+ };
+
+ // 빠른 선택 클릭
+ const handleQuickClick = (m: string) => {
+ const msg = (m ?? '').trim();
+ if (!msg) return;
+ sendTextMessage(msg);
+ };
+
+ // ===== 음성 녹음/전송 =====
+ const handleStartRecording = async () => {
+ if (uploading) return;
+ try {
+ if (!navigator.mediaDevices?.getUserMedia) {
+ alert('이 브라우저는 녹음을 지원하지 않습니다.');
+ return;
+ }
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ streamRef.current = stream;
+ chunksRef.current = [];
+
+ const mimeType = pickMime() || undefined;
+ const mr = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
+ mediaRef.current = mr;
+
+ mr.ondataavailable = (e) => e.data?.size && chunksRef.current.push(e.data);
+ mr.onstop = async () => {
+ const raw = new Blob(chunksRef.current, { type: mr.mimeType || 'audio/webm' });
+ let wav16: Blob;
+ try {
+ wav16 = await blobToWav16kMono(raw, 16000);
+ } catch (err) {
+ console.error('16k 변환 실패, 원본으로 전송', err);
+ wav16 = raw;
+ }
+ await sendSpeech(wav16);
+ stopTimer();
+ stopTracks();
+ setSeconds(0);
+ };
+
+ setIsRecording(true);
+ setSeconds(0);
+ mr.start(100);
+ startTimer();
+ } catch (e: any) {
+ alert(e?.message ?? '마이크 권한을 확인해 주세요.');
+ }
+ };
+
+ const handleStopRecording = () => {
+ if (!isRecording) return;
+ setIsRecording(false);
+ mediaRef.current?.stop();
+ };
+
+ const sendSpeech = async (wavBlob: Blob) => {
+ setUploading(true);
+ try {
+ const fd = new FormData();
+ fd.append('audioFile', new File([wavBlob], 'speech.wav', { type: 'audio/wav' }));
+ if (conversationContext) fd.append('conversationContext', conversationContext);
+
+ const res = await fetch('/api/public/chat/speech', {
+ method: 'POST',
+ body: fd,
+ credentials: 'include',
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+
+ const data: SpeechApiResponse = await res.json();
+
+ if (data.userMessageText) {
+ setMessages((prev) => [...prev, { role: 'user', text: data.userMessageText }]);
+ }
+
+ let audioUrl: string | undefined;
+ if (data.chatbotAudio) {
+ audioUrl = base64ToUrl(data.chatbotAudio, 'audio/mpeg') ?? undefined;
+ }
+
+ setMessages((prev) => [
+ ...prev,
+ { role: 'assistant', text: data.chatbotMessageText || '(응답 없음)', audioUrl },
+ ]);
+
+ if (data.conversationContext) setConversationContext(data.conversationContext);
+
+ if (data.actionType) {
+ // 필요 시 음성 경로에서도 SHOW_VIDEO/HIGHLIGHT 처리 가능
+ performAction(data.actionType, data.actionData, () => {});
+ }
+ } catch (err: any) {
+ console.error(err);
+ alert('음성 전송 중 오류가 발생했습니다.');
+ } finally {
+ setUploading(false);
+ }
+ };
+
+ // UI
+ const recordingBadge = isRecording ? (
+
+
+ 녹음 중… {String(Math.floor(seconds / 60)).padStart(2, '0')}:
+ {String(seconds % 60).padStart(2, '0')}
+
+ ) : null;
+
+ const handleSendTextClick = () => {
+ const msg = value.trim();
+ if (!msg) return;
+ setValue('');
+ sendTextMessage(msg);
+ };
+
+ const bottomRef = useRef(null);
+
+ const scrollToBottom = useCallback(() => {
+ // 1) 우선 기준점으로 스무스 스크롤
+ bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
+
+ // 2) 레이아웃 확정 이후(iframe/audio 로드 등) 강제 보정
+ requestAnimationFrame(() => {
+ const viewport = bottomRef.current?.closest(
+ '[data-radix-scroll-area-viewport]',
+ ) as HTMLElement | null;
+ if (viewport) {
+ viewport.scrollTop = viewport.scrollHeight;
+ }
+ });
+ }, []);
+
+ useEffect(() => {
+ scrollToBottom();
+ }, [messages, scrollToBottom]);
+
return (
@@ -46,6 +502,10 @@ export default function Chatbot() {
setDesktopOpen(false)}
+ fontPx={fontPx}
+ setFontPx={setFontPx}
+ volume={volume}
+ setVolume={setVolume}
/>
)}
@@ -87,24 +547,29 @@ export default function Chatbot() {
-
-
+