Agent 2.0 exdev 서버 배포 스택

- server-dev start/stop/deploy 및 Gitea push 자동 배포
- local-dev 로컬 개발 환경

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-07-21 22:57:30 +09:00
commit 4b86b2a660
344 changed files with 45787 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
import type { Metadata, Viewport } from "next";
import { getUiMode } from "@/lib/uiMode";
import "./globals.css";
const uiMode = getUiMode();
const titles: Record<string, string> = {
app: "ex AI Chatbot 2.0",
ai: "ex AI Chatbot 2.0",
banner: "ex AI Chatbot 2.0",
voicebot: "ex AI Chatbot 2.0",
};
const descriptions: Record<string, string> = {
app: "고속도로 이용 관련 AI 상담",
ai: "고속도로·휴게소·통행료 AI 상담 (AI 모드)",
banner: "검색창 + 드롭다운 AI 검색 (임베드용)",
voicebot: "음성으로 고속도로 이용 AI 상담",
};
export const metadata: Metadata = {
title: titles[uiMode] ?? titles.app,
description: descriptions[uiMode] ?? descriptions.app,
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: uiMode === "app" ? "#b2c7d9" : "#246beb",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
const htmlClass =
uiMode === "ai"
? "ai-mode"
: uiMode === "banner"
? "banner-mode"
: uiMode === "voicebot"
? "voicebot-mode"
: undefined;
const bodyAttr =
uiMode === "ai"
? "ai-mode"
: uiMode === "banner"
? "banner-mode"
: uiMode === "voicebot"
? "voicebot-mode"
: "next-sse";
return (
<html lang="ko" className={htmlClass}>
<body data-chatbot-app={bodyAttr}>{children}</body>
</html>
);
}
@@ -0,0 +1,13 @@
import { AiChatApp } from "@/components/ai/AiChatApp";
import { BannerChatApp } from "@/components/banner/BannerChatApp";
import { ChatApp } from "@/components/ChatApp";
import { VoicebotChatApp } from "@/components/voicebot/VoicebotChatApp";
import { getUiMode } from "@/lib/uiMode";
export default function Page() {
const mode = getUiMode();
if (mode === "banner") return <BannerChatApp />;
if (mode === "ai") return <AiChatApp />;
if (mode === "voicebot") return <VoicebotChatApp />;
return <ChatApp />;
}
@@ -0,0 +1,43 @@
import type { WebActionButton } from "@/lib/types";
interface ActionButtonListProps {
buttons?: WebActionButton[];
}
function resolveHref(button: WebActionButton): string | undefined {
if (button.action === "phone") {
const number = button.phoneNumber?.replace(/[^0-9+]/g, "");
return number ? `tel:${number}` : button.url;
}
return button.url || button.webUrl || button.pcUrl || button.mobileUrl || button.androidUrl || button.iosUrl;
}
function isExternalHref(href: string): boolean {
return /^https?:\/\//i.test(href);
}
export function ActionButtonList({ buttons }: ActionButtonListProps) {
const resolvedButtons = (buttons ?? [])
.map((button) => ({ button, href: resolveHref(button) }))
.filter((item): item is { button: WebActionButton; href: string } => Boolean(item.href));
if (resolvedButtons.length === 0) {
return null;
}
return (
<div className="action-buttons">
{resolvedButtons.map(({ button, href }, index) => (
<a
key={`${button.action ?? "link"}-${button.label}-${href}-${index}`}
className="action-button"
href={href}
target={isExternalHref(href) ? "_blank" : undefined}
rel={isExternalHref(href) ? "noopener noreferrer" : undefined}
>
{button.label || "바로가기"}
</a>
))}
</div>
);
}
@@ -0,0 +1,65 @@
"use client";
import { ExBrandTitle } from "@/components/ExBrandTitle";
import { ChatCloseButton } from "@/components/ChatCloseButton";
import { Composer } from "@/components/Composer";
import { FaqPanel } from "@/components/FaqPanel";
import { MessageList } from "@/components/MessageList";
import { RelatedQuestionsBar } from "@/components/RelatedQuestionsBar";
import { useEmbedShell } from "@/hooks/useEmbedShell";
import { useChatSession } from "@/hooks/useChatSession";
import { getLatestAssistantQuickReplies } from "@/lib/relatedQuestions";
import { WELCOME_TEXT } from "@/lib/config";
export function ChatApp() {
const {
messages,
busy,
faqOpen,
faqLoading,
faqCategories,
faqSupported,
sendMessage,
toggleFaq,
setFaqOpen,
} = useChatSession();
const embedded = useEmbedShell();
const hasConversation = messages.length > 0;
const relatedQuestions = getLatestAssistantQuickReplies(messages);
return (
<div className={`app${embedded ? " app--embed" : ""}`} data-chatbot-app="next-sse">
<header className="app-header">
<ExBrandTitle as="h1" className="app-header-brand" />
{embedded ? (
<div className="app-header-actions">
<ChatCloseButton className="app-btn-close" />
</div>
) : null}
</header>
<MessageList messages={messages} welcome={WELCOME_TEXT} onSuggestionSelect={(text) => void sendMessage(text)} />
{hasConversation && relatedQuestions.length > 0 ? (
<RelatedQuestionsBar
variant="app"
items={relatedQuestions}
busy={busy}
onSelect={(text) => void sendMessage(text)}
/>
) : null}
<Composer
busy={busy}
faqEnabled={faqSupported}
onSend={(text) => void sendMessage(text)}
onFaqToggle={toggleFaq}
/>
<FaqPanel
open={faqOpen}
loading={faqLoading}
categories={faqCategories}
onClose={() => setFaqOpen(false)}
onSelect={(question) => void sendMessage(question)}
/>
</div>
);
}
@@ -0,0 +1,29 @@
"use client";
import { requestChatClose } from "@/lib/embedShell";
interface ChatCloseButtonProps {
className?: string;
label?: string;
}
export function ChatCloseButton({
className = "ai-btn-close",
label = "종료",
}: ChatCloseButtonProps) {
return (
<button
type="button"
className={className}
aria-label={label}
onClick={requestChatClose}
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path
fill="currentColor"
d="M18.3 5.71a1 1 0 0 0-1.41 0L12 10.59 7.11 5.7A1 1 0 0 0 5.7 7.11L10.59 12 5.7 16.89a1 1 0 1 0 1.41 1.41L12 13.41l4.89 4.89a1 1 0 0 0 1.41-1.41L13.41 12l4.89-4.89a1 1 0 0 0 0-1.4z"
/>
</svg>
</button>
);
}
@@ -0,0 +1,137 @@
"use client";
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
interface ComposerProps {
busy: boolean;
faqEnabled?: boolean;
onSend: (text: string) => void;
onFaqToggle: () => void;
}
export function Composer({ busy, faqEnabled = true, onSend, onFaqToggle }: ComposerProps) {
const [value, setValue] = useState("");
const [listening, setListening] = useState(false);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const voiceBaseRef = useRef("");
const speechSupported =
typeof window !== "undefined" &&
!!(window.SpeechRecognition || window.webkitSpeechRecognition);
useEffect(() => {
if (!speechSupported) return;
const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!Ctor) return;
const recognition = new Ctor();
recognition.lang = "ko-KR";
recognition.interimResults = true;
recognition.continuous = false;
recognition.maxAlternatives = 1;
recognition.onstart = () => setListening(true);
recognition.onend = () => setListening(false);
recognition.onerror = () => setListening(false);
recognition.onresult = (event: SpeechRecognitionEvent) => {
let transcript = "";
for (let i = event.resultIndex; i < event.results.length; i += 1) {
transcript += event.results[i][0].transcript;
}
setValue(voiceBaseRef.current + transcript.trim());
};
recognitionRef.current = recognition;
return () => {
try {
recognition.stop();
} catch {
/* noop */
}
};
}, [speechSupported]);
const submit = useCallback(
(e?: FormEvent) => {
e?.preventDefault();
const text = value.trim();
if (!text || busy) return;
onSend(text);
setValue("");
voiceBaseRef.current = "";
},
[busy, onSend, value],
);
const toggleVoice = () => {
const recognition = recognitionRef.current;
if (!recognition || busy) return;
if (listening) {
recognition.stop();
return;
}
voiceBaseRef.current = value.trim() ? `${value.trim()} ` : "";
try {
recognition.start();
} catch {
/* noop */
}
};
return (
<footer className="composer">
<form id="form" autoComplete="off" onSubmit={submit}>
<input
id="input"
type="text"
placeholder="메시지를 입력하세요"
maxLength={500}
enterKeyHint="send"
value={value}
disabled={busy}
onChange={(e) => setValue(e.target.value)}
/>
<div className="faq-wrap">
{faqEnabled ? (
<button
type="button"
id="faqBtn"
className="btn-faq"
aria-label="자주 묻는 질문"
disabled={busy}
onClick={onFaqToggle}
>
<svg className="icon-faq" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path
fill="currentColor"
d="M12 2a7 7 0 0 0-7 7c0 2.8 1.6 5.2 4 6.3V18a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-2.7A7 7 0 0 0 19 9a7 7 0 0 0-7-7zm-1 15v1h2v-1h-2zm0-11.5A4.5 4.5 0 0 1 15.5 9 4.5 4.5 0 0 1 11 13.5 4.5 4.5 0 0 1 6.5 9 4.5 4.5 0 0 1 11 5.5z"
/>
</svg>
</button>
) : null}
</div>
<button
type="button"
id="voiceBtn"
className={`btn-voice${listening ? " listening" : ""}${!speechSupported ? " unsupported" : ""}`}
aria-label={listening ? "음성 입력 중 — 탭하여 중지" : "음성 입력"}
disabled={busy || !speechSupported}
onClick={toggleVoice}
>
<svg className="icon-mic" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path
fill="currentColor"
d="M12 14a3 3 0 0 0 3-3V5a3 3 0 1 0-6 0v6a3 3 0 0 0 3 3zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.92V21h2v-3.08A7 7 0 0 0 19 11h-2z"
/>
</svg>
</button>
</form>
</footer>
);
}
declare global {
interface Window {
SpeechRecognition: typeof SpeechRecognition;
webkitSpeechRecognition: typeof SpeechRecognition;
}
}
@@ -0,0 +1,38 @@
import { CHATBOT_BRAND_SUFFIX } from "@/lib/brand";
import "@/styles/ex-brand.css";
type ExBrandTitleProps = {
as?: "h1" | "h2" | "div" | "span";
className?: string;
size?: "md" | "sm";
variant?: "light" | "dark";
showDivider?: boolean;
};
export function ExBrandTitle({
as: Tag = "div",
className = "",
size = "md",
variant = "light",
showDivider = true,
}: ExBrandTitleProps) {
const classes = [
"ex-brand",
variant === "dark" ? "ex-brand--dark" : "",
size === "sm" ? "ex-brand--sm" : "",
className,
]
.filter(Boolean)
.join(" ");
return (
<Tag className={classes}>
<span className="ex-brand-mark" aria-hidden="true">
<span className="ex-brand-e">e</span>
<span className="ex-brand-x">x</span>
</span>
{showDivider ? <span className="ex-brand-divider" aria-hidden="true" /> : null}
<span className="ex-brand-text">{CHATBOT_BRAND_SUFFIX}</span>
</Tag>
);
}
@@ -0,0 +1,50 @@
import type { FaqCategory } from "@/lib/types";
interface FaqPanelProps {
open: boolean;
loading: boolean;
categories: FaqCategory[];
onClose: () => void;
onSelect: (question: string) => void;
}
export function FaqPanel({ open, loading, categories, onClose, onSelect }: FaqPanelProps) {
return (
<div
id="faqPanel"
className={`faq-panel${open ? "" : " faq-panel--closed"}`}
hidden={!open}
role="dialog"
aria-label="자주 묻는 질문"
aria-modal="true"
>
<div className="faq-panel-head">
<span>자주 묻는 질문</span>
<button type="button" className="faq-close" aria-label="닫기" onClick={onClose}>
×
</button>
</div>
<div className="faq-body">
{loading && <p className="faq-empty">FAQ 불러오는 중…</p>}
{!loading && categories.length === 0 && (
<p className="faq-empty">표시할 FAQ가 없습니다.</p>
)}
{!loading &&
categories.map((cat) => (
<section key={cat.category} className="faq-section">
<h3 className="faq-cat">{cat.category}</h3>
<ul className="faq-list">
{cat.items.map((item) => (
<li key={item.question}>
<button type="button" className="faq-item" onClick={() => onSelect(item.question)}>
{item.question}
</button>
</li>
))}
</ul>
</section>
))}
</div>
</div>
);
}
@@ -0,0 +1,94 @@
import { formatFareWon, type ParsedFareResult } from "@/lib/fareResultCard";
import "@/styles/fare-result-card.css";
interface FareResultCardProps {
data: ParsedFareResult;
}
function SectionIcon({ kind }: { kind: "kec" | "private" }) {
return (
<span
className={`fare-result-card__section-icon fare-result-card__section-icon--${kind}`}
aria-hidden="true"
>
{kind === "kec" ? "🛣️" : "🏗️"}
</span>
);
}
function VehicleIcon({ imageUrl, label }: { imageUrl?: string; label: string }) {
if (imageUrl) {
return <img className="fare-result-card__vehicle-icon" src={imageUrl} alt="" loading="lazy" />;
}
return (
<span className="fare-result-card__vehicle-fallback" aria-hidden="true">
{label.includes("경차") ? "🚗" : "🚙"}
</span>
);
}
export function FareResultCard({ data }: FareResultCardProps) {
const showPrivateSection = data.privateFare > 0;
return (
<article className="fare-result-card">
<p className="fare-result-card__intro">{data.introText}</p>
<div className="fare-result-card__total-banner">
<span className="fare-result-card__total-label">총 통행료</span>
<strong className="fare-result-card__total-amount">{formatFareWon(data.primaryFare)}</strong>
<span className="fare-result-card__total-route">{data.routeLabel}</span>
</div>
<div className="fare-result-card__sections">
<div className="fare-result-card__section-row">
<div className="fare-result-card__section-label">
<SectionIcon kind="kec" />
<span>도로공사 구간 요금</span>
</div>
<strong className="fare-result-card__section-price">{formatFareWon(data.kecFare)}</strong>
</div>
{showPrivateSection ? (
<div className="fare-result-card__section-row">
<div className="fare-result-card__section-label">
<SectionIcon kind="private" />
<span>민자도로 구간 요금</span>
</div>
<strong className="fare-result-card__section-price">
{formatFareWon(data.privateFare)}
</strong>
</div>
) : null}
</div>
<div className="fare-result-card__vehicle-panel">
<h4 className="fare-result-card__vehicle-title">차종별 요금 안내</h4>
<ul className="fare-result-card__vehicle-list">
{data.vehicles.map((vehicle) => (
<li
key={`${vehicle.typeLabel}-${vehicle.amount}`}
className="fare-result-card__vehicle-item"
>
<div className="fare-result-card__vehicle-row">
<div className="fare-result-card__vehicle-label">
<VehicleIcon imageUrl={vehicle.imageUrl} label={vehicle.typeLabel} />
<span className="fare-result-card__vehicle-type">{vehicle.typeLabel}</span>
</div>
<strong className="fare-result-card__vehicle-total">
{formatFareWon(vehicle.amount)}
</strong>
</div>
{vehicle.privateAmount > 0 ? (
<p className="fare-result-card__vehicle-split">
도로공사 {formatFareWon(vehicle.kec)} · 민자 {formatFareWon(vehicle.privateAmount)}
</p>
) : null}
</li>
))}
</ul>
</div>
<p className="fare-result-card__note">* 차종별 편도 기준 (1종 승용차 기준)</p>
</article>
);
}
@@ -0,0 +1,104 @@
import { FareResultCard } from "@/components/FareResultCard";
import {
isFareListCard,
parseFareListCard,
} from "@/lib/fareResultCard";
import {
LIST_CARD_ICON,
LIST_CARD_LABEL,
listCardItemKindFromImageUrl,
type WebListCard,
} from "@/lib/listCards";
interface ListCardPanelProps {
card: WebListCard;
}
export function ListCardPanel({ card }: ListCardPanelProps) {
if (!card.items?.length) {
return null;
}
return (
<article className="list-card">
{card.title ? <h3 className="list-card__title">{card.title}</h3> : null}
<ul className="list-card__items">
{card.items.map((item, index) => {
const kind = listCardItemKindFromImageUrl(item.imageUrl);
const label = LIST_CARD_LABEL[kind];
const fallbackIcon = LIST_CARD_ICON[kind];
const key = `${item.title}-${item.description ?? ""}-${index}`;
const content = (
<>
<div className="list-card__meta">
<div className="list-card__price">{item.title}</div>
{item.description ? (
<div className="list-card__location">{item.description}</div>
) : null}
</div>
<div className={`list-card__fuel list-card__fuel--${kind}`} aria-hidden="true">
{item.imageUrl ? (
<img
className="list-card__thumb"
src={item.imageUrl}
alt=""
width={36}
height={36}
loading="lazy"
/>
) : (
<span className="list-card__fuel-icon">{fallbackIcon}</span>
)}
<span className="list-card__fuel-label">{label}</span>
</div>
</>
);
return (
<li className="list-card__row" key={key}>
{item.linkUrl ? (
<a
className="list-card__row-link"
href={item.linkUrl}
target={/^https?:\/\//i.test(item.linkUrl) ? "_blank" : undefined}
rel={/^https?:\/\//i.test(item.linkUrl) ? "noopener noreferrer" : undefined}
>
{content}
</a>
) : (
content
)}
</li>
);
})}
</ul>
</article>
);
}
interface ListCardStackProps {
cards?: WebListCard[];
}
export function ListCardStack({ cards }: ListCardStackProps) {
if (!cards?.length) {
return null;
}
const hasFare = cards.some(isFareListCard);
const stackClass = hasFare ? "list-card-stack list-card-stack--fare" : "list-card-stack";
return (
<div className={stackClass}>
{cards.map((card, index) => {
if (isFareListCard(card)) {
const parsed = parseFareListCard(card);
if (parsed) {
return <FareResultCard key={`fare-${index}`} data={parsed} />;
}
}
return <ListCardPanel key={`card-${card.title ?? "card"}-${index}`} card={card} />;
})}
</div>
);
}
@@ -0,0 +1,118 @@
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface MarkdownBodyProps {
text: string;
onSuggestionSelect?: (question: string) => void;
}
const SUGGESTION_MARKER = "혹시 이런 것을 찾으셨나요";
const SUGGESTION_LINE = /^\s*(\d+)[.)]\s*(.+?)\s*$/;
type MarkdownSection =
| { type: "markdown"; text: string }
| { type: "suggestions"; title: string; items: { index: string; question: string }[]; footer?: string };
function parseSuggestionSections(text: string): MarkdownSection[] {
const lines = text.split(/\r?\n/);
const sections: MarkdownSection[] = [];
let markdownLines: string[] = [];
let suggestionTitle = "";
let suggestionItems: { index: string; question: string }[] = [];
let suggestionFooter: string[] = [];
let inSuggestions = false;
const flushMarkdown = () => {
const value = markdownLines.join("\n").trim();
if (value) sections.push({ type: "markdown", text: value });
markdownLines = [];
};
const flushSuggestions = () => {
if (suggestionItems.length > 0) {
sections.push({
type: "suggestions",
title: suggestionTitle || "💡 혹시 이런 것을 찾으셨나요?",
items: suggestionItems,
footer: suggestionFooter.join("\n").trim() || undefined,
});
}
suggestionTitle = "";
suggestionItems = [];
suggestionFooter = [];
};
for (const line of lines) {
const trimmed = line.trim();
if (!inSuggestions && trimmed.includes(SUGGESTION_MARKER)) {
flushMarkdown();
inSuggestions = true;
suggestionTitle = trimmed;
continue;
}
if (inSuggestions) {
const match = trimmed.match(SUGGESTION_LINE);
if (match) {
suggestionItems.push({ index: match[1], question: match[2] });
continue;
}
if (trimmed) {
suggestionFooter.push(line);
}
continue;
}
markdownLines.push(line);
}
if (inSuggestions) {
flushSuggestions();
} else {
flushMarkdown();
}
return sections;
}
export function MarkdownBody({ text, onSuggestionSelect }: MarkdownBodyProps) {
const sections = parseSuggestionSections(text);
return (
<div className="bubble-body md-content">
{sections.map((section, index) => {
if (section.type === "markdown") {
return (
<ReactMarkdown key={`md-${index}`} remarkPlugins={[[remarkGfm, { singleTilde: false }]]}>
{section.text}
</ReactMarkdown>
);
}
return (
<div className="suggestion-block" key={`suggestions-${index}`}>
<p className="suggestion-block__title">{section.title}</p>
<ol className="suggestion-block__list">
{section.items.map((item) => (
<li key={`${item.index}-${item.question}`}>
<button
type="button"
className="suggestion-block__button"
disabled={!onSuggestionSelect}
onClick={() => onSuggestionSelect?.(item.question)}
>
<span className="suggestion-block__index">{item.index}.</span>
<span>{item.question}</span>
</button>
</li>
))}
</ol>
{section.footer ? (
<ReactMarkdown remarkPlugins={[[remarkGfm, { singleTilde: false }]]}>{section.footer}</ReactMarkdown>
) : null}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,55 @@
import { ListCardStack } from "@/components/ListCardStack";
import { ExBrandTitle } from "@/components/ExBrandTitle";
import { hasFareListCards } from "@/lib/fareResultCard";
import type { ChatMessage } from "@/lib/types";
import { MarkdownBody } from "./MarkdownBody";
interface MessageBubbleProps {
message: ChatMessage;
onSuggestionSelect?: (question: string) => void;
}
export function MessageBubble({ message, onSuggestionSelect }: MessageBubbleProps) {
if (message.role === "user") {
return (
<div className="msg-row user">
<div>
<div className="bubble user">{message.text}</div>
<div className="msg-time">{message.time}</div>
</div>
</div>
);
}
const showFareCard = !message.streaming && hasFareListCards(message.listCards);
const showText = Boolean(message.text) && !showFareCard;
return (
<div className="msg-row bot">
<div className="bot-avatar" aria-hidden="true">
<div className="bot-avatar-fallback">AI</div>
</div>
<div className="bot-content">
<ExBrandTitle as="div" size="sm" className="bot-name" />
{(message.streaming && !message.text && !message.listCards?.length) || showText ? (
<div className={`bubble bot bubble--rich${message.streaming ? " streaming" : ""}`}>
{message.streaming && !message.text && !message.listCards?.length ? (
<span className="loading-text">
{message.statusText || "답변준비중…"}
<span className="typing" aria-hidden="true">
<span />
<span />
<span />
</span>
</span>
) : showText ? (
<MarkdownBody text={message.text!} onSuggestionSelect={onSuggestionSelect} />
) : null}
</div>
) : null}
{!message.streaming ? <ListCardStack cards={message.listCards} /> : null}
<div className="msg-time">{message.time}</div>
</div>
</div>
);
}
@@ -0,0 +1,28 @@
import { useEffect, useRef } from "react";
import type { ChatMessage } from "@/lib/types";
import { MessageBubble } from "./MessageBubble";
interface MessageListProps {
messages: ChatMessage[];
welcome: string;
onSuggestionSelect?: (question: string) => void;
}
export function MessageList({ messages, welcome, onSuggestionSelect }: MessageListProps) {
const chatRef = useRef<HTMLElement>(null);
useEffect(() => {
const el = chatRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [messages]);
return (
<main className="chat" id="chat" ref={chatRef} aria-live="polite">
<div className="welcome-card">{welcome}</div>
{messages.map((message) => (
<MessageBubble key={message.id} message={message} onSuggestionSelect={onSuggestionSelect} />
))}
</main>
);
}
@@ -0,0 +1,34 @@
"use client";
import type { WebQuickReply } from "@/lib/types";
interface QuickReplyChipsProps {
items?: WebQuickReply[];
disabled?: boolean;
onSelect: (message: string) => void;
}
export function QuickReplyChips({ items, disabled, onSelect }: QuickReplyChipsProps) {
if (!items?.length) {
return null;
}
return (
<div className="quick-reply-wrap" role="group" aria-label="유사 질문">
<p className="quick-reply-title">이런 질문도 있어요</p>
<div className="quick-reply-list">
{items.map((item) => (
<button
key={item.message}
type="button"
className="quick-reply-chip"
disabled={disabled}
onClick={() => onSelect(item.message)}
>
{item.label}
</button>
))}
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import type { WebQuickReply } from "@/lib/types";
interface RelatedQuestionsBarProps {
items: WebQuickReply[];
busy: boolean;
onSelect: (message: string) => void;
variant?: "ai" | "app";
}
export function RelatedQuestionsBar({
items,
busy,
onSelect,
variant = "ai",
}: RelatedQuestionsBarProps) {
if (!items.length) {
return null;
}
const isAi = variant === "ai";
return (
<aside
className={isAi ? "ai-faq-bar related-questions-bar" : "related-questions-bar related-questions-bar--app"}
aria-label="관련 질문"
>
<div className={isAi ? "ai-faq-bar-head" : "related-questions-bar__head"}>관련 질문</div>
<div className={isAi ? "ai-faq-bar-scroll" : "related-questions-bar__scroll"}>
{items.map((item) => (
<button
key={item.message}
type="button"
className={isAi ? "ai-faq-chip" : "related-questions-bar__chip"}
disabled={busy}
onClick={() => onSelect(item.message)}
>
{item.label}
</button>
))}
</div>
</aside>
);
}
@@ -0,0 +1,89 @@
"use client";
import { useEffect } from "react";
import "@/styles/ai-mode.css";
import { FaqPanel } from "@/components/FaqPanel";
import { useChatSession } from "@/hooks/useChatSession";
import { ChatCloseButton } from "@/components/ChatCloseButton";
import { ExBrandTitle } from "@/components/ExBrandTitle";
import { RelatedQuestionsBar } from "@/components/RelatedQuestionsBar";
import { useEmbedShell } from "@/hooks/useEmbedShell";
import { getLatestAssistantQuickReplies } from "@/lib/relatedQuestions";
import { AiComposer } from "./AiComposer";
import { AiMessageList } from "./AiMessageList";
export function AiChatApp() {
const {
messages,
busy,
faqOpen,
faqLoading,
faqCategories,
faqSupported,
sendMessage,
toggleFaq,
loadFaq,
setFaqOpen,
} = useChatSession();
const embedded = useEmbedShell();
const hasConversation = messages.length > 0;
const relatedQuestions = getLatestAssistantQuickReplies(messages);
useEffect(() => {
if (faqSupported) void loadFaq();
}, [faqSupported, loadFaq]);
return (
<div
className={`ai-app${hasConversation ? " ai-app--chat" : " ai-app--landing"}${embedded ? " ai-app--embed" : ""}`}
data-chatbot-app="ai-mode"
>
<header className="ai-header">
<ExBrandTitle as="h1" className="ai-header-brand" />
<div className="ai-header-actions">
{faqSupported ? (
<button
type="button"
className={`ai-btn-ghost${faqOpen ? " is-open" : ""}`}
disabled={busy}
aria-expanded={faqOpen}
onClick={toggleFaq}
>
자주 묻는 질문
</button>
) : null}
<ChatCloseButton />
</div>
</header>
<AiMessageList
messages={messages}
busy={busy}
faqLoading={faqLoading}
faqCategories={faqCategories}
faqSupported={faqSupported}
onSuggestion={(text) => void sendMessage(text)}
/>
{hasConversation && relatedQuestions.length > 0 ? (
<RelatedQuestionsBar
variant="ai"
items={relatedQuestions}
busy={busy}
onSelect={(question) => void sendMessage(question)}
/>
) : null}
<AiComposer busy={busy} showFooterNote={hasConversation} onSend={(text) => void sendMessage(text)} />
<FaqPanel
open={faqOpen}
loading={faqLoading}
categories={faqCategories}
onClose={() => setFaqOpen(false)}
onSelect={(question) => void sendMessage(question)}
/>
</div>
);
}
@@ -0,0 +1,127 @@
"use client";
import { FormEvent, KeyboardEvent, useCallback, useRef, useState } from "react";
import { useSpeechRecognition } from "@/hooks/useSpeechRecognition";
import { AiVoiceOverlay } from "./AiVoiceOverlay";
interface AiComposerProps {
busy: boolean;
showFooterNote?: boolean;
onSend: (text: string) => void;
}
export function AiComposer({ busy, showFooterNote = true, onSend }: AiComposerProps) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const resizeTextarea = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
}, []);
const handleVoiceComplete = useCallback(
(text: string) => {
setValue(text);
requestAnimationFrame(resizeTextarea);
textareaRef.current?.focus();
},
[resizeTextarea],
);
const { active: voiceActive, interimText, speaking, error: voiceError, start, cancel, supported } =
useSpeechRecognition({ onComplete: handleVoiceComplete });
const submit = useCallback(
(e?: FormEvent) => {
e?.preventDefault();
const text = value.trim();
if (!text || busy) return;
onSend(text);
setValue("");
requestAnimationFrame(resizeTextarea);
},
[busy, onSend, resizeTextarea, value],
);
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const openVoiceInput = () => {
if (busy || voiceActive) return;
start(value);
};
const canSend = value.trim().length > 0 && !busy;
return (
<>
<footer className="ai-composer-wrap">
<div className="ai-composer-inner">
<form className="ai-composer-box" onSubmit={submit}>
<textarea
ref={textareaRef}
className="ai-composer-input"
placeholder="한국도로공사 AI에 물어보세요"
maxLength={500}
rows={1}
value={value}
disabled={busy || voiceActive}
onChange={(e) => {
setValue(e.target.value);
resizeTextarea();
}}
onKeyDown={onKeyDown}
aria-label="질문 입력"
/>
<div className="ai-composer-actions">
<button
type="button"
className={`ai-icon-btn${voiceActive ? " listening" : ""}${!supported ? " unsupported" : ""}`}
aria-label={voiceActive ? "음성 입력 중" : "음성 입력"}
disabled={busy}
onClick={openVoiceInput}
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path
fill="currentColor"
d="M12 14a3 3 0 0 0 3-3V5a3 3 0 1 0-6 0v6a3 3 0 0 0 3 3zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.92V21h2v-3.08A7 7 0 0 0 19 11h-2z"
/>
</svg>
</button>
<button
type="submit"
className={`ai-send-btn${canSend ? " is-ready" : ""}`}
aria-label="전송"
disabled={!canSend}
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true">
<path fill="currentColor" d="M3.4 20.4 22 12 3.4 3.6 3 10.8l12.8 1.2L3 13.2z" />
</svg>
</button>
</div>
</form>
{showFooterNote ? (
<p className="ai-footer-note">
AI 답변은 참고용이며, 정확한 정보는 한국도로공사 공식 안내를 확인해 주세요.
</p>
) : null}
</div>
</footer>
<AiVoiceOverlay
open={voiceActive}
listening={voiceActive && !voiceError}
speaking={speaking}
interimText={interimText}
error={voiceError}
onStop={cancel}
/>
</>
);
}
@@ -0,0 +1,53 @@
import { ActionButtonList } from "@/components/ActionButtonList";
import { ListCardStack } from "@/components/ListCardStack";
import { MarkdownBody } from "@/components/MarkdownBody";
import { hasFareListCards } from "@/lib/fareResultCard";
import type { ChatMessage } from "@/lib/types";
interface AiMessageBubbleProps {
message: ChatMessage;
busy?: boolean;
onSuggestionSelect?: (question: string) => void;
}
export function AiMessageBubble({ message, onSuggestionSelect }: AiMessageBubbleProps) {
if (message.role === "user") {
return (
<div className="ai-msg user">
<div className="ai-msg-bubble">{message.text}</div>
</div>
);
}
const loading = message.streaming && !message.text && !message.listCards?.length;
const showFareCard = !message.streaming && hasFareListCards(message.listCards);
const showText = Boolean(message.text) && !showFareCard;
return (
<div className="ai-msg assistant">
<div className="ai-msg-label">AI</div>
{(loading || showText) && (
<div className="ai-msg-bubble">
{loading ? (
<span className="ai-loading">
{message.statusText || "답변을 준비하고 있어요"}
<span className="ai-loading-dots" aria-hidden="true">
<span />
<span />
<span />
</span>
</span>
) : showText ? (
<MarkdownBody text={message.text!} onSuggestionSelect={onSuggestionSelect} />
) : null}
</div>
)}
{!message.streaming ? (
<div className="ai-list-cards">
<ListCardStack cards={message.listCards} />
</div>
) : null}
{!message.streaming ? <ActionButtonList buttons={message.actionButtons} /> : null}
</div>
);
}
@@ -0,0 +1,82 @@
import { useEffect, useRef } from "react";
import type { ChatMessage, FaqCategory } from "@/lib/types";
import { heroFaqQuestions } from "@/lib/faqItems";
import { aiAssetPath, AI_WELCOME_SUBTITLE, AI_WELCOME_TITLE } from "@/lib/uiMode";
import { AiMessageBubble } from "./AiMessageBubble";
interface AiMessageListProps {
messages: ChatMessage[];
busy?: boolean;
faqLoading?: boolean;
faqCategories?: FaqCategory[];
faqSupported?: boolean;
onSuggestion?: (text: string) => void;
}
export function AiMessageList({
messages,
busy,
faqLoading = false,
faqCategories = [],
faqSupported = false,
onSuggestion,
}: AiMessageListProps) {
const mainRef = useRef<HTMLElement>(null);
const isEmpty = messages.length === 0;
const heroQuestions = faqSupported ? heroFaqQuestions(faqCategories) : [];
const mascotSrc = aiAssetPath("/images/giltong.jpg");
useEffect(() => {
const el = mainRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [messages]);
return (
<main className="ai-main" ref={mainRef} aria-live="polite">
<div className="ai-main-inner">
{isEmpty ? (
<section className="ai-hero" aria-label="시작 안내">
<div className="ai-hero-layout">
<div className="ai-hero-copy">
<h1 className="ai-hero-greeting">{AI_WELCOME_TITLE}</h1>
<p className="ai-hero-question">{AI_WELCOME_SUBTITLE}</p>
{faqSupported ? (
<div className="ai-hero-faq">
<p className="ai-hero-faq-title">자주 묻는 질문</p>
{faqLoading && heroQuestions.length === 0 ? (
<p className="ai-hero-faq-loading">불러오는 중…</p>
) : (
<div className="ai-suggestions">
{heroQuestions.map((text) => (
<button
key={text}
type="button"
className="ai-suggestion-chip"
disabled={busy}
onClick={() => onSuggestion?.(text)}
>
{text}
</button>
))}
</div>
)}
</div>
) : null}
</div>
<div className="ai-hero-visual" aria-hidden="true">
<img className="ai-hero-mascot" src={mascotSrc} alt="" />
</div>
</div>
</section>
) : (
<div className="ai-thread">
{messages.map((message) => (
<AiMessageBubble key={message.id} message={message} onSuggestionSelect={onSuggestion} />
))}
</div>
)}
</div>
</main>
);
}
@@ -0,0 +1,104 @@
"use client";
import { useEffect, useMemo, type CSSProperties } from "react";
interface AiVoiceOverlayProps {
open: boolean;
listening: boolean;
speaking: boolean;
interimText: string;
error: string | null;
onStop: () => void;
}
const WAVE_BARS = 28;
export function AiVoiceOverlay({
open,
listening,
speaking,
interimText,
error,
onStop,
}: AiVoiceOverlayProps) {
const barHeights = useMemo(
() =>
Array.from({ length: WAVE_BARS }, (_, i) => ({
min: 6 + (i % 4) * 2,
max: 14 + ((i * 5) % 22),
delay: `${(i * 0.035).toFixed(3)}s`,
duration: `${(0.38 + (i % 7) * 0.06).toFixed(2)}s`,
})),
[],
);
useEffect(() => {
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onStop();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open, onStop]);
if (!open) return null;
const eqActive = listening && !error;
return (
<div className="ai-voice-overlay" role="dialog" aria-modal="true" aria-label="음성 입력">
<div className="ai-voice-sheet">
<div className="ai-voice-sheet-fade" aria-hidden="true" />
<div className="ai-voice-sheet-body">
<button
type="button"
className={`ai-voice-mic-btn${speaking ? " is-speaking" : ""}${listening ? " is-listening" : ""}`}
aria-label="음성 입력 종료"
onClick={onStop}
>
<span className="ai-voice-mic-ring ai-voice-mic-ring--1" aria-hidden="true" />
<span className="ai-voice-mic-ring ai-voice-mic-ring--2" aria-hidden="true" />
<span className="ai-voice-mic-core">
<svg viewBox="0 0 24 24" width="36" height="36" aria-hidden="true">
<path
fill="currentColor"
d="M12 14a3 3 0 0 0 3-3V5a3 3 0 1 0-6 0v6a3 3 0 0 0 3 3zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.92V21h2v-3.08A7 7 0 0 0 19 11h-2z"
/>
</svg>
</span>
</button>
<div
className={`ai-voice-waveform${eqActive ? " is-listening" : ""}${speaking ? " is-speaking" : ""}`}
aria-hidden="true"
>
{barHeights.map((bar, i) => (
<span
key={i}
className="ai-voice-wave-bar"
style={
{
"--bar-min": `${bar.min}px`,
"--bar-max": `${bar.max}px`,
animationDelay: bar.delay,
animationDuration: bar.duration,
} as CSSProperties
}
/>
))}
</div>
<p className={`ai-voice-status-pill${error ? " is-error" : ""}`}>
{error ?? "음성인식 중입니다"}
</p>
{interimText ? (
<p className="ai-voice-interim" aria-live="polite">
{interimText}
</p>
) : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,419 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import "@/styles/banner-mode.css";
import { ExBrandTitle } from "@/components/ExBrandTitle";
import { BannerMessageBubble } from "@/components/banner/BannerMessageBubble";
import { RelatedQuestionsBar } from "@/components/RelatedQuestionsBar";
import { useChatSession } from "@/hooks/useChatSession";
import { getLatestAssistantQuickReplies } from "@/lib/relatedQuestions";
import { BANNER_CHECKING_TEXT, BANNER_PLACEHOLDER, BANNER_SUGGESTIONS } from "@/lib/uiMode";
import {
isPartcptnMinwonQuery,
PARTCPTN_MINWON_ACTION_LABEL,
PARTCPTN_MINWON_ANSWER,
resolvePartcptnMinwonUrl,
} from "@/lib/partcptnIntents";
function useEmbedTheme(): string {
const [theme, setTheme] = useState("default");
useEffect(() => {
const params = new URLSearchParams(window.location.search);
setTheme(params.get("theme")?.trim() || "default");
}, []);
return theme;
}
function useEmbedMode(): boolean {
const [embed, setEmbed] = useState(false);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
setEmbed(params.get("embed") === "1" || params.get("embed") === "true");
}, []);
return embed;
}
function usePlaceholder(): string {
const [placeholder, setPlaceholder] = useState(BANNER_PLACEHOLDER);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const custom = params.get("placeholder")?.trim();
if (custom) setPlaceholder(custom);
}, []);
return placeholder;
}
function usePartcptnMinwonUrl(): string {
const [minwonUrl, setMinwonUrl] = useState(resolvePartcptnMinwonUrl(undefined));
useEffect(() => {
const params = new URLSearchParams(window.location.search);
setMinwonUrl(resolvePartcptnMinwonUrl(params.get("minwonUrl")));
}, []);
return minwonUrl;
}
function postEmbedHeight(root: HTMLElement | null, minHeight = 0) {
if (!root || window.parent === window) return;
const notify = () => {
const area = root.querySelector(".banner-search-area") as HTMLElement | null;
const dropdown = root.querySelector(".banner-dropdown") as HTMLElement | null;
const searchWrap = area?.querySelector(".banner-search-wrap") as HTMLElement | null;
const searchH = searchWrap ? Math.ceil(searchWrap.getBoundingClientRect().height) : 0;
const dropdownH = dropdown
? Math.ceil(Math.max(dropdown.offsetHeight, dropdown.scrollHeight))
: 0;
const flowH = area
? Math.ceil(Math.max(area.offsetHeight, area.scrollHeight))
: Math.ceil(Math.max(root.offsetHeight, root.scrollHeight));
const composed = searchH + (dropdownH > 0 ? dropdownH + 8 : 0);
const h = Math.max(flowH, composed, minHeight);
window.parent.postMessage({ type: "ex-chatbot-banner-resize", height: h + 8 }, "*");
};
requestAnimationFrame(() => requestAnimationFrame(notify));
}
function hasAssistantContent(messages: ReturnType<typeof useChatSession>["messages"]) {
const assistant = [...messages].reverse().find((m) => m.role === "assistant");
if (!assistant) return false;
if ((assistant.text?.length ?? 0) > 0) return true;
if ((assistant.listCards?.length ?? 0) > 0) return true;
return !assistant.streaming;
}
export function BannerChatApp() {
const embedMode = useEmbedMode();
const embedTheme = useEmbedTheme();
const isPartcptnTheme = embedTheme === "partcptn";
const placeholder = usePlaceholder();
const minwonUrl = usePartcptnMinwonUrl();
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const voiceBaseRef = useRef("");
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState("");
const [listening, setListening] = useState(false);
const { messages, busy, sendMessage, sendPresetReply, resetConversation } = useChatSession();
const speechSupported =
typeof window !== "undefined" &&
!!(window.SpeechRecognition || window.webkitSpeechRecognition);
useEffect(() => {
if (!speechSupported) return;
const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!Ctor) return;
const recognition = new Ctor();
recognition.lang = "ko-KR";
recognition.interimResults = true;
recognition.continuous = false;
recognition.onstart = () => setListening(true);
recognition.onend = () => setListening(false);
recognition.onerror = () => setListening(false);
recognition.onresult = (event: SpeechRecognitionEvent) => {
let transcript = "";
for (let i = event.resultIndex; i < event.results.length; i += 1) {
transcript += event.results[i][0].transcript;
}
setDraft(voiceBaseRef.current + transcript.trim());
};
recognitionRef.current = recognition;
return () => {
try {
recognition.stop();
} catch {
/* noop */
}
};
}, [speechSupported]);
const hasConversation = messages.length > 0;
const responseReady = hasConversation && hasAssistantContent(messages);
const relatedQuestions = getLatestAssistantQuickReplies(messages);
const showSuggestions = open && !busy && !hasConversation;
const showResults = hasConversation && (busy || responseReady);
useEffect(() => {
if (!embedMode || !isPartcptnTheme) return;
document.documentElement.classList.add("banner-embed-transparent");
document.documentElement.style.background = "transparent";
document.body.style.background = "transparent";
return () => {
document.documentElement.classList.remove("banner-embed-transparent");
document.documentElement.style.background = "";
document.body.style.background = "";
};
}, [embedMode, isPartcptnTheme]);
useEffect(() => {
postEmbedHeight(rootRef.current);
}, [showSuggestions, showResults, messages, busy, embedMode, responseReady]);
useEffect(() => {
if (!embedMode || !responseReady) return;
const t = window.setTimeout(() => postEmbedHeight(rootRef.current), 120);
return () => window.clearTimeout(t);
}, [embedMode, responseReady, messages]);
useEffect(() => {
const root = rootRef.current;
if (!root || window.parent === window) return;
const area = root.querySelector(".banner-search-area");
if (!area) return;
const ro = new ResizeObserver(() => postEmbedHeight(root));
ro.observe(area);
return () => ro.disconnect();
}, [embedMode]);
useEffect(() => {
if (!open && !showResults) return;
const onDocClick = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) {
if (!busy && !hasConversation) setOpen(false);
}
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && !busy) {
if (hasConversation) {
resetConversation();
}
setOpen(false);
}
};
document.addEventListener("mousedown", onDocClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("keydown", onKey);
};
}, [open, showResults, hasConversation, busy, resetConversation]);
const handleSend = useCallback(
async (text?: string) => {
const query = (text ?? draft).trim();
if (!query || busy) return;
setDraft("");
setOpen(false);
if (embedMode) {
postEmbedHeight(rootRef.current, 160);
}
if (isPartcptnTheme && isPartcptnMinwonQuery(query)) {
sendPresetReply(query, PARTCPTN_MINWON_ANSWER, {
label: PARTCPTN_MINWON_ACTION_LABEL,
href: minwonUrl,
});
if (embedMode) {
postEmbedHeight(rootRef.current);
}
return;
}
await sendMessage(query);
if (embedMode) {
postEmbedHeight(rootRef.current);
}
},
[busy, draft, embedMode, isPartcptnTheme, minwonUrl, sendMessage, sendPresetReply],
);
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
void handleSend();
};
const toggleVoice = () => {
const recognition = recognitionRef.current;
if (!recognition || busy) return;
if (listening) {
recognition.stop();
return;
}
voiceBaseRef.current = draft.trim() ? `${draft.trim()} ` : "";
try {
recognition.start();
} catch {
/* noop */
}
};
const canSend = draft.trim().length > 0 && !busy;
const inputValue = busy ? BANNER_CHECKING_TEXT : draft;
return (
<div
ref={rootRef}
className={`banner-root${embedMode ? " banner-root--embed" : ""}${isPartcptnTheme ? " banner-root--partcptn" : ""}`}
data-chatbot-app="banner-mode"
>
{!embedMode ? (
<div className="banner-demo-header">
<ExBrandTitle as="h1" />
<p>검색창에 질문을 입력하면 아래 드롭다운에 AI 답변이 표시됩니다.</p>
</div>
) : null}
<div className="banner-page-shell">
<div className="banner-search-area">
<form className="banner-search-wrap" onSubmit={onSubmit} role="search">
<textarea
ref={inputRef}
className={`banner-search-input${busy ? " banner-search-input--checking" : ""}`}
rows={1}
maxLength={500}
placeholder={busy ? "" : placeholder}
value={inputValue}
readOnly={busy}
aria-label="AI 검색"
aria-busy={busy}
aria-expanded={showSuggestions || showResults}
aria-controls="banner-dropdown-panel"
onFocus={() => {
if (busy) return;
setOpen(true);
if (embedMode && !hasConversation) {
postEmbedHeight(rootRef.current, 320);
}
}}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (busy) return;
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
}}
/>
<div className="banner-search-actions">
{!isPartcptnTheme ? (
<button
type="button"
className={`banner-icon-btn${listening ? " listening" : ""}`}
aria-label={listening ? "음성 입력 중지" : "음성 입력"}
disabled={busy || !speechSupported}
onClick={toggleVoice}
>
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path
fill="currentColor"
d="M12 14a3 3 0 0 0 3-3V5a3 3 0 1 0-6 0v6a3 3 0 0 0 3 3zm5-3a5 5 0 0 1-10 0H5a7 7 0 0 0 6 6.92V21h2v-3.08A7 7 0 0 0 19 11h-2z"
/>
</svg>
</button>
) : null}
<button
type="submit"
className="banner-icon-btn"
aria-label={isPartcptnTheme ? "검색" : "전송"}
disabled={!canSend && !isPartcptnTheme}
>
{isPartcptnTheme ? (
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path
fill="currentColor"
d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C8.01 14 6 11.99 6 9.5S8.01 5 10.5 5 15 7.01 15 9.5 12.99 14 10.5 14z"
/>
</svg>
) : (
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path fill="currentColor" d="M3.4 20.4 22 12 3.4 3.6 3 10.8l12.8 1.2L3 13.2z" />
</svg>
)}
</button>
</div>
</form>
{showSuggestions ? (
<div
id="banner-dropdown-panel"
className="banner-dropdown"
role="listbox"
aria-label="추천 질문"
>
<div className="banner-dropdown-head">
<span>추천 질문</span>
<button
type="button"
className="banner-dropdown-close"
aria-label="닫기"
onClick={() => {
setOpen(false);
if (embedMode) {
postEmbedHeight(rootRef.current);
}
}}
>
×
</button>
</div>
<div className="banner-dropdown-body">
<div className="banner-suggestions">
{BANNER_SUGGESTIONS.map((text) => (
<button
key={text}
type="button"
className="banner-suggestion-item"
onClick={() => void handleSend(text)}
>
{text}
</button>
))}
</div>
</div>
</div>
) : null}
{showResults ? (
<div
id="banner-dropdown-panel"
className="banner-dropdown"
role="region"
aria-label="검색 결과"
>
<div className="banner-dropdown-head">
<span>검색 결과</span>
<button
type="button"
className="banner-dropdown-close"
aria-label="닫기"
onClick={() => {
resetConversation();
setOpen(false);
setDraft("");
}}
>
×
</button>
</div>
<div className="banner-dropdown-body">
{busy && !responseReady ? (
<div className="banner-dropdown-loading">{BANNER_CHECKING_TEXT}</div>
) : (
messages.map((msg) => <BannerMessageBubble key={msg.id} message={msg} />)
)}
</div>
{relatedQuestions.length > 0 ? (
<RelatedQuestionsBar
variant="app"
items={relatedQuestions}
busy={busy}
onSelect={(text) => void handleSend(text)}
/>
) : null}
<p className="banner-footer-note">
AI 답변은 참고용이며, 정확한 정보는 한국도로공사 공식 안내를 확인해 주세요.
</p>
</div>
) : null}
</div>
</div>
</div>
);
}
declare global {
interface Window {
SpeechRecognition: typeof SpeechRecognition;
webkitSpeechRecognition: typeof SpeechRecognition;
}
}
@@ -0,0 +1,76 @@
"use client";
import { ActionButtonList } from "@/components/ActionButtonList";
import { ListCardStack } from "@/components/ListCardStack";
import { MarkdownBody } from "@/components/MarkdownBody";
import { hasFareListCards } from "@/lib/fareResultCard";
import type { ChatMessage } from "@/lib/types";
interface BannerMessageBubbleProps {
message: ChatMessage;
}
export function BannerMessageBubble({ message }: BannerMessageBubbleProps) {
if (message.role === "user") {
return (
<div className="banner-msg user">
<div className="banner-msg-bubble">{message.text}</div>
</div>
);
}
const loading = message.streaming && !message.text && !message.listCards?.length;
const showFareCard = !message.streaming && hasFareListCards(message.listCards);
const showText = Boolean(message.text) && !showFareCard;
return (
<div className="banner-msg assistant">
<div className="banner-msg-label">AI 답변</div>
{(loading || showText) && (
<div className="banner-msg-bubble">
{loading ? (
<span className="banner-loading">
{message.statusText || "답변을 준비하고 있어요"}
<span className="banner-loading-dots" aria-hidden="true">
<span />
<span />
<span />
</span>
</span>
) : showText ? (
<MarkdownBody text={message.text!} />
) : null}
</div>
)}
{!message.streaming && message.actionLink ? (
<div className="banner-action-link-wrap">
<a
href={message.actionLink.href}
className="banner-action-link"
target={message.actionLink.external ? "_blank" : undefined}
rel={message.actionLink.external ? "noopener noreferrer" : undefined}
onClick={(e) => {
const link = message.actionLink;
if (!link) return;
if (link.external) return;
e.preventDefault();
if (window.top && window.top !== window) {
window.top.location.assign(link.href);
} else {
window.location.assign(link.href);
}
}}
>
{message.actionLink.label}
</a>
</div>
) : null}
{!message.streaming ? (
<div className="banner-list-cards">
<ListCardStack cards={message.listCards} />
</div>
) : null}
{!message.streaming ? <ActionButtonList buttons={message.actionButtons} /> : null}
</div>
);
}
@@ -0,0 +1,174 @@
"use client";
import { FormEvent, useEffect, useRef } from "react";
import { ActionButtonList } from "@/components/ActionButtonList";
import { MarkdownBody } from "@/components/MarkdownBody";
import { ListCardStack } from "@/components/ListCardStack";
import { ChatCloseButton } from "@/components/ChatCloseButton";
import { ExBrandTitle } from "@/components/ExBrandTitle";
import { useEmbedShell } from "@/hooks/useEmbedShell";
import { hasFareListCards } from "@/lib/fareResultCard";
import { useVoiceConversation } from "@/hooks/useVoiceConversation";
import "@/styles/voicebot-mode.css";
function micHint(
phase: string,
connected: boolean,
manualPaused: boolean,
activating: boolean,
interimText: string,
): string {
if (activating) return "마이크 연결 중…";
if (!connected) return "탭하여 음성 입력 시작";
if (manualPaused) return "탭하여 음성 입력 재개";
if (phase === "processing") return "처리 중…";
if (phase === "speaking") return "답변 재생 중…";
if (interimText) return interimText;
return "말씀해 주세요";
}
export function VoicebotChatApp() {
const embedded = useEmbedShell();
const textRef = useRef<HTMLInputElement>(null);
const {
phase,
ttsEnabled,
messages,
userSpeaking,
interimText,
error,
voiceConnected,
activating,
speechSupported,
toggleVoiceInput,
sendText,
toggleTts,
} = useVoiceConversation();
const micZoneClass = [
"vb-mic-zone",
!voiceConnected || phase === "idle" ? "vb-mic-idle" : "",
voiceConnected && phase === "listening" && !userSpeaking ? "vb-mic-listening" : "",
userSpeaking || phase === "speaking" ? "vb-mic-speaking" : "",
]
.filter(Boolean)
.join(" ");
const onSubmit = (e: FormEvent) => {
e.preventDefault();
const text = textRef.current?.value.trim() ?? "";
if (!text) return;
sendText(text);
if (textRef.current) textRef.current.value = "";
};
const logRef = useRef<HTMLElement>(null);
useEffect(() => {
const el = logRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages, error]);
const voicePaused = voiceConnected && phase === "idle";
return (
<div className={`vb-app${embedded ? " vb-app--embed" : ""}`} data-chatbot-app="voicebot-mode">
<header className="vb-header">
<ExBrandTitle as="h1" variant="dark" className="vb-header-brand" />
<div className="vb-header-actions">
<span
className={`vb-status-dot${voiceConnected ? " is-on" : ""}`}
aria-hidden="true"
/>
{embedded ? <ChatCloseButton className="vb-btn-close" /> : null}
</div>
</header>
<main className="vb-log" aria-live="polite" ref={logRef}>
{messages.length === 0 ? (
<p className="vb-empty">
{activating
? "마이크 권한을 요청하는 중입니다…"
: voiceConnected
? "말씀해 주세요."
: "음성 입력을 시작하지 못했습니다. 마이크 버튼을 탭해 주세요."}
</p>
) : null}
{messages.map((msg) => {
const showFareCard = !msg.streaming && hasFareListCards(msg.listCards);
const showText = Boolean(msg.text) && !showFareCard;
return (
<div
key={msg.id}
className={`vb-msg vb-msg--${msg.role}${msg.streaming ? " is-streaming" : ""}`}
>
<div className="vb-bubble">
{msg.statusText ? <p className="vb-status">{msg.statusText}</p> : null}
{showText ? <MarkdownBody text={msg.text!} /> : null}
{msg.listCards?.length ? <ListCardStack cards={msg.listCards} /> : null}
{!msg.streaming ? <ActionButtonList buttons={msg.actionButtons} /> : null}
</div>
</div>
);
})}
{error ? <p className="vb-error">{error}</p> : null}
</main>
<div className="vb-bottom">
<div className={micZoneClass}>
<button
type="button"
className="vb-mic-btn"
aria-label={voiceConnected ? "음성 입력 토글" : "음성 입력 시작"}
disabled={!speechSupported || activating}
onClick={toggleVoiceInput}
>
<span className="vb-mic-ring vb-mic-ring--1" aria-hidden="true" />
<span className="vb-mic-ring vb-mic-ring--2" aria-hidden="true" />
<span className="vb-mic-ring vb-mic-ring--3" aria-hidden="true" />
<span className="vb-mic-core">
<svg viewBox="0 0 24 24" width="32" height="32" aria-hidden="true">
<path
fill="currentColor"
d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z"
/>
<path
fill="currentColor"
d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"
/>
</svg>
</span>
</button>
</div>
<footer className="vb-panel">
<p className="vb-hint">{micHint(phase, voiceConnected, voicePaused, activating, interimText)}</p>
<form className="vb-input-row" onSubmit={onSubmit}>
<button
type="button"
className={`vb-tts-toggle${ttsEnabled ? " is-on" : ""}`}
aria-pressed={ttsEnabled}
onClick={toggleTts}
>
{ttsEnabled ? "음성 ON" : "음성 OFF"}
</button>
<input
ref={textRef}
className="vb-text-input"
type="text"
placeholder="질문을 입력하세요..."
maxLength={500}
autoComplete="off"
/>
<button type="submit" className="vb-send-btn" aria-label="전송">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
<path fill="currentColor" d="M3.4 20.4 22 12 3.4 3.6 3 10.8l12.8 1.2L3 13.2z" />
</svg>
</button>
</form>
</footer>
</div>
</div>
);
}
@@ -0,0 +1,53 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { plainTextForSpeech } from "@/lib/voiceText";
function pickKoreanVoice(): SpeechSynthesisVoice | null {
if (typeof window === "undefined" || !window.speechSynthesis) return null;
return window.speechSynthesis.getVoices().find((v) => v.lang.startsWith("ko")) ?? null;
}
export function useBrowserTts() {
const utterRef = useRef<SpeechSynthesisUtterance | null>(null);
useEffect(() => {
if (typeof window === "undefined" || !window.speechSynthesis) return;
const onVoices = () => pickKoreanVoice();
window.speechSynthesis.addEventListener("voiceschanged", onVoices);
return () => window.speechSynthesis.removeEventListener("voiceschanged", onVoices);
}, []);
const stop = useCallback(() => {
window.speechSynthesis?.cancel();
utterRef.current = null;
}, []);
const speak = useCallback(
(text: string, enabled: boolean): Promise<void> => {
if (!enabled || !text.trim() || typeof window === "undefined" || !window.speechSynthesis) {
return Promise.resolve();
}
stop();
const spoken = plainTextForSpeech(text);
if (!spoken) return Promise.resolve();
return new Promise((resolve) => {
const utter = new SpeechSynthesisUtterance(spoken);
utter.lang = "ko-KR";
utter.rate = 1;
const ko = pickKoreanVoice();
if (ko) utter.voice = ko;
utter.onend = () => resolve();
utter.onerror = () => resolve();
utterRef.current = utter;
window.speechSynthesis.resume();
window.speechSynthesis.speak(utter);
});
},
[stop],
);
return { speak, stop, supported: typeof window !== "undefined" && !!window.speechSynthesis };
}
@@ -0,0 +1,237 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useChatStream } from "@/hooks/useChatStream";
import { loadApiCapabilities } from "@/lib/apiCapabilities";
import { apiUrl, LOADING_TEXT } from "@/lib/config";
import { formatTime, newMessageId } from "@/lib/format";
import { getBotId, startNewChatSession, trackSessionStart } from "@/lib/session";
import type { ChatMessage, FaqCategory } from "@/lib/types";
export function useChatSession() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [busy, setBusy] = useState(false);
const [faqOpen, setFaqOpen] = useState(false);
const [faqLoading, setFaqLoading] = useState(false);
const [faqCategories, setFaqCategories] = useState<FaqCategory[]>([]);
const [faqSupported, setFaqSupported] = useState(false);
const { send, cancel } = useChatStream();
useEffect(() => {
// 앱 진입 = 새 대화: 새 botId 발급(과거 이력 비연계) 후 세션 추적.
startNewChatSession();
trackSessionStart();
void loadApiCapabilities().then((caps) => setFaqSupported(caps.faqPopular));
return () => cancel();
}, [cancel]);
const loadFaq = useCallback(async () => {
if (!faqSupported) return;
setFaqLoading(true);
try {
const res = await fetch(apiUrl("/faq/popular?limit=10"), { credentials: "same-origin" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { categories?: FaqCategory[] };
setFaqCategories(data.categories ?? []);
} catch {
setFaqCategories([]);
} finally {
setFaqLoading(false);
}
}, [faqSupported]);
const toggleFaq = useCallback(() => {
if (!faqSupported) return;
setFaqOpen((open) => {
const next = !open;
if (next && faqCategories.length === 0) {
void loadFaq();
}
return next;
});
}, [faqCategories.length, faqSupported, loadFaq]);
const resetConversation = useCallback(() => {
cancel();
setBusy(false);
setMessages([]);
setFaqOpen(false);
// "새 대화" = 새 botId 발급 → 서버 이력·pending과 분리된 새 세션 시작.
startNewChatSession();
}, [cancel]);
const sendMessage = useCallback(
async (text: string) => {
const query = text.trim();
if (!query || busy) return;
setBusy(true);
setFaqOpen(false);
const userMsg: ChatMessage = {
id: newMessageId(),
role: "user",
text: query,
time: formatTime(),
};
const assistantId = newMessageId();
const assistantMsg: ChatMessage = {
id: assistantId,
role: "assistant",
text: "",
time: formatTime(),
streaming: true,
statusText: LOADING_TEXT,
};
setMessages((prev) => [...prev, userMsg, assistantMsg]);
try {
await send({
query,
botId: getBotId(),
onEvent: (event) => {
if (event.type === "status") {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, statusText: event.message || LOADING_TEXT, streaming: true }
: m,
),
);
return;
}
if (event.type === "token") {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, text: m.text + event.text, streaming: true, statusText: undefined }
: m,
),
);
return;
}
if (event.type === "error") {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? {
...m,
text: event.message,
streaming: false,
statusText: undefined,
}
: m,
),
);
return;
}
if (event.type === "done") {
const answer = (event.payload.answer ?? "").trim();
const listCards = event.payload.listCards;
const quickReplies = event.payload.quickReplies;
const actionButtons = event.payload.actionButtons;
const hasCards = Array.isArray(listCards) && listCards.length > 0;
const hasQuickReplies = Array.isArray(quickReplies) && quickReplies.length > 0;
const hasActionButtons = Array.isArray(actionButtons) && actionButtons.length > 0;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? {
...m,
text:
answer ||
(hasCards ? "" : "죄송합니다. 답변을 생성하지 못했습니다."),
listCards: hasCards ? listCards : undefined,
quickReplies: hasQuickReplies ? quickReplies : undefined,
actionButtons: hasActionButtons ? actionButtons : undefined,
meta: {
routeType: event.payload.routeType,
intentType: event.payload.intentType,
uiType: event.payload.uiType,
numReferences: event.payload.num_references,
references: event.payload.references,
params: event.payload.params,
domainData: event.payload.domainData,
rawData: event.payload.rawData,
},
streaming: false,
statusText: undefined,
time: formatTime(),
}
: m,
),
);
}
},
});
} catch (err) {
const aborted = err instanceof DOMException && err.name === "AbortError";
if (!aborted) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? {
...m,
text: "네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.",
streaming: false,
statusText: undefined,
}
: m,
),
);
}
} finally {
setBusy(false);
}
},
[busy, send],
);
const sendPresetReply = useCallback(
(
query: string,
answer: string,
actionLink?: { label: string; href: string; external?: boolean },
) => {
const text = query.trim();
if (!text || busy) return;
setFaqOpen(false);
const userMsg: ChatMessage = {
id: newMessageId(),
role: "user",
text,
time: formatTime(),
};
const assistantMsg: ChatMessage = {
id: newMessageId(),
role: "assistant",
text: answer,
time: formatTime(),
actionLink,
};
setMessages((prev) => [...prev, userMsg, assistantMsg]);
},
[busy],
);
return {
messages,
busy,
faqOpen,
faqLoading,
faqCategories,
faqSupported,
sendMessage,
sendPresetReply,
toggleFaq,
resetConversation,
setFaqOpen,
loadFaq,
};
}
@@ -0,0 +1,130 @@
"use client";
import { useCallback, useRef } from "react";
import { loadApiCapabilities } from "@/lib/apiCapabilities";
import { apiUrl, ASK_TIMEOUT_MS, LOADING_TEXT } from "@/lib/config";
import { parseSseChunk, type StreamDonePayload, type StreamEvent } from "@/lib/types";
interface StreamOptions {
query: string;
botId: string;
onEvent: (event: StreamEvent) => void;
}
async function sendJsonAsk(
query: string,
botId: string,
onEvent: (event: StreamEvent) => void,
signal: AbortSignal,
): Promise<void> {
onEvent({ type: "status", phase: "llm", message: LOADING_TEXT });
const res = await fetch(apiUrl("/ask"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, botId }),
credentials: "same-origin",
signal,
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const payload = (await res.json()) as StreamDonePayload;
onEvent({ type: "done", payload });
}
async function sendSseAsk(
query: string,
botId: string,
onEvent: (event: StreamEvent) => void,
signal: AbortSignal,
): Promise<void> {
const res = await fetch(apiUrl("/ask/stream"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({ query, botId }),
credentials: "same-origin",
signal,
});
if (res.status === 404 || res.status === 405) {
throw new Error("stream_unavailable");
}
if (!res.ok || !res.body) {
throw new Error(`HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parsed = parseSseChunk(buffer);
buffer = parsed.rest;
for (const event of parsed.events) {
onEvent(event);
}
}
if (buffer.trim()) {
const parsed = parseSseChunk(`${buffer}\n\n`);
for (const event of parsed.events) {
onEvent(event);
}
}
}
export function useChatStream() {
const abortRef = useRef<AbortController | null>(null);
const cancel = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
}, []);
const send = useCallback(async ({ query, botId, onEvent }: StreamOptions) => {
cancel();
const controller = new AbortController();
abortRef.current = controller;
const timer = window.setTimeout(() => controller.abort(), ASK_TIMEOUT_MS);
try {
const caps = await loadApiCapabilities();
if (caps.askStream) {
try {
await sendSseAsk(query, botId, onEvent, controller.signal);
return;
} catch (err) {
if (!(err instanceof Error && err.message === "stream_unavailable") || !caps.askJson) {
throw err;
}
}
}
if (caps.askJson) {
await sendJsonAsk(query, botId, onEvent, controller.signal);
return;
}
onEvent({ type: "error", message: "질의 API를 사용할 수 없습니다." });
} finally {
window.clearTimeout(timer);
if (abortRef.current === controller) {
abortRef.current = null;
}
}
}, [cancel]);
return { send, cancel };
}
@@ -0,0 +1,14 @@
"use client";
import { useEffect, useState } from "react";
import { isEmbedShell } from "@/lib/embedShell";
export function useEmbedShell(): boolean {
const [embedded, setEmbedded] = useState(false);
useEffect(() => {
setEmbedded(isEmbedShell());
}, []);
return embedded;
}
@@ -0,0 +1,247 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
export function isSpeechRecognitionSupported(): boolean {
if (typeof window === "undefined") return false;
return !!(window.SpeechRecognition || window.webkitSpeechRecognition);
}
interface UseSpeechRecognitionOptions {
onComplete: (text: string) => void;
}
export function useSpeechRecognition({ onComplete }: UseSpeechRecognitionOptions) {
const [active, setActive] = useState(false);
const [interimText, setInterimText] = useState("");
const [speaking, setSpeaking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [supported, setSupported] = useState(false);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const finalPartsRef = useRef<string[]>([]);
const interimRef = useRef("");
const cancelledRef = useRef(false);
const baseTextRef = useRef("");
const sessionActiveRef = useRef(false);
useEffect(() => {
setSupported(isSpeechRecognitionSupported());
}, []);
const cleanupRecognition = useCallback(() => {
const recognition = recognitionRef.current;
recognitionRef.current = null;
if (!recognition) return;
recognition.onstart = null;
recognition.onend = null;
recognition.onresult = null;
recognition.onerror = null;
try {
recognition.abort();
} catch {
try {
recognition.stop();
} catch {
/* noop */
}
}
}, []);
const resetUi = useCallback(() => {
setActive(false);
setInterimText("");
setSpeaking(false);
setError(null);
finalPartsRef.current = [];
interimRef.current = "";
sessionActiveRef.current = false;
}, []);
const finish = useCallback(
(cancelled: boolean) => {
cleanupRecognition();
const finals = finalPartsRef.current.join(" ").trim();
const interim = interimRef.current.trim();
const text = finals && interim ? `${finals} ${interim}`.trim() : finals || interim;
resetUi();
if (!cancelled && text) {
onComplete(`${baseTextRef.current}${text}`.trim());
}
},
[cleanupRecognition, onComplete, resetUi],
);
const bindRecognition = useCallback(
(recognition: SpeechRecognition) => {
recognition.onstart = () => {
setError(null);
setActive(true);
};
recognition.onresult = (event: SpeechRecognitionEvent) => {
let interim = "";
for (let i = event.resultIndex; i < event.results.length; i += 1) {
const part = event.results[i][0]?.transcript ?? "";
if (event.results[i].isFinal) {
finalPartsRef.current.push(part.trim());
interimRef.current = "";
setInterimText("");
setSpeaking(false);
} else {
interim += part;
}
}
if (interim.trim()) {
interimRef.current = interim.trim();
setInterimText(interim.trim());
setSpeaking(true);
}
};
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
if (event.error === "aborted") return;
if (event.error === "not-allowed") {
setError("마이크 권한을 허용해 주세요.");
setActive(true);
sessionActiveRef.current = false;
cleanupRecognition();
return;
}
if (event.error === "service-not-allowed") {
setError("이 환경에서는 음성 입력을 사용할 수 없습니다. HTTPS 또는 마이크 허용을 확인해 주세요.");
setActive(true);
sessionActiveRef.current = false;
cleanupRecognition();
return;
}
if (event.error !== "no-speech") {
setError("음성 인식 중 오류가 발생했습니다. 다시 시도해 주세요.");
}
};
recognition.onend = () => {
recognitionRef.current = null;
if (cancelledRef.current) {
resetUi();
return;
}
const finals = finalPartsRef.current.join(" ").trim();
const interim = interimRef.current.trim();
const text = finals && interim ? `${finals} ${interim}`.trim() : finals || interim;
if (text) {
finish(false);
return;
}
if (sessionActiveRef.current) {
window.setTimeout(() => {
if (!sessionActiveRef.current || cancelledRef.current || recognitionRef.current) return;
try {
const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!Ctor) return;
const next = new Ctor();
next.lang = "ko-KR";
next.continuous = false;
next.interimResults = true;
next.maxAlternatives = 1;
recognitionRef.current = next;
bindRecognition(next);
next.start();
} catch {
setError("음성 입력을 시작하지 못했습니다.");
}
}, 280);
}
};
},
[cleanupRecognition, finish, resetUi],
);
const start = useCallback(
(baseText = "") => {
if (recognitionRef.current) return false;
cancelledRef.current = false;
baseTextRef.current = baseText.trim() ? `${baseText.trim()} ` : "";
finalPartsRef.current = [];
interimRef.current = "";
setInterimText("");
setSpeaking(false);
setError(null);
setActive(true);
if (!isSpeechRecognitionSupported()) {
setError("이 브라우저는 음성 입력을 지원하지 않습니다. Chrome 또는 Safari를 사용해 주세요.");
sessionActiveRef.current = false;
return false;
}
if (typeof window !== "undefined" && !window.isSecureContext) {
setError("음성 입력은 HTTPS 환경에서만 사용할 수 있습니다.");
sessionActiveRef.current = false;
return false;
}
const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!Ctor) {
setError("음성 입력 API를 사용할 수 없습니다.");
sessionActiveRef.current = false;
return false;
}
sessionActiveRef.current = true;
const recognition = new Ctor();
recognition.lang = "ko-KR";
recognition.continuous = false;
recognition.interimResults = true;
recognition.maxAlternatives = 1;
bindRecognition(recognition);
recognitionRef.current = recognition;
try {
recognition.start();
return true;
} catch {
sessionActiveRef.current = false;
setError("음성 입력을 시작하지 못했습니다. 잠시 후 다시 시도해 주세요.");
cleanupRecognition();
return false;
}
},
[bindRecognition, cleanupRecognition],
);
const cancel = useCallback(() => {
cancelledRef.current = true;
sessionActiveRef.current = false;
finish(true);
}, [finish]);
useEffect(
() => () => {
cancelledRef.current = true;
sessionActiveRef.current = false;
cleanupRecognition();
},
[cleanupRecognition],
);
return {
active,
interimText,
speaking,
error,
start,
cancel,
supported,
};
}
@@ -0,0 +1,527 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useBrowserTts } from "@/hooks/useBrowserTts";
import { useChatStream } from "@/hooks/useChatStream";
import { isSpeechRecognitionSupported } from "@/hooks/useSpeechRecognition";
import { LOADING_TEXT } from "@/lib/config";
import { formatTime, newMessageId } from "@/lib/format";
import {
ensureMicStream,
getMicrophoneErrorMessage,
isMobileDevice,
releaseMicStream,
} from "@/lib/microphone";
import { getVoiceBotId, startNewVoiceSession, trackVoiceSessionStart } from "@/lib/session";
import type { ChatMessage } from "@/lib/types";
export type VoicePhase = "idle" | "listening" | "processing" | "speaking";
const VOICE_AUTOSTART_MESSAGE = "ex-voicebot-autostart";
export function useVoiceConversation() {
const [phase, setPhase] = useState<VoicePhase>("idle");
const [ttsEnabled, setTtsEnabled] = useState(true);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [userSpeaking, setUserSpeaking] = useState(false);
const [interimText, setInterimText] = useState("");
const [error, setError] = useState<string | null>(null);
const [voiceConnected, setVoiceConnected] = useState(false);
const [activating, setActivating] = useState(false);
const [speechSupported, setSpeechSupported] = useState(false);
const voiceActiveRef = useRef(false);
const manualOffRef = useRef(false);
const recognitionRef = useRef<SpeechRecognition | null>(null);
const restartTimerRef = useRef<number | null>(null);
const processingRef = useRef(false);
const speakingRef = useRef(false);
const ttsEnabledRef = useRef(true);
const sttRestartAttemptsRef = useRef(0);
const activatingRef = useRef(false);
const autoStartedRef = useRef(false);
const activateVoiceRef = useRef<() => Promise<void>>(async () => {});
const { send, cancel } = useChatStream();
const { speak, stop: stopTts } = useBrowserTts();
useEffect(() => {
ttsEnabledRef.current = ttsEnabled;
}, [ttsEnabled]);
useEffect(() => {
setSpeechSupported(isSpeechRecognitionSupported());
}, []);
const clearRestartTimer = useCallback(() => {
if (restartTimerRef.current !== null) {
window.clearTimeout(restartTimerRef.current);
restartTimerRef.current = null;
}
}, []);
const stopStt = useCallback(() => {
clearRestartTimer();
sttRestartAttemptsRef.current = 0;
const rec = recognitionRef.current;
recognitionRef.current = null;
if (!rec) return;
rec.onend = null;
rec.onresult = null;
rec.onerror = null;
rec.onspeechstart = null;
rec.onspeechend = null;
try {
rec.abort();
} catch {
try {
rec.stop();
} catch {
/* noop */
}
}
setUserSpeaking(false);
setInterimText("");
}, [clearRestartTimer]);
const handleUserTextRef = useRef<(query: string) => Promise<void>>(async () => {});
const startSttRef = useRef<(warmMic?: boolean) => Promise<void>>(async () => {});
const bindRecognition = useCallback(
(recognition: SpeechRecognition, mobile: boolean) => {
recognition.onresult = (event: SpeechRecognitionEvent) => {
if (!voiceActiveRef.current || processingRef.current || speakingRef.current) return;
let interim = "";
let finalText = "";
for (let i = event.resultIndex; i < event.results.length; i += 1) {
const part = event.results[i][0]?.transcript ?? "";
if (event.results[i].isFinal) finalText += part;
else interim += part;
}
const hasInterim = !!interim.trim();
const hasFinal = !!finalText.trim();
setInterimText(hasInterim ? interim.trim() : "");
setUserSpeaking(hasInterim || hasFinal);
finalText = finalText.trim();
if (finalText) {
setInterimText("");
void handleUserTextRef.current(finalText);
}
};
recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
if (event.error === "aborted") return;
if (event.error === "not-allowed") {
setError("마이크 권한을 허용해 주세요.");
voiceActiveRef.current = false;
manualOffRef.current = true;
setVoiceConnected(false);
setPhase("idle");
return;
}
if (event.error === "service-not-allowed") {
setError("이 환경에서는 음성 입력을 사용할 수 없습니다. HTTPS 또는 마이크 허용을 확인해 주세요.");
voiceActiveRef.current = false;
manualOffRef.current = true;
setVoiceConnected(false);
setPhase("idle");
return;
}
if (event.error === "no-speech") return;
if (voiceActiveRef.current && !manualOffRef.current && !processingRef.current && !speakingRef.current) {
clearRestartTimer();
restartTimerRef.current = window.setTimeout(() => {
void startSttRef.current(true);
}, mobile ? 400 : 300);
}
};
recognition.onend = () => {
recognitionRef.current = null;
setUserSpeaking(false);
setInterimText("");
if (
voiceActiveRef.current &&
!manualOffRef.current &&
!processingRef.current &&
!speakingRef.current
) {
clearRestartTimer();
const delay = mobile ? 350 : 150;
restartTimerRef.current = window.setTimeout(() => {
void startSttRef.current(true);
}, delay);
}
};
recognition.onspeechstart = () => {
if (voiceActiveRef.current && !processingRef.current && !speakingRef.current) {
setUserSpeaking(true);
}
};
recognition.onspeechend = () => {
if (!recognition.interimResults) {
setUserSpeaking(false);
}
};
},
[clearRestartTimer],
);
const startStt = useCallback(
async (warmMic = false) => {
if (
!isSpeechRecognitionSupported() ||
!voiceActiveRef.current ||
manualOffRef.current ||
processingRef.current ||
speakingRef.current ||
recognitionRef.current
) {
return;
}
if (typeof window !== "undefined" && !window.isSecureContext) {
setError(getMicrophoneErrorMessage());
voiceActiveRef.current = false;
manualOffRef.current = true;
setVoiceConnected(false);
setPhase("idle");
return;
}
if (warmMic) {
try {
await ensureMicStream();
} catch {
/* STT는 getUserMedia 없이도 동작 가능 (AI 챗봇과 동일) */
}
}
const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!Ctor) return;
const mobile = isMobileDevice();
const recognition = new Ctor();
recognition.lang = "ko-KR";
recognition.continuous = !mobile;
recognition.interimResults = mobile;
recognition.maxAlternatives = 1;
bindRecognition(recognition, mobile);
recognitionRef.current = recognition;
try {
recognition.start();
sttRestartAttemptsRef.current = 0;
setPhase("listening");
setError(null);
} catch {
recognitionRef.current = null;
sttRestartAttemptsRef.current += 1;
if (sttRestartAttemptsRef.current < 4) {
clearRestartTimer();
restartTimerRef.current = window.setTimeout(() => {
void startSttRef.current(true);
}, 500);
} else {
setError("음성 입력을 시작하지 못했습니다. 잠시 후 다시 시도해 주세요.");
voiceActiveRef.current = false;
manualOffRef.current = true;
setVoiceConnected(false);
setPhase("idle");
}
}
},
[bindRecognition, clearRestartTimer],
);
useEffect(() => {
startSttRef.current = startStt;
}, [startStt]);
const resumeListening = useCallback(() => {
if (!voiceActiveRef.current || manualOffRef.current) {
setPhase("idle");
return;
}
processingRef.current = false;
speakingRef.current = false;
setPhase("listening");
void startStt(true);
}, [startStt]);
const handleUserText = useCallback(
async (query: string) => {
const text = query.trim();
if (!text || processingRef.current) return;
processingRef.current = true;
stopStt();
stopTts();
setPhase("processing");
setError(null);
const userMsg: ChatMessage = {
id: newMessageId(),
role: "user",
text,
time: formatTime(),
};
const assistantId = newMessageId();
const assistantMsg: ChatMessage = {
id: assistantId,
role: "assistant",
text: "",
time: formatTime(),
streaming: true,
statusText: LOADING_TEXT,
};
setMessages((prev) => [...prev, userMsg, assistantMsg]);
let answerText = "";
try {
await send({
query: text,
botId: getVoiceBotId(),
onEvent: (event) => {
if (event.type === "status") {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, statusText: event.message || LOADING_TEXT, streaming: true }
: m,
),
);
return;
}
if (event.type === "token") {
answerText += event.text;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, text: m.text + event.text, streaming: true, statusText: undefined }
: m,
),
);
return;
}
if (event.type === "error") {
answerText = event.message;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, text: event.message, streaming: false, statusText: undefined }
: m,
),
);
return;
}
if (event.type === "done") {
answerText = (event.payload.answer ?? answerText).trim();
const listCards = event.payload.listCards;
const actionButtons = event.payload.actionButtons;
const hasCards = Array.isArray(listCards) && listCards.length > 0;
const hasActionButtons = Array.isArray(actionButtons) && actionButtons.length > 0;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? {
...m,
text:
answerText ||
(hasCards ? "관련 정보를 확인해 주세요." : "답변을 생성하지 못했습니다."),
listCards: hasCards ? listCards : undefined,
actionButtons: hasActionButtons ? actionButtons : undefined,
meta: {
routeType: event.payload.routeType,
intentType: event.payload.intentType,
uiType: event.payload.uiType,
numReferences: event.payload.num_references,
references: event.payload.references,
params: event.payload.params,
domainData: event.payload.domainData,
rawData: event.payload.rawData,
},
streaming: false,
statusText: undefined,
time: formatTime(),
}
: m,
),
);
}
},
});
} catch (err) {
const aborted = err instanceof DOMException && err.name === "AbortError";
if (!aborted) {
answerText = "네트워크 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.";
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, text: answerText, streaming: false, statusText: undefined }
: m,
),
);
}
}
processingRef.current = false;
if (answerText && ttsEnabledRef.current) {
speakingRef.current = true;
setPhase("speaking");
await speak(answerText, true);
speakingRef.current = false;
}
resumeListening();
},
[resumeListening, send, speak, stopStt, stopTts],
);
useEffect(() => {
handleUserTextRef.current = handleUserText;
}, [handleUserText]);
const activateVoice = useCallback(async () => {
if (activatingRef.current) return;
if (!isSpeechRecognitionSupported()) {
setError("이 브라우저는 음성 입력을 지원하지 않습니다.");
return;
}
if (typeof window !== "undefined" && !window.isSecureContext) {
setError(getMicrophoneErrorMessage());
return;
}
activatingRef.current = true;
setActivating(true);
manualOffRef.current = false;
voiceActiveRef.current = true;
setVoiceConnected(true);
setError(null);
try {
await startStt(false);
} finally {
activatingRef.current = false;
setActivating(false);
}
}, [startStt]);
useEffect(() => {
activateVoiceRef.current = activateVoice;
}, [activateVoice]);
const requestAutoStart = useCallback(() => {
if (autoStartedRef.current || manualOffRef.current) return;
autoStartedRef.current = true;
void activateVoiceRef.current();
}, []);
const toggleVoiceInput = useCallback(() => {
if (activatingRef.current) return;
const shouldStart =
!voiceActiveRef.current ||
manualOffRef.current ||
(voiceConnected &&
phase === "idle" &&
!processingRef.current &&
!speakingRef.current);
if (shouldStart) {
manualOffRef.current = false;
void activateVoice();
return;
}
manualOffRef.current = true;
stopStt();
stopTts();
cancel();
processingRef.current = false;
speakingRef.current = false;
setPhase("idle");
setUserSpeaking(false);
setInterimText("");
}, [activateVoice, cancel, phase, stopStt, stopTts, voiceConnected]);
const sendText = useCallback(
(text: string) => {
manualOffRef.current = false;
if (!voiceActiveRef.current) {
voiceActiveRef.current = true;
setVoiceConnected(true);
}
void handleUserText(text);
},
[handleUserText],
);
const toggleTts = useCallback(() => {
setTtsEnabled((on) => {
if (on) stopTts();
return !on;
});
}, [stopTts]);
useEffect(() => {
// 앱 진입 = 새 음성 대화: 새 botId 발급(과거 이력 비연계) 후 세션 추적.
startNewVoiceSession();
trackVoiceSessionStart();
const onMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type === VOICE_AUTOSTART_MESSAGE) {
requestAutoStart();
}
};
window.addEventListener("message", onMessage);
requestAutoStart();
return () => {
window.removeEventListener("message", onMessage);
voiceActiveRef.current = false;
clearRestartTimer();
stopStt();
stopTts();
cancel();
releaseMicStream();
};
}, [cancel, clearRestartTimer, requestAutoStart, stopStt, stopTts]);
return {
phase,
ttsEnabled,
messages,
userSpeaking,
interimText,
error,
voiceConnected,
activating,
speechSupported,
toggleVoiceInput,
sendText,
toggleTts,
activateVoice,
};
}
@@ -0,0 +1,54 @@
import { apiUrl } from "@/lib/config";
export interface ApiCapabilities {
askStream: boolean;
askJson: boolean;
faqPopular: boolean;
sessionStart: boolean;
}
const DEFAULT_CAPABILITIES: ApiCapabilities = {
askStream: true,
askJson: true,
faqPopular: true,
sessionStart: true,
};
let cached: ApiCapabilities | null = null;
let loading: Promise<ApiCapabilities> | null = null;
function hasPath(paths: Set<string>, ...candidates: string[]): boolean {
return candidates.some((candidate) => paths.has(candidate));
}
export function loadApiCapabilities(): Promise<ApiCapabilities> {
if (cached) return Promise.resolve(cached);
if (loading) return loading;
loading = (async () => {
try {
const res = await fetch(apiUrl("/openapi.json"), { credentials: "same-origin" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const spec = (await res.json()) as { paths?: Record<string, unknown> };
const paths = new Set(Object.keys(spec.paths ?? {}));
cached = {
askStream: hasPath(paths, "/ask/stream"),
askJson: hasPath(paths, "/ask"),
// openapi에 FAQ path가 누락된 배포(exdev 등)에서도 /faq/popular 는 동작함
faqPopular:
hasPath(paths, "/faq/popular") ||
hasPath(paths, "/ask/stream") ||
hasPath(paths, "/ask"),
sessionStart: hasPath(paths, "/session/start"),
};
return cached;
} catch {
cached = DEFAULT_CAPABILITIES;
return cached;
} finally {
loading = null;
}
})();
return loading;
}
@@ -0,0 +1,3 @@
export const CHATBOT_BRAND_SUFFIX = "AI Chatbot 2.0";
export const CHATBOT_BRAND_LABEL = `ex ${CHATBOT_BRAND_SUFFIX}`;
@@ -0,0 +1,36 @@
import { CHATBOT_BRAND_LABEL } from "@/lib/brand";
export const BOT_NAME = CHATBOT_BRAND_LABEL;
export const WELCOME_TEXT =
"안녕하세요! 고속도로 이용 관련 궁금한 점을 편하게 물어보세요.\n" +
"통행료, 하이패스, 휴게소, 출퇴근 할인 등 무엇이든 답변해 드립니다.";
export const LOADING_TEXT = "답변준비중…";
export const ASK_TIMEOUT_MS = 120_000;
export function apiRoot(): string {
const root = process.env.NEXT_PUBLIC_CHATBOT_API_ROOT || "/chatbot";
const trimmed = root.trim() || "/chatbot";
return trimmed.startsWith("/") ? trimmed.replace(/\/$/, "") : `/${trimmed}`.replace(/\/$/, "");
}
/** 원격 API 직접 호출 시에만 설정 (예: https://chatbot.exdev.co.kr). 미설정 시 same-origin /chatbot 프록시 사용. */
export function apiOrigin(): string {
const base = (process.env.NEXT_PUBLIC_CHATBOT_API_BASE || "").trim().replace(/\/$/, "");
if (!base) {
return typeof window === "undefined" ? "" : window.location.origin;
}
return base.startsWith("http") ? base : `https://${base}`;
}
export function apiUrl(path: string): string {
const root = apiRoot();
const suffix = path.startsWith("/") ? path : `/${path}`;
const origin = apiOrigin();
if (!origin) {
return `${root}${suffix}`;
}
return `${origin}${root}${suffix}`;
}
@@ -0,0 +1,36 @@
export const CHAT_CLOSE_MESSAGE = "ex-chatbot-close";
export function isEmbedShell(): boolean {
if (typeof window === "undefined") return false;
const params = new URLSearchParams(window.location.search);
if (params.get("embed") === "1" || params.get("embed") === "true") {
return true;
}
try {
return window.parent !== window;
} catch {
return false;
}
}
export function requestChatClose(): void {
if (typeof window === "undefined") return;
try {
if (window.parent && window.parent !== window) {
window.parent.postMessage({ type: CHAT_CLOSE_MESSAGE }, "*");
return;
}
} catch {
/* noop */
}
if (window.history.length > 1) {
window.history.back();
return;
}
window.close();
}
@@ -0,0 +1,21 @@
import { AI_SUGGESTIONS } from "@/lib/uiMode";
import type { FaqCategory } from "@/lib/types";
export function flattenFaqQuestions(categories: FaqCategory[], limit = 6): string[] {
const items: string[] = [];
for (const cat of categories) {
for (const item of cat.items) {
if (item.question && !items.includes(item.question)) {
items.push(item.question);
if (items.length >= limit) return items;
}
}
}
return items;
}
export function heroFaqQuestions(categories: FaqCategory[], limit = 6): string[] {
const fromApi = flattenFaqQuestions(categories, limit);
if (fromApi.length > 0) return fromApi;
return [...AI_SUGGESTIONS].slice(0, limit);
}
@@ -0,0 +1,211 @@
import type { FareBreakdown, FareBreakdownVehicle, WebListCard } from "@/lib/listCards";
export interface ParsedFareVehicle {
typeLabel: string;
amount: number;
kec: number;
privateAmount: number;
description?: string;
imageUrl?: string;
compact?: boolean;
}
export interface ParsedFareResult {
fromIc: string;
toIc: string;
routeLabel: string;
introText: string;
primaryFare: number;
kecFare: number;
privateFare: number;
compactFare?: number;
vehicles: ParsedFareVehicle[];
}
const FARE_ITEM_URL = /\/fare\//i;
const FARE_TITLE = /통행요금/i;
const FARE_TYPE_LINE = /(\d+)종\s*:\s*([\d,]+)\s*원/;
const COMPACT_LINE = /1종\s*\(\s*경차\s*\)\s*:\s*([\d,]+)\s*원/;
function parseAmount(raw: string | number): number {
if (typeof raw === "number") return raw;
return Number.parseInt(String(raw).replace(/,/g, ""), 10);
}
function parseRouteFromTitle(title?: string): { fromIc: string; toIc: string } | null {
if (!title) return null;
const match = title.match(/^(.+?)\s*[-–—]\s*(.+?)(?:\s*통행요금.*)?$/);
if (!match) return null;
const fromIc = match[1].trim();
const toIc = match[2].replace(/통행요금.*/, "").trim();
if (!fromIc || !toIc) return null;
return { fromIc, toIc };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function parseBreakdownVehicle(raw: unknown, imageUrl?: string): ParsedFareVehicle | null {
if (!isRecord(raw)) return null;
const typeLabel = String(raw.typeLabel ?? "");
const total = parseAmount(raw.total as string | number);
if (!typeLabel || Number.isNaN(total)) return null;
return {
typeLabel,
amount: total,
kec: parseAmount(raw.kec as string | number),
privateAmount: parseAmount(raw.private as string | number),
description: typeof raw.description === "string" ? raw.description : undefined,
imageUrl,
compact: Boolean(raw.compact),
};
}
export function parseFareBreakdown(raw: unknown): ParsedFareResult | null {
if (!isRecord(raw)) return null;
const fromIc = String(raw.fromIc ?? "");
const toIc = String(raw.toIc ?? "");
const totalFare = parseAmount(raw.totalFare as string | number);
const kecFare = parseAmount(raw.kecFare as string | number);
const privateFare = parseAmount(raw.privateFare as string | number);
if (!fromIc || !toIc || Number.isNaN(totalFare)) return null;
const vehiclesRaw = Array.isArray(raw.vehicles) ? raw.vehicles : [];
const vehicles = vehiclesRaw
.map((vehicle) => parseBreakdownVehicle(vehicle))
.filter((vehicle): vehicle is ParsedFareVehicle => vehicle != null);
const compactTotal = raw.compactTotalFare != null
? parseAmount(raw.compactTotalFare as string | number)
: undefined;
return {
fromIc,
toIc,
routeLabel: String(raw.routeLabel ?? `${fromIc} → ${toIc}`),
introText: String(
raw.introText ??
"요청하신 경로의 통행요금입니다. 도로공사 구간 요금·민자도로 구간 요금 조회 결과입니다.",
),
primaryFare: totalFare,
kecFare: Number.isNaN(kecFare) ? totalFare : kecFare,
privateFare: Number.isNaN(privateFare) ? 0 : privateFare,
compactFare: compactTotal,
vehicles: vehicles.length
? vehicles
: [
{
typeLabel: "1종",
amount: totalFare,
kec: Number.isNaN(kecFare) ? totalFare : kecFare,
privateAmount: Number.isNaN(privateFare) ? 0 : privateFare,
},
],
};
}
export function isFareListCard(card: WebListCard): boolean {
if (card.fareBreakdown) return true;
if (!card.items?.length) return false;
if (FARE_TITLE.test(card.title ?? "")) return true;
return card.items.some(
(item) => FARE_ITEM_URL.test(item.imageUrl ?? "") || FARE_TYPE_LINE.test(item.title),
);
}
export function hasFareListCards(cards?: WebListCard[]): boolean {
return Boolean(cards?.some(isFareListCard));
}
export function parseFareListCard(card: WebListCard): ParsedFareResult | null {
if (card.fareBreakdown) {
const parsed = parseFareBreakdown(card.fareBreakdown);
if (!parsed || !card.items?.length) {
return parsed;
}
const imageUrls = card.items
.map((item) => item.imageUrl)
.filter((url): url is string => Boolean(url));
parsed.vehicles = parsed.vehicles.map((vehicle, index) => ({
...vehicle,
imageUrl: vehicle.imageUrl ?? imageUrls[index],
}));
return parsed;
}
if (!card.items?.length) return null;
const route = parseRouteFromTitle(card.title);
const vehicles: ParsedFareVehicle[] = [];
let compactFare: number | undefined;
for (const item of card.items) {
const lines = item.title.split("\n");
const mainLine = lines[0] ?? item.title;
const typeMatch = mainLine.match(FARE_TYPE_LINE);
if (!typeMatch) continue;
const typeNum = typeMatch[1];
const amount = parseAmount(typeMatch[2]);
vehicles.push({
typeLabel: `${typeNum}종`,
amount,
kec: amount,
privateAmount: 0,
description: item.description,
imageUrl: item.imageUrl,
});
for (const line of lines.slice(1)) {
const compactMatch = line.match(COMPACT_LINE);
if (compactMatch) {
compactFare = parseAmount(compactMatch[1]);
}
}
}
if (!vehicles.length) return null;
if (compactFare != null) {
vehicles.push({
typeLabel: "경차",
amount: compactFare,
kec: compactFare,
privateAmount: 0,
compact: true,
description: "1종(경차)",
});
}
const fromIc = route?.fromIc ?? "출발";
const toIc = route?.toIc ?? "도착";
return {
fromIc,
toIc,
routeLabel: `${fromIc} → ${toIc}`,
introText:
"요청하신 경로의 통행요금입니다. 도로공사 구간 요금·민자도로 구간 요금 조회 결과입니다.",
primaryFare: vehicles[0].amount,
kecFare: vehicles[0].amount,
privateFare: 0,
compactFare,
vehicles,
};
}
export function formatFareWon(amount: number): string {
return `${amount.toLocaleString("ko-KR")}원`;
}
export function buildKakaoMapSearchUrl(fromIc: string, toIc: string): string {
return `https://map.kakao.com/?q=${encodeURIComponent(`${fromIc} ${toIc}`)}`;
}
export function buildTmapSearchUrl(toIc: string): string {
return `https://tmapapi.sktelecom.com/main/pois?search=${encodeURIComponent(toIc)}`;
}
export type { FareBreakdown, FareBreakdownVehicle };
@@ -0,0 +1,11 @@
export function formatTime(date = new Date()): string {
const h = date.getHours();
const m = String(date.getMinutes()).padStart(2, "0");
const ap = h >= 12 ? "오후" : "오전";
const hh = ((h + 11) % 12) + 1;
return `${ap} ${hh}:${m}`;
}
export function newMessageId(): string {
return `m-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
@@ -0,0 +1,216 @@
export interface WebListCardItem {
title: string;
description?: string;
imageUrl?: string;
linkUrl?: string;
}
export interface FareBreakdownVehicle {
typeLabel: string;
description?: string;
total: number;
kec: number;
private: number;
compact?: boolean;
imageUrl?: string;
}
export interface FareBreakdown {
fromIc: string;
toIc: string;
routeLabel: string;
introText?: string;
totalFare: number;
kecFare: number;
privateFare: number;
compactTotalFare?: number;
compactKecFare?: number;
compactPrivateFare?: number;
vehicles?: FareBreakdownVehicle[];
}
export interface WebListCard {
title?: string;
headerImageUrl?: string;
items: WebListCardItem[];
fareBreakdown?: FareBreakdown;
}
export type ListCardItemKind =
| "gasoline"
| "diesel"
| "lpg"
| "electric"
| "hydrogen"
| "food"
| "food-soup"
| "food-rice"
| "food-meat"
| "food-fish"
| "food-noodle"
| "food-coffee"
| "food-bread"
| "food-drink"
| "food-fastfood"
| "food-premium"
| "food-recommend"
| "facility"
| "facility-nursery"
| "facility-shower"
| "facility-pharmacy"
| "facility-carwash"
| "facility-repair"
| "shop"
| "theme"
| "recruit"
| "road-smooth"
| "road-slow"
| "road-congestion"
| "road-accident"
| "road-work"
| "unknown";
/** Twemoji CDN 파일명(hex) → UI kind */
const TWEMOJI_KIND: Record<string, ListCardItemKind> = {
"26fd": "gasoline",
"1f6e2": "diesel",
"1f525": "lpg",
"26a1": "electric",
"1f4a7": "hydrogen",
"1f35c": "food-noodle",
"1f372": "food-soup",
"1f35a": "food-rice",
"1f969": "food-meat",
"1f41f": "food-fish",
"2615": "food-coffee",
"1f956": "food-bread",
"1f964": "food-drink",
"1f357": "food-fastfood",
"1f354": "food-fastfood",
"1f451": "food-premium",
"2b50": "food-recommend",
"1f3ea": "facility",
"1f476": "facility-nursery",
"1f6bf": "facility-shower",
"1f48a": "facility-pharmacy",
"1f697": "facility-carwash",
"1f527": "facility-repair",
"1f3e7": "facility",
"1f4f6": "facility",
"1f17f": "facility",
"1f6bb": "facility",
"1f3ec": "shop",
"1f3a8": "theme",
"1f4cb": "recruit",
"1f7e2": "road-smooth",
"1f7e1": "road-slow",
"1f534": "road-congestion",
"1f6a8": "road-accident",
"1f6a7": "road-work",
"1f6a6": "road-work",
};
function twemojiCodeFromUrl(imageUrl: string): string | null {
const match = imageUrl.toLowerCase().match(/\/([0-9a-f]+)\.png(?:\?|$)/i);
return match ? match[1] : null;
}
export function listCardItemKindFromImageUrl(imageUrl?: string): ListCardItemKind {
if (!imageUrl) return "unknown";
const lower = imageUrl.toLowerCase();
const twemojiCode = twemojiCodeFromUrl(lower);
if (twemojiCode && TWEMOJI_KIND[twemojiCode]) {
return TWEMOJI_KIND[twemojiCode];
}
if (lower.includes("/logo/")) return "shop";
if (lower.includes("gasoline") || lower.includes("26fd")) return "gasoline";
if (lower.includes("diesel") || lower.includes("1f6e2")) return "diesel";
if (lower.includes("lpg") || lower.includes("1f525")) return "lpg";
if (lower.includes("elec") || lower.includes("26a1")) return "electric";
if (lower.includes("hydro") || lower.includes("1f4a7")) return "hydrogen";
if (lower.includes("menu") || lower.includes("1f35c")) return "food-noodle";
if (lower.includes("congestion") || lower.includes("1f534")) return "road-congestion";
if (lower.includes("accident") || lower.includes("1f6a8")) return "road-accident";
return "unknown";
}
export const LIST_CARD_ICON: Record<ListCardItemKind, string> = {
gasoline: "⛽",
diesel: "🛢️",
lpg: "🔥",
electric: "⚡",
hydrogen: "💧",
food: "🍜",
"food-soup": "🍲",
"food-rice": "🍚",
"food-meat": "🥩",
"food-fish": "🐟",
"food-noodle": "🍜",
"food-coffee": "☕",
"food-bread": "🥖",
"food-drink": "🥤",
"food-fastfood": "🍗",
"food-premium": "👑",
"food-recommend": "⭐",
facility: "🏪",
"facility-nursery": "👶",
"facility-shower": "🚿",
"facility-pharmacy": "💊",
"facility-carwash": "🚗",
"facility-repair": "🔧",
shop: "🏬",
theme: "🎨",
recruit: "📋",
"road-smooth": "🟢",
"road-slow": "🟡",
"road-congestion": "🔴",
"road-accident": "🚨",
"road-work": "🚧",
unknown: "📌",
};
export const LIST_CARD_LABEL: Record<ListCardItemKind, string> = {
gasoline: "휘발유",
diesel: "경유",
lpg: "LPG",
electric: "전기",
hydrogen: "수소",
food: "메뉴",
"food-soup": "국/탕",
"food-rice": "밥류",
"food-meat": "고기",
"food-fish": "생선",
"food-noodle": "면류",
"food-coffee": "카페",
"food-bread": "베이커리",
"food-drink": "음료",
"food-fastfood": "패스트푸드",
"food-premium": "프리미엄",
"food-recommend": "추천",
facility: "시설",
"facility-nursery": "수유실",
"facility-shower": "샤워실",
"facility-pharmacy": "약국",
"facility-carwash": "세차장",
"facility-repair": "정비",
shop: "매장",
theme: "테마",
recruit: "채용",
"road-smooth": "원활",
"road-slow": "서행",
"road-congestion": "정체",
"road-accident": "사고",
"road-work": "공사",
unknown: "정보",
};
/** @deprecated use listCardItemKindFromImageUrl */
export function fuelKindFromImageUrl(imageUrl?: string): ListCardItemKind {
return listCardItemKindFromImageUrl(imageUrl);
}
/** @deprecated use LIST_CARD_LABEL */
export const FUEL_LABEL = LIST_CARD_LABEL;
@@ -0,0 +1,52 @@
let micStream: MediaStream | null = null;
export function isMobileDevice(): boolean {
if (typeof navigator === "undefined") return false;
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent);
}
export async function queryMicPermission(): Promise<PermissionState | "unknown"> {
if (typeof navigator === "undefined" || !navigator.permissions?.query) return "unknown";
try {
const status = await navigator.permissions.query({ name: "microphone" as PermissionName });
return status.state;
} catch {
return "unknown";
}
}
export function getMicrophoneErrorMessage(): string {
if (typeof window === "undefined") return "마이크를 사용할 수 없습니다.";
if (!window.isSecureContext) return "마이크는 HTTPS 환경에서만 사용할 수 있습니다.";
if (!navigator.mediaDevices?.getUserMedia) {
return "마이크 API를 지원하지 않습니다. Chrome 또는 Safari를 사용해 주세요.";
}
return "마이크 권한을 허용해 주세요.";
}
/** Web Speech API 재시도 시 마이크 스트림 확보 (선택적) */
export async function ensureMicStream(): Promise<MediaStream> {
if (micStream?.active) return micStream;
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error(getMicrophoneErrorMessage());
}
micStream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
video: false,
});
return micStream;
}
export function releaseMicStream(): void {
if (!micStream) return;
micStream.getTracks().forEach((track) => track.stop());
micStream = null;
}
@@ -0,0 +1,24 @@
export const PARTCPTN_MINWON_ANSWER =
"고속도로 이용과 관련된 민원은 국민참여로에서 온라인으로 신청·접수하실 수 있습니다.\n\n민원 접수·조회, 포장 파손 피해 배상, 도로 점용 신청 등은 아래 **민원신청** 페이지에서 진행해 주세요.";
export const PARTCPTN_MINWON_ACTION_LABEL = "민원신청 하러가기";
export const PARTCPTN_MINWON_URL_DEFAULT = "/chatbot/sample/minwon/";
export function isPartcptnMinwonQuery(query: string): boolean {
const normalized = query.replace(/\s+/g, "").toLowerCase();
return (
normalized.includes("민원신청") ||
normalized === "민원" ||
normalized.includes("민원신청하기")
);
}
export function resolvePartcptnMinwonUrl(raw: string | null | undefined): string {
const value = raw?.trim();
if (!value) return PARTCPTN_MINWON_URL_DEFAULT;
if (value.startsWith("/") || value.startsWith("http://") || value.startsWith("https://")) {
return value;
}
return PARTCPTN_MINWON_URL_DEFAULT;
}
@@ -0,0 +1,16 @@
import type { ChatMessage, WebQuickReply } from "@/lib/types";
/** 가장 최근 완료된 assistant 메시지의 관련 질문(quickReplies)만 반환한다. */
export function getLatestAssistantQuickReplies(messages: ChatMessage[]): WebQuickReply[] {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (message.role !== "assistant") {
continue;
}
if (message.streaming) {
return [];
}
return message.quickReplies?.length ? message.quickReplies : [];
}
return [];
}
@@ -0,0 +1,85 @@
import { loadApiCapabilities } from "@/lib/apiCapabilities";
import { apiUrl } from "@/lib/config";
const BOT_ID_KEY = "exchatbot_web_bot_id";
const VOICE_BOT_ID_KEY = "exchatbot_voice_bot_id";
// 정책: 대화마다 새 botId(무상태). 카카오↔웹 세션 미연계·과거 이력 비복원.
// - startNew*Session(): 새 대화 시작(앱 진입·"새 대화" 버튼) 시 새 botId 발급.
// - get*BotId(): 진행 중 대화의 botId 반환(멀티턴 동안 동일 유지). 없으면 새로 발급.
function generateBotId(prefix: string): string {
return `${prefix}-${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`;
}
function readOrCreate(key: string, prefix: string, ssrValue: string): string {
if (typeof window === "undefined") {
return ssrValue;
}
try {
const existing = sessionStorage.getItem(key);
if (existing) return existing;
const id = generateBotId(prefix);
sessionStorage.setItem(key, id);
return id;
} catch {
return generateBotId(prefix);
}
}
function rotate(key: string, prefix: string): string {
const id = generateBotId(prefix);
if (typeof window !== "undefined") {
try {
sessionStorage.setItem(key, id);
} catch {
/* sessionStorage 미가용 시 in-memory 없이 진행 */
}
}
return id;
}
export function getBotId(): string {
return readOrCreate(BOT_ID_KEY, "web", "web-ssr");
}
export function getVoiceBotId(): string {
return readOrCreate(VOICE_BOT_ID_KEY, "voice", "voice-ssr");
}
/** 새 대화 시작: 새 botId를 발급해 저장하고 반환한다(과거 이력 비연계). */
export function startNewChatSession(): string {
return rotate(BOT_ID_KEY, "web");
}
/** 새 음성 대화 시작: 새 voice botId를 발급해 저장하고 반환한다. */
export function startNewVoiceSession(): string {
return rotate(VOICE_BOT_ID_KEY, "voice");
}
export function trackSessionStart(): void {
if (typeof window === "undefined") return;
void loadApiCapabilities().then((caps) => {
if (!caps.sessionStart) return;
const botId = getBotId();
fetch(apiUrl("/session/start"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ botId, source: "kakao-inapp" }),
keepalive: true,
}).catch(() => undefined);
});
}
export function trackVoiceSessionStart(): void {
if (typeof window === "undefined") return;
void loadApiCapabilities().then((caps) => {
if (!caps.sessionStart) return;
const botId = getVoiceBotId();
fetch(apiUrl("/session/start"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ botId, source: "voicebot" }),
keepalive: true,
}).catch(() => undefined);
});
}
@@ -0,0 +1,116 @@
import type { WebListCard } from "@/lib/listCards";
export type ChatRole = "user" | "assistant";
export interface WebQuickReply {
label: string;
message: string;
}
export interface WebActionButton {
label: string;
action?: "webLink" | "osLink" | "phone" | string;
url?: string;
webUrl?: string;
pcUrl?: string;
mobileUrl?: string;
androidUrl?: string;
iosUrl?: string;
phoneNumber?: string;
}
export interface ChatMessage {
id: string;
role: ChatRole;
text: string;
time: string;
streaming?: boolean;
statusText?: string;
listCards?: WebListCard[];
quickReplies?: WebQuickReply[];
actionButtons?: WebActionButton[];
actionLink?: {
label: string;
href: string;
external?: boolean;
};
meta?: {
routeType?: string;
intentType?: string;
uiType?: string;
numReferences?: number;
references?: unknown[];
params?: Record<string, unknown>;
domainData?: Record<string, unknown>;
rawData?: Record<string, unknown>;
};
}
export interface FaqCategory {
category: string;
items: { question: string; count?: number }[];
}
export interface FaqResponse {
categories: FaqCategory[];
}
export interface StreamDonePayload {
status?: boolean;
answer?: string;
botId?: string;
routeType?: string;
intentType?: string;
uiType?: string;
num_references?: number;
references?: unknown[];
params?: Record<string, unknown>;
domainData?: Record<string, unknown>;
rawData?: Record<string, unknown>;
listCards?: WebListCard[];
quickReplies?: WebQuickReply[];
actionButtons?: WebActionButton[];
}
export type StreamEvent =
| { type: "status"; phase: string; message?: string }
| { type: "token"; text: string }
| { type: "done"; payload: StreamDonePayload }
| { type: "error"; message: string };
export function parseSseChunk(buffer: string): { events: StreamEvent[]; rest: string } {
const events: StreamEvent[] = [];
const parts = buffer.split("\n\n");
const rest = parts.pop() ?? "";
for (const part of parts) {
if (!part.trim()) continue;
let eventName = "message";
let dataLine = "";
for (const line of part.split("\n")) {
if (line.startsWith("event:")) eventName = line.slice(6).trim();
if (line.startsWith("data:")) dataLine += line.slice(5).trim();
}
if (!dataLine) continue;
try {
const data = JSON.parse(dataLine) as Record<string, unknown>;
if (eventName === "status") {
events.push({
type: "status",
phase: String(data.phase ?? ""),
message: typeof data.message === "string" ? data.message : undefined,
});
} else if (eventName === "token") {
events.push({ type: "token", text: String(data.text ?? "") });
} else if (eventName === "done") {
events.push({ type: "done", payload: data as StreamDonePayload });
} else if (eventName === "error") {
events.push({ type: "error", message: String(data.message ?? "오류") });
}
} catch {
/* ignore malformed chunk */
}
}
return { events, rest };
}
@@ -0,0 +1,45 @@
export type UiMode = "app" | "ai" | "banner" | "voicebot";
export function getUiMode(): UiMode {
const mode = process.env.NEXT_PUBLIC_UI_MODE;
if (mode === "ai") return "ai";
if (mode === "banner") return "banner";
if (mode === "voicebot") return "voicebot";
return "app";
}
export function isAiMode(): boolean {
return getUiMode() === "ai";
}
export function isBannerMode(): boolean {
return getUiMode() === "banner";
}
export function isVoicebotMode(): boolean {
return getUiMode() === "voicebot";
}
export const AI_SUGGESTIONS = [
"칠곡휴게소 주유 가격 알려줘",
"서울에서 부산 가는데 몇시간 걸려?",
"경부선 공사 구간 있나요?",
"하이패스 미납 확인 방법 알려줘",
"휴게소 편의시설 뭐 있어?",
"출퇴근 할인 등록은 어떻게 해?",
] as const;
export const BANNER_SUGGESTIONS = AI_SUGGESTIONS;
export const AI_WELCOME_TITLE = "안녕하세요";
export const AI_WELCOME_SUBTITLE = "무엇을 알고 싶으세요?";
export const AI_LOCATION_LABEL = "한국도로공사";
export function aiAssetPath(relativePath: string): string {
const base = process.env.NEXT_PUBLIC_BASE_PATH || "";
const path = relativePath.startsWith("/") ? relativePath : `/${relativePath}`;
return `${base}${path}`;
}
export const BANNER_PLACEHOLDER = "한국도로공사 AI에 물어보세요";
export const BANNER_CHECKING_TEXT = "답변확인중...";
@@ -0,0 +1,11 @@
/** TTS 재생용 — 마크다운·URL 제거 후 구어체 텍스트 */
export function plainTextForSpeech(text: string): string {
let t = text;
t = t.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1");
t = t.replace(/https?:\/\/\S+/g, "");
t = t.replace(/\*\*([^*]+)\*\*/g, "$1");
t = t.replace(/__([^_]+)__/g, "$1");
t = t.replace(/`([^`]+)`/g, "$1");
t = t.replace(/\s+/g, " ");
return t.trim();
}
@@ -0,0 +1,974 @@
/* 정부24 AI 모드 스타일 — 한국도로공사 AI 챗봇 */
:root {
--ai-primary: #246beb;
--ai-primary-dark: #1b53b7;
--ai-bg: #ffffff;
--ai-surface: #ffffff;
--ai-border: #e4e7ec;
--ai-text: #111827;
--ai-subtext: #6b7280;
--ai-user-bg: #eef4ff;
--ai-max-width: 760px;
}
html.ai-mode,
html.ai-mode body {
background: var(--ai-bg);
color: var(--ai-text);
overflow: hidden;
}
.ai-app {
display: flex;
flex-direction: column;
width: 100%;
height: 100vh;
height: 100dvh;
min-height: 100vh;
min-height: 100dvh;
background: var(--ai-bg);
}
.ai-header {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 12px;
padding: calc(14px + env(safe-area-inset-top, 0px)) 20px 12px;
background: var(--ai-surface);
border-bottom: none;
z-index: 20;
}
.ai-app--chat .ai-header {
border-bottom: 1px solid var(--ai-border);
}
.ai-header-brand {
flex: 1;
min-width: 0;
overflow: hidden;
}
.ai-header-brand .ex-brand {
max-width: 100%;
}
.ai-header-brand .ex-brand-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-location {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
color: #374151;
}
.ai-location-icon {
flex-shrink: 0;
color: var(--ai-primary);
}
.ai-location-label {
font-size: 15px;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.3;
}
.ai-brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.ai-brand-mark {
width: 36px;
height: 36px;
border-radius: 10px;
background: linear-gradient(135deg, #246beb 0%, #1a4fa8 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 800;
letter-spacing: -0.02em;
flex-shrink: 0;
}
.ai-brand-text {
min-width: 0;
}
.ai-brand-title {
margin: 0;
font-size: 15px;
font-weight: 700;
line-height: 1.3;
letter-spacing: -0.02em;
}
.ai-brand-sub {
margin: 2px 0 0;
font-size: 12px;
color: var(--ai-subtext);
line-height: 1.3;
}
.ai-header-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
margin-left: auto;
}
.ai-btn-ghost {
border: 1px solid var(--ai-border);
background: var(--ai-surface);
color: var(--ai-text);
border-radius: 999px;
padding: 8px 14px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: background 0.15s ease, border-color 0.15s ease;
}
.ai-btn-ghost.is-open {
background: #eef4ff;
border-color: #bfd3ff;
color: var(--ai-primary-dark);
}
.ai-btn-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid var(--ai-border);
border-radius: 50%;
background: var(--ai-surface);
color: var(--ai-text);
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s ease, border-color 0.15s ease;
}
.ai-btn-close:hover {
background: #f9fafb;
border-color: #cbd5e1;
}
.ai-btn-close:active {
transform: scale(0.96);
}
.ai-btn-ghost:hover:not(:disabled) {
background: #f9fafb;
border-color: #cbd5e1;
}
.ai-btn-ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.ai-main {
flex: 1;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
}
.ai-app--landing .ai-main {
flex: 1;
display: flex;
flex-direction: column;
}
.ai-app--landing .ai-main-inner {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.ai-main-inner {
width: 100%;
max-width: var(--ai-max-width);
margin: 0 auto;
padding: 16px 20px 16px;
}
.ai-app--landing .ai-main-inner {
max-width: 960px;
}
.ai-hero {
padding: clamp(12px, 3vh, 28px) 0 clamp(16px, 4vh, 32px);
}
.ai-hero-layout {
display: flex;
align-items: center;
justify-content: space-between;
gap: clamp(16px, 4vw, 40px);
}
.ai-hero-copy {
flex: 1 1 0;
min-width: 0;
text-align: left;
}
.ai-hero-visual {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: min(42vw, 220px);
max-width: 220px;
}
.ai-hero-mascot {
display: block;
width: 100%;
height: auto;
object-fit: contain;
}
.ai-hero-greeting {
margin: 0;
font-size: clamp(30px, 7vw, 42px);
font-weight: 800;
line-height: 1.2;
letter-spacing: -0.04em;
color: #0f766e;
}
.ai-hero-question {
margin: 8px 0 0;
font-size: clamp(28px, 6.5vw, 38px);
font-weight: 800;
line-height: 1.25;
letter-spacing: -0.04em;
color: var(--ai-primary);
}
.ai-hero-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
margin-bottom: 16px;
border-radius: 999px;
background: #eef4ff;
color: var(--ai-primary);
font-size: 12px;
font-weight: 700;
}
.ai-hero-title {
margin: 0;
font-size: clamp(28px, 6vw, 36px);
font-weight: 800;
line-height: 1.25;
letter-spacing: -0.03em;
}
.ai-hero-subtitle {
margin: 10px 0 0;
font-size: clamp(16px, 3.5vw, 18px);
color: var(--ai-subtext);
line-height: 1.5;
}
.ai-hero-faq {
margin-top: clamp(20px, 4vh, 32px);
width: 100%;
max-width: 520px;
}
.ai-hero-faq-title {
margin: 0 0 12px;
font-size: 13px;
font-weight: 700;
color: var(--ai-subtext);
letter-spacing: -0.01em;
text-align: left;
}
.ai-hero-faq-loading {
margin: 0;
font-size: 14px;
color: var(--ai-subtext);
text-align: left;
}
.ai-suggestions {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
gap: 8px;
margin-top: 0;
}
.ai-suggestion-chip {
border: 1px solid var(--ai-border);
background: var(--ai-surface);
color: var(--ai-text);
border-radius: 999px;
padding: 10px 16px;
font-size: 14px;
line-height: 1.35;
font-family: inherit;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.ai-suggestion-chip:hover:not(:disabled) {
border-color: #93c5fd;
background: #f8fbff;
}
.ai-suggestion-chip:active:not(:disabled) {
transform: scale(0.98);
}
.ai-suggestion-chip:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.ai-thread {
display: flex;
flex-direction: column;
gap: 20px;
padding-bottom: 8px;
}
.ai-msg {
display: flex;
flex-direction: column;
gap: 6px;
}
.ai-msg.user {
align-items: flex-end;
}
.ai-msg.assistant {
align-items: flex-start;
}
.ai-msg-label {
font-size: 12px;
font-weight: 700;
color: var(--ai-subtext);
padding: 0 4px;
}
.ai-msg-bubble {
max-width: min(92%, 640px);
padding: 14px 16px;
border-radius: 16px;
font-size: 15px;
line-height: 1.65;
word-break: break-word;
}
.ai-msg.user .ai-msg-bubble {
background: var(--ai-user-bg);
border: 1px solid #dbeafe;
border-bottom-right-radius: 4px;
white-space: pre-wrap;
}
.ai-msg.assistant .ai-msg-bubble {
background: var(--ai-surface);
border: 1px solid var(--ai-border);
border-bottom-left-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.ai-msg-bubble .md-content {
font-size: 15px;
}
.ai-loading {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--ai-subtext);
}
.ai-loading-dots {
display: inline-flex;
gap: 4px;
}
.ai-loading-dots span {
width: 6px;
height: 6px;
border-radius: 50%;
background: #94a3b8;
animation: ai-bounce 1.2s infinite ease-in-out;
}
.ai-loading-dots span:nth-child(2) { animation-delay: 0.15s; }
.ai-loading-dots span:nth-child(3) { animation-delay: 0.3s; }
@keyframes ai-bounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.45; }
40% { transform: translateY(-4px); opacity: 1; }
}
.ai-composer-wrap {
flex-shrink: 0;
background: transparent;
border-top: none;
padding: 8px 20px calc(16px + env(safe-area-inset-bottom, 0px));
}
.ai-app--landing .ai-composer-wrap {
margin-top: auto;
}
.ai-composer-inner {
width: 100%;
max-width: var(--ai-max-width);
margin: 0 auto;
}
.ai-composer-box {
display: flex;
align-items: flex-end;
gap: 4px;
padding: 8px 10px 8px 22px;
border: 1.5px solid transparent;
border-radius: 9999px;
background:
linear-gradient(var(--ai-surface), var(--ai-surface)) padding-box,
linear-gradient(90deg, #14b8a6 0%, var(--ai-primary) 100%) border-box;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
transition: box-shadow 0.15s ease;
}
.ai-composer-box:focus-within {
box-shadow: 0 4px 18px rgba(36, 107, 235, 0.12);
}
.ai-composer-input {
flex: 1;
min-width: 0;
min-height: 24px;
max-height: 120px;
border: none;
outline: none;
resize: none;
background: transparent;
font-size: 16px;
line-height: 1.5;
font-family: inherit;
color: var(--ai-text);
padding: 8px 0;
}
.ai-composer-input::placeholder {
color: #9ca3af;
}
.ai-composer-input:disabled {
opacity: 0.6;
}
.ai-composer-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.ai-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: transparent;
color: #4b5563;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
}
.ai-icon-btn:hover:not(:disabled) {
background: #f1f5f9;
color: var(--ai-primary);
}
.ai-icon-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ai-icon-btn.listening {
background: #fee2e2;
color: #ef4444;
}
.ai-icon-btn.unsupported {
opacity: 0.85;
}
.ai-send-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: transparent;
color: #4b5563;
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
}
.ai-send-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.ai-send-btn.is-ready {
color: #374151;
}
.ai-send-btn.is-ready:hover:not(:disabled) {
color: var(--ai-primary);
background: rgba(36, 107, 235, 0.06);
}
.ai-send-btn.is-ready:active {
transform: scale(0.96);
}
.ai-footer-note {
margin: 10px 4px 0;
font-size: 11px;
line-height: 1.5;
color: #9ca3af;
text-align: center;
}
.ai-faq-bar {
flex-shrink: 0;
border-top: 1px solid var(--ai-border);
background: #f9fafb;
padding: 10px 0 calc(8px + env(safe-area-inset-bottom, 0px));
}
.ai-faq-bar-head {
max-width: var(--ai-max-width);
margin: 0 auto 8px;
padding: 0 16px;
font-size: 12px;
font-weight: 700;
color: var(--ai-subtext);
letter-spacing: -0.01em;
}
.ai-faq-bar-scroll {
display: flex;
gap: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
padding: 0 16px 2px;
max-width: var(--ai-max-width);
margin: 0 auto;
}
.ai-faq-bar-scroll::-webkit-scrollbar {
display: none;
}
.ai-faq-bar-empty {
margin: 0;
padding: 4px 0;
font-size: 13px;
color: var(--ai-subtext);
white-space: nowrap;
}
.ai-faq-chip {
flex-shrink: 0;
border: 1px solid var(--ai-border);
background: var(--ai-surface);
color: var(--ai-text);
border-radius: 999px;
padding: 8px 14px;
font-size: 13px;
line-height: 1.35;
font-family: inherit;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease;
max-width: min(280px, 72vw);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ai-faq-chip:hover:not(:disabled) {
border-color: #93c5fd;
background: #eef4ff;
}
.ai-faq-chip:disabled {
opacity: 0.55;
cursor: not-allowed;
}
/* AI 모드 — 헤더 FAQ 드롭다운 */
html.ai-mode .faq-panel {
left: auto;
right: 16px;
bottom: auto;
top: calc(56px + env(safe-area-inset-top, 0px));
transform: none;
width: min(calc(100vw - 32px), 360px);
max-height: min(58vh, 520px);
}
html.ai-mode .ai-app--embed .faq-panel {
right: 12px;
}
.ai-quick-replies {
margin-top: 10px;
max-width: min(92%, 640px);
}
.ai-quick-replies .quick-reply-title {
color: var(--ai-subtext);
}
.ai-quick-replies .quick-reply-chip {
border-color: var(--ai-border);
border-radius: 999px;
}
.ai-quick-replies .quick-reply-chip:hover:not(:disabled) {
background: #eef4ff;
border-color: #93c5fd;
}
.ai-list-cards {
margin-top: 10px;
max-width: min(92%, 640px);
}
.ai-list-cards .list-card {
border-radius: 12px;
}
.ai-list-cards .fare-result-card {
max-width: 100%;
}
@media (max-width: 560px) {
.ai-header {
padding-left: 14px;
padding-right: 14px;
}
.ai-btn-ghost {
padding: 8px 12px;
font-size: 12px;
}
.ai-hero-layout {
flex-direction: column-reverse;
align-items: stretch;
text-align: center;
}
.ai-hero-copy {
text-align: center;
}
.ai-hero-faq-title,
.ai-hero-faq-loading {
text-align: center;
}
.ai-suggestions {
justify-content: center;
}
.ai-hero-visual {
width: min(56vw, 180px);
max-width: none;
margin: 0 auto;
}
}
@media (min-width: 768px) {
.ai-header {
padding-left: 28px;
padding-right: 28px;
}
.ai-main-inner,
.ai-composer-inner,
.ai-faq-bar-head,
.ai-faq-bar-scroll {
padding-left: 28px;
padding-right: 28px;
}
.ai-composer-wrap {
padding-left: 28px;
padding-right: 28px;
}
}
/* ── 음성 입력 오버레이 (정부24 하단 시트 스타일) ── */
.ai-voice-overlay {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
pointer-events: none;
}
.ai-voice-sheet {
position: relative;
pointer-events: auto;
overflow: visible;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.88) 14%,
#ffffff 28%,
#ffffff 100%
);
border-radius: 28px 28px 0 0;
box-shadow:
0 -4px 24px rgba(15, 23, 42, 0.06),
0 -1px 0 rgba(255, 255, 255, 0.8) inset;
}
.ai-voice-sheet-fade {
position: absolute;
left: 0;
right: 0;
top: -72px;
height: 72px;
pointer-events: none;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.35) 45%,
rgba(255, 255, 255, 0.82) 100%
);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
mask-image: linear-gradient(180deg, transparent 0%, #000 72%);
-webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 72%);
}
.ai-voice-sheet-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 8px 24px calc(20px + env(safe-area-inset-bottom, 0px));
}
.ai-voice-mic-btn {
position: relative;
width: 92px;
height: 92px;
margin-top: 4px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.ai-voice-mic-btn:active {
transform: scale(0.96);
}
.ai-voice-mic-ring {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
opacity: 0;
transition: opacity 0.25s ease;
}
.ai-voice-mic-btn.is-listening .ai-voice-mic-ring--1,
.ai-voice-mic-btn.is-speaking .ai-voice-mic-ring--1 {
opacity: 1;
inset: -6px;
border: 2px solid rgba(36, 107, 235, 0.35);
animation: ai-voice-ring-pulse 2.2s ease-out infinite;
}
.ai-voice-mic-btn.is-speaking .ai-voice-mic-ring--2 {
opacity: 1;
inset: -14px;
border: 1.5px solid rgba(15, 118, 110, 0.35);
animation: ai-voice-ring-pulse 2.2s ease-out infinite 0.35s;
}
.ai-voice-mic-core {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border-radius: 50%;
color: #fff;
background: linear-gradient(145deg, #10b981 0%, #0ea5e9 42%, #246beb 100%);
box-shadow:
0 10px 32px rgba(36, 107, 235, 0.32),
0 2px 8px rgba(16, 185, 129, 0.25);
transition: box-shadow 0.25s ease, transform 0.2s ease;
}
.ai-voice-mic-btn.is-listening .ai-voice-mic-core {
animation: ai-voice-mic-glow 2.4s ease-in-out infinite;
}
.ai-voice-mic-btn.is-speaking .ai-voice-mic-core {
animation: ai-voice-mic-glow 1s ease-in-out infinite;
}
.ai-voice-waveform {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
width: min(100%, 280px);
height: 36px;
opacity: 0.45;
transition: opacity 0.25s ease;
}
.ai-voice-waveform.is-listening {
opacity: 1;
}
.ai-voice-wave-bar {
display: block;
flex: 0 0 5px;
width: 5px;
min-height: var(--bar-min, 6px);
height: var(--bar-min, 6px);
border-radius: 999px;
background: linear-gradient(180deg, #34d399 0%, #22d3ee 45%, #3b82f6 100%);
transform-origin: center bottom;
}
.ai-voice-waveform.is-listening .ai-voice-wave-bar {
animation-name: ai-voice-eq;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
animation-direction: alternate;
}
.ai-voice-waveform.is-speaking .ai-voice-wave-bar {
animation-direction: alternate-reverse;
}
.ai-voice-status-pill {
margin: 0;
padding: 10px 22px;
font-size: 14px;
font-weight: 600;
color: #1f2937;
background: #fff;
border: 1px solid #e0e7ff;
border-radius: 999px;
box-shadow: 0 2px 10px rgba(36, 107, 235, 0.07);
max-width: min(92vw, 420px);
text-align: center;
line-height: 1.45;
word-break: keep-all;
}
.ai-voice-status-pill.is-error {
color: #b45309;
border-color: #fde68a;
background: #fffbeb;
}
.ai-voice-interim {
margin: -4px 0 0;
max-width: min(92vw, 420px);
font-size: 14px;
line-height: 1.5;
color: #6b7280;
text-align: center;
word-break: keep-all;
}
@keyframes ai-voice-ring-pulse {
0% {
transform: scale(1);
opacity: 0.65;
}
100% {
transform: scale(1.4);
opacity: 0;
}
}
@keyframes ai-voice-mic-glow {
0%,
100% {
box-shadow:
0 10px 32px rgba(36, 107, 235, 0.28),
0 0 0 0 rgba(36, 107, 235, 0.15);
}
50% {
box-shadow:
0 14px 40px rgba(36, 107, 235, 0.42),
0 0 0 12px rgba(36, 107, 235, 0.05);
}
}
@keyframes ai-voice-eq {
0% {
height: var(--bar-min, 6px);
transform: scaleY(0.85);
}
100% {
height: var(--bar-max, 20px);
transform: scaleY(1);
}
}
@@ -0,0 +1,443 @@
/* /chatbot/banner — 검색창 + 드롭다운 임베드 UI (정부24 스타일 pill) */
:root {
--banner-primary: #246beb;
--banner-accent: #14b8a6;
--banner-bg: #ffffff;
--banner-text: #1a1a1a;
--banner-muted: #9ca3af;
--banner-icon: #4b5563;
--banner-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
--banner-dropdown-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
}
html.banner-mode,
html.banner-mode body {
margin: 0;
padding: 0;
background: var(--banner-bg);
min-height: auto;
font-family: "Pretendard", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Noto Sans KR", sans-serif;
color: var(--banner-text);
}
html.banner-mode.banner-embed-transparent,
html.banner-mode.banner-embed-transparent body {
background: transparent !important;
overflow: visible !important;
height: auto !important;
min-height: 0 !important;
}
.banner-root {
position: relative;
width: 100%;
margin: 0;
padding: 0;
box-sizing: border-box;
overflow: visible;
}
.banner-root--embed {
background: transparent;
overflow: visible;
height: auto;
}
/* iframe 임베드: vh는 iframe 높이 기준이라 드롭다운이 잘림 → 문서 흐름 + px */
.banner-root--embed .banner-dropdown {
position: relative;
top: auto;
left: auto;
right: auto;
margin-top: 8px;
max-height: none;
}
.banner-root--embed .banner-dropdown-body {
max-height: 320px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.banner-search-area {
position: relative;
width: 100%;
overflow: visible;
}
/* 정부24 스타일 — 그라데이션 테두리 pill, 별도 외곽 박스 없음 */
.banner-search-wrap {
position: relative;
display: flex;
align-items: center;
gap: 4px;
width: 100%;
margin: 0;
padding: 8px 10px 8px 22px;
border: 1.5px solid transparent;
border-radius: 9999px;
background:
linear-gradient(var(--banner-bg), var(--banner-bg)) padding-box,
linear-gradient(90deg, var(--banner-accent) 0%, var(--banner-primary) 100%) border-box;
box-shadow: var(--banner-shadow);
transition: box-shadow 0.15s ease;
}
.banner-root--embed .banner-search-wrap {
background:
linear-gradient(#fff, #fff) padding-box,
linear-gradient(90deg, var(--banner-accent) 0%, var(--banner-primary) 100%) border-box;
}
.banner-search-wrap:focus-within {
box-shadow: 0 4px 18px rgba(36, 107, 235, 0.12);
}
.banner-search-input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-size: 16px;
line-height: 1.5;
padding: 8px 0;
min-width: 0;
color: var(--banner-text);
resize: none;
font-family: inherit;
}
.banner-search-input::placeholder {
color: var(--banner-muted);
}
.banner-search-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.banner-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--banner-icon);
cursor: pointer;
transition: color 0.15s ease, background 0.15s ease;
}
.banner-icon-btn:hover:not(:disabled) {
color: var(--banner-primary);
background: rgba(36, 107, 235, 0.06);
}
.banner-icon-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.banner-icon-btn.listening {
color: #ef4444;
background: #fee2e2;
}
.banner-dropdown {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 8px);
z-index: 1000;
background: var(--banner-bg);
border: 1px solid #e8ecf2;
border-radius: 16px;
box-shadow: var(--banner-dropdown-shadow);
max-height: min(420px, 70vh);
overflow: hidden;
display: flex;
flex-direction: column;
animation: banner-drop-in 0.16s ease-out;
}
@keyframes banner-drop-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.banner-dropdown-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px 8px;
border-bottom: 1px solid #eef1f6;
font-size: 13px;
font-weight: 600;
color: var(--banner-muted);
}
.banner-dropdown-close {
border: none;
background: transparent;
color: var(--banner-muted);
cursor: pointer;
font-size: 18px;
line-height: 1;
padding: 4px;
}
.banner-dropdown-body {
overflow-y: auto;
padding: 12px 16px 16px;
flex: 1;
}
.banner-suggestions {
display: flex;
flex-direction: column;
gap: 6px;
}
.banner-suggestion-item {
display: block;
width: 100%;
text-align: left;
border: none;
background: #f4f7fb;
color: var(--banner-text);
font-size: 14px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.12s;
}
.banner-suggestion-item:hover {
background: #e8eef8;
}
.banner-msg {
margin-bottom: 12px;
}
.banner-msg.user .banner-msg-bubble {
margin-left: auto;
max-width: 88%;
background: #eef3ff;
color: var(--banner-text);
border-radius: 12px 12px 4px 12px;
padding: 10px 12px;
font-size: 14px;
line-height: 1.5;
}
.banner-msg.assistant .banner-msg-label {
font-size: 11px;
font-weight: 600;
color: var(--banner-primary);
margin-bottom: 4px;
}
.banner-msg.assistant .banner-msg-bubble {
background: #f8fafc;
border: 1px solid #eef1f6;
border-radius: 4px 12px 12px 12px;
padding: 10px 12px;
font-size: 14px;
line-height: 1.55;
}
.banner-loading {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--banner-muted);
}
.banner-loading-dots span {
display: inline-block;
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--banner-muted);
animation: banner-dot 1.2s infinite ease-in-out;
}
.banner-loading-dots span:nth-child(2) {
animation-delay: 0.15s;
}
.banner-loading-dots span:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes banner-dot {
0%,
80%,
100% {
opacity: 0.3;
transform: scale(0.85);
}
40% {
opacity: 1;
transform: scale(1);
}
}
.banner-list-cards {
margin-top: 8px;
}
.banner-quick-replies {
margin-top: 8px;
}
.banner-footer-note {
padding: 8px 16px 12px;
font-size: 11px;
color: var(--banner-muted);
text-align: center;
border-top: 1px solid #eef1f6;
}
.banner-demo-header {
text-align: center;
padding: 32px 16px 20px;
}
.banner-demo-header h1 {
margin: 0 0 8px;
font-size: 20px;
font-weight: 700;
}
.banner-demo-header p {
margin: 0;
font-size: 14px;
color: var(--banner-muted);
}
.banner-page-shell {
width: 100%;
max-width: 720px;
margin: 0 auto;
padding: 0 16px;
box-sizing: border-box;
}
.banner-root--embed .banner-page-shell {
max-width: none;
padding: 0;
}
/* 국민참여로(ex.co.kr/partcptn) 임베드 — 반투명 pill, 배경 투명 */
.banner-root--partcptn {
background: transparent;
}
.banner-root--partcptn .banner-search-wrap {
padding: 10px 14px 10px 20px;
border: 1px solid rgba(255, 255, 255, 0.55);
border-radius: 9999px;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.06);
}
.banner-root--partcptn .banner-search-wrap:focus-within {
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.08);
}
.banner-root--partcptn .banner-search-input {
font-size: 15px;
}
.banner-root--partcptn .banner-search-input::placeholder {
color: #888;
}
.banner-root--partcptn .banner-icon-btn {
color: #222;
}
.banner-root--partcptn .banner-dropdown {
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
max-height: min(360px, 55vh);
z-index: 9999;
}
.banner-root--partcptn .banner-dropdown-body {
max-height: min(280px, 45vh);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.banner-root--partcptn.banner-root--embed .banner-dropdown,
.banner-root--partcptn.banner-root--embed .banner-dropdown-body {
max-height: none;
}
.banner-root--partcptn.banner-root--embed .banner-dropdown-body {
max-height: 320px;
}
.banner-dropdown-loading {
padding: 16px 4px;
font-size: 14px;
color: var(--banner-muted);
text-align: center;
}
.banner-action-link-wrap {
margin-top: 10px;
}
.banner-action-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 12px 16px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #246beb 0%, #1a4fa8 100%);
color: #fff;
font-size: 14px;
font-weight: 700;
font-family: inherit;
text-decoration: none;
cursor: pointer;
transition: opacity 0.15s ease, transform 0.15s ease;
box-sizing: border-box;
}
.banner-action-link:hover {
opacity: 0.92;
}
.banner-action-link:active {
transform: scale(0.98);
}
.banner-root--partcptn .banner-action-link {
border-radius: 999px;
}
.banner-search-input--checking {
color: var(--banner-muted);
}
@@ -0,0 +1,59 @@
.ex-brand {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
font-family: "Pretendard", "Malgun Gothic", "Apple SD Gothic Neo", sans-serif;
line-height: 1;
}
.ex-brand-mark {
display: inline-flex;
align-items: baseline;
font-size: 22px;
font-weight: 900;
letter-spacing: -0.04em;
}
.ex-brand-e {
color: #e60012;
}
.ex-brand-x {
color: #005bac;
}
.ex-brand-divider {
width: 1px;
height: 18px;
background: #111;
flex-shrink: 0;
}
.ex-brand-text {
font-size: 17px;
font-weight: 800;
letter-spacing: -0.03em;
color: #111;
}
.ex-brand--dark .ex-brand-divider {
background: rgba(255, 255, 255, 0.35);
}
.ex-brand--dark .ex-brand-text {
color: #f3f4f6;
}
.ex-brand--sm .ex-brand-mark {
font-size: 15px;
}
.ex-brand--sm .ex-brand-divider {
height: 14px;
}
.ex-brand--sm .ex-brand-text {
font-size: 12px;
font-weight: 700;
}
@@ -0,0 +1,200 @@
.fare-result-card {
width: 100%;
max-width: min(92vw, 420px);
background: #fff;
border: 1px solid rgba(36, 107, 235, 0.12);
border-radius: 16px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
overflow: hidden;
}
.fare-result-card__intro {
margin: 0;
padding: 14px 16px 0;
font-size: 13px;
line-height: 1.55;
color: #374151;
}
.fare-result-card__total-banner {
margin: 12px 16px 0;
padding: 18px 16px;
border-radius: 14px;
background: linear-gradient(135deg, #1b4ea8 0%, #246beb 100%);
color: #fff;
text-align: center;
}
.fare-result-card__total-label {
display: block;
font-size: 13px;
font-weight: 700;
opacity: 0.92;
}
.fare-result-card__total-amount {
display: block;
margin-top: 6px;
font-size: 30px;
font-weight: 800;
line-height: 1.15;
color: #ffe066;
letter-spacing: -0.03em;
}
.fare-result-card__total-route {
display: block;
margin-top: 8px;
font-size: 12px;
font-weight: 600;
opacity: 0.88;
}
.fare-result-card__sections {
margin: 12px 16px 0;
padding: 12px 14px;
border-radius: 12px;
background: #eef4ff;
}
.fare-result-card__section-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
}
.fare-result-card__section-row + .fare-result-card__section-row {
border-top: 1px solid rgba(36, 107, 235, 0.12);
}
.fare-result-card__section-label {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
font-size: 13px;
font-weight: 700;
color: #1f2937;
}
.fare-result-card__section-icon {
flex-shrink: 0;
width: 34px;
height: 34px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.fare-result-card__section-icon--kec {
background: #dbeafe;
}
.fare-result-card__section-icon--private {
background: #dcfce7;
}
.fare-result-card__section-price {
flex-shrink: 0;
font-size: 17px;
font-weight: 800;
color: #111827;
}
.fare-result-card__vehicle-panel {
margin: 12px 16px 0;
padding: 12px 14px 10px;
border-radius: 12px;
background: #f9fafb;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.fare-result-card__vehicle-title {
margin: 0 0 10px;
font-size: 13px;
font-weight: 800;
color: #111827;
}
.fare-result-card__vehicle-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.fare-result-card__vehicle-item {
padding: 10px 0;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
.fare-result-card__vehicle-item:first-child {
border-top: none;
padding-top: 0;
}
.fare-result-card__vehicle-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.fare-result-card__vehicle-label {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.fare-result-card__vehicle-icon {
width: 28px;
height: 28px;
object-fit: contain;
flex-shrink: 0;
}
.fare-result-card__vehicle-fallback {
flex-shrink: 0;
width: 28px;
text-align: center;
font-size: 20px;
line-height: 1;
}
.fare-result-card__vehicle-type {
font-size: 14px;
font-weight: 700;
color: #374151;
}
.fare-result-card__vehicle-total {
flex-shrink: 0;
font-size: 16px;
font-weight: 800;
color: #111827;
}
.fare-result-card__vehicle-split {
margin: 4px 0 0 38px;
font-size: 11px;
line-height: 1.35;
color: #6b7280;
}
.fare-result-card__note {
margin: 10px 16px 14px;
font-size: 11px;
line-height: 1.4;
color: #9ca3af;
}
.list-card-stack--fare {
max-width: min(92vw, 420px);
}
@@ -0,0 +1,364 @@
:root {
--vb-accent: #3b82f6;
--vb-accent-light: #60a5fa;
--vb-surface: #1a1f35;
--vb-surface-light: #252b45;
--vb-bar: #12182a;
--vb-text: #e5e7eb;
--vb-subtext: #9ca3af;
}
html.voicebot-mode,
html.voicebot-mode body {
margin: 0;
height: 100%;
overflow: hidden;
background: linear-gradient(165deg, #0a0e1c 0%, #121830 45%, #0c1020 100%);
color: var(--vb-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
}
.vb-app {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
min-height: 100vh;
min-height: 100dvh;
}
.vb-header {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
padding: calc(16px + env(safe-area-inset-top, 0px)) 16px 8px;
}
.vb-header-brand {
flex: 1;
min-width: 0;
}
.vb-header-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
margin-left: auto;
}
.vb-btn-close {
border-color: rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.08);
color: #f3f4f6;
}
.vb-btn-close:hover {
background: rgba(255, 255, 255, 0.14);
border-color: rgba(255, 255, 255, 0.28);
}
.vb-header-mark {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
color: #fff;
background: linear-gradient(145deg, var(--vb-accent), #2563eb);
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.35);
}
.vb-header-title {
margin: 0;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.02em;
}
.vb-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #4b5563;
}
.vb-status-dot.is-on {
background: var(--vb-accent);
animation: vb-pulse 2s ease-in-out infinite;
}
.vb-log {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px 16px 16px;
display: flex;
flex-direction: column;
gap: 14px;
-webkit-overflow-scrolling: touch;
}
.vb-empty {
margin: auto;
font-size: 14px;
color: var(--vb-subtext);
text-align: center;
font-style: italic;
}
.vb-msg {
display: flex;
}
.vb-msg--user {
justify-content: flex-end;
}
.vb-msg--assistant {
justify-content: flex-start;
}
.vb-bubble {
max-width: 82%;
padding: 12px 14px;
border-radius: 16px;
font-size: 14px;
line-height: 1.55;
}
.vb-msg--user .vb-bubble {
background: rgba(37, 43, 69, 0.95);
color: var(--vb-text);
border-bottom-right-radius: 4px;
}
.vb-msg--assistant .vb-bubble {
background: rgba(26, 31, 53, 0.95);
border: 1px solid rgba(255, 255, 255, 0.06);
border-bottom-left-radius: 4px;
}
.vb-status {
margin: 0 0 6px;
font-size: 13px;
color: var(--vb-accent-light);
}
.vb-error {
margin: 0;
text-align: center;
font-size: 12px;
color: #f87171;
}
.vb-bottom {
flex-shrink: 0;
position: relative;
margin: 0 12px calc(12px + env(safe-area-inset-bottom, 0px));
}
.vb-mic-zone {
position: absolute;
left: 50%;
top: -34px;
transform: translateX(-50%);
z-index: 2;
}
.vb-mic-btn {
position: relative;
width: 68px;
height: 68px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
}
.vb-mic-ring {
position: absolute;
inset: 0;
border-radius: 50%;
pointer-events: none;
opacity: 0;
}
.vb-mic-idle .vb-mic-ring--1 {
opacity: 1;
inset: -4px;
border: 1.5px solid rgba(96, 165, 250, 0.45);
}
.vb-mic-listening .vb-mic-core {
animation: vb-listen-glow 2.8s ease-in-out infinite;
}
.vb-mic-listening .vb-mic-ring--1 {
opacity: 1;
inset: -3px;
border: 2px solid rgba(96, 165, 250, 0.85);
animation: vb-listen-glow 2.8s ease-in-out infinite;
}
.vb-mic-speaking .vb-mic-core {
animation: vb-speak-glow 1.1s ease-in-out infinite;
}
.vb-mic-speaking .vb-mic-ring--1,
.vb-mic-speaking .vb-mic-ring--2,
.vb-mic-speaking .vb-mic-ring--3 {
opacity: 1;
border: 2px solid rgba(96, 165, 250, 0.7);
animation: vb-ring-pulse 1.1s ease-out infinite;
}
.vb-mic-speaking .vb-mic-ring--2 {
inset: -10px;
animation-delay: 0.12s;
}
.vb-mic-speaking .vb-mic-ring--3 {
inset: -18px;
animation-delay: 0.24s;
}
.vb-mic-core {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
border-radius: 50%;
color: #6b7280;
background: #161b2e;
transition: color 0.25s, background 0.25s;
}
.vb-mic-listening .vb-mic-core,
.vb-mic-speaking .vb-mic-core {
color: #93c5fd;
}
.vb-panel {
padding: 40px 12px 12px;
border-radius: 16px;
background: rgba(18, 24, 42, 0.95);
border: 1px solid rgba(255, 255, 255, 0.05);
backdrop-filter: blur(8px);
}
.vb-hint {
margin: 0 0 12px;
text-align: center;
font-size: 12px;
color: var(--vb-accent-light);
}
.vb-input-row {
display: flex;
align-items: center;
gap: 8px;
}
.vb-tts-toggle {
flex-shrink: 0;
padding: 8px 12px;
border: none;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
cursor: pointer;
background: #4b5563;
color: #d1d5db;
}
.vb-tts-toggle.is-on {
background: var(--vb-accent);
color: #fff;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.35);
}
.vb-text-input {
flex: 1;
min-width: 0;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(26, 31, 53, 0.85);
color: var(--vb-text);
font-size: 14px;
}
.vb-text-input::placeholder {
color: #6b7280;
}
.vb-text-input:focus {
outline: none;
border-color: rgba(59, 130, 246, 0.5);
}
.vb-send-btn {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border: none;
border-radius: 12px;
background: var(--vb-accent);
color: #fff;
cursor: pointer;
}
.vb-bubble .markdown-body {
color: inherit;
font-size: inherit;
}
@keyframes vb-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
@keyframes vb-listen-glow {
0%,
100% {
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.2);
}
50% {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.85), 0 0 22px rgba(59, 130, 246, 0.55);
}
}
@keyframes vb-speak-glow {
0%,
100% {
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.8), 0 0 20px rgba(59, 130, 246, 0.5);
}
50% {
box-shadow: 0 0 0 3px rgba(59, 130, 246, 1), 0 0 32px rgba(59, 130, 246, 0.75);
}
}
@keyframes vb-ring-pulse {
0% {
transform: scale(1);
opacity: 0.7;
}
100% {
transform: scale(1.45);
opacity: 0;
}
}
+86
View File
@@ -0,0 +1,86 @@
interface SpeechRecognitionEventMap {
audioend: Event;
audiostart: Event;
end: Event;
error: SpeechRecognitionErrorEvent;
nomatch: SpeechRecognitionEvent;
result: SpeechRecognitionEvent;
soundend: Event;
soundstart: Event;
speechend: Event;
speechstart: Event;
start: Event;
}
interface SpeechRecognition extends EventTarget {
continuous: boolean;
grammars: SpeechGrammarList;
interimResults: boolean;
lang: string;
maxAlternatives: number;
onaudioend: ((this: SpeechRecognition, ev: Event) => void) | null;
onaudiostart: ((this: SpeechRecognition, ev: Event) => void) | null;
onend: ((this: SpeechRecognition, ev: Event) => void) | null;
onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void) | null;
onnomatch: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null;
onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void) | null;
onsoundend: ((this: SpeechRecognition, ev: Event) => void) | null;
onsoundstart: ((this: SpeechRecognition, ev: Event) => void) | null;
onspeechend: ((this: SpeechRecognition, ev: Event) => void) | null;
onspeechstart: ((this: SpeechRecognition, ev: Event) => void) | null;
onstart: ((this: SpeechRecognition, ev: Event) => void) | null;
start(): void;
stop(): void;
abort(): void;
}
declare var SpeechRecognition: {
prototype: SpeechRecognition;
new (): SpeechRecognition;
};
interface SpeechRecognitionErrorEvent extends Event {
error: string;
message: string;
}
interface SpeechRecognitionEvent extends Event {
resultIndex: number;
results: SpeechRecognitionResultList;
}
interface SpeechRecognitionResultList {
length: number;
item(index: number): SpeechRecognitionResult;
[index: number]: SpeechRecognitionResult;
}
interface SpeechRecognitionResult {
isFinal: boolean;
length: number;
item(index: number): SpeechRecognitionAlternative;
[index: number]: SpeechRecognitionAlternative;
}
interface SpeechRecognitionAlternative {
transcript: string;
confidence: number;
}
interface SpeechGrammarList {
length: number;
addFromString(string: string, weight?: number): void;
addFromURI(src: string, weight?: number): void;
item(index: number): SpeechGrammar;
[index: number]: SpeechGrammar;
}
interface SpeechGrammar {
src: string;
weight: number;
}
declare var webkitSpeechRecognition: {
prototype: SpeechRecognition;
new (): SpeechRecognition;
};