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:
@@ -0,0 +1,109 @@
|
||||
# chatbotApp — 카카오 인앱·브라우저 단독 챗봇 UI (Next.js + SSE)
|
||||
|
||||
`chatbotAdmin`의 챗봇 테스트 화면과 **별개**인, 고속도로 이용객 대상 **단독 웹 챗봇**입니다.
|
||||
|
||||
| 구분 | 경로 | 기술 |
|
||||
|------|------|------|
|
||||
| **chatbotApp (기존)** | `/chatbot/app/` | Next.js 14 · 카카오톡 스타일 · SSE |
|
||||
| **chatbotApp (AI 모드)** | `/chatbot/ai/` | Next.js 14 · 정부24 AI 스타일 · SSE |
|
||||
| **chatbotApp (배너)** | `/chatbot/banner/` | 검색창 + 드롭다운 · iframe/embed.js 임베드 |
|
||||
| **chatbotApp (샘플)** | `/chatbot/sample/` | 국민참여로 UI + banner iframe 데모 |
|
||||
| chatbotAdmin ChatPage | `/chatbot/admin/` | Admin RAG 디버그 |
|
||||
|
||||
## 운영 URL
|
||||
|
||||
```
|
||||
https://chatbot.exdev.co.kr/chatbot/app/ ← 기존 UI
|
||||
https://chatbot.exdev.co.kr/chatbot/ai/ ← AI 모드 UI
|
||||
https://chatbot.exdev.co.kr/chatbot/banner/ ← 배너 검색 (임베드)
|
||||
https://chatbot.exdev.co.kr/chatbot/sample/ ← 국민참여로 샘플
|
||||
```
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
APP[브라우저/카카오 인앱] -->|/chatbot/ask/stream SSE, /chatbot/ask JSON| SK[kakaoChatbotSkill /web/ask +/stream]
|
||||
SK --> RAG[RAG :28012 /agent/chat]
|
||||
RAG --> OUT([answer + 카드 + faqUrls])
|
||||
```
|
||||
|
||||
- **웹 입력 전용 경로**: chatbotApp 질의는 kakaoChatbotSkill의 `/web/ask` 또는 `/web/ask/stream`을 거쳐 RAG Agent(`/agent/chat`)로 전달됩니다.
|
||||
- **속도**: SSE는 kakao-skill에서 전체 답변 완료 후 `done` 이벤트로 전달 (카카오 callback과 유사).
|
||||
- **안정성**: JSON API도 유지해 스트림 실패 시 일반 응답을 받을 수 있습니다.
|
||||
- **FAQ/세션**: `/chatbot/faq/*`, `/chatbot/session/*`는 웹 UI 보조 API입니다.
|
||||
|
||||
### botId · 세션 정책 (현재)
|
||||
|
||||
- 웹앱은 `sessionStorage`에 저장한 랜덤 `botId`(`web-...`, `src/lib/session.ts`)를 사용하며, **대화마다 새 botId로 시작하는 무상태 정책**입니다.
|
||||
- 카카오톡과 **세션을 연계하지 않으며**, 웹 진입 시 **과거 대화를 화면에 복원하지 않습니다**(`useChatSession`은 빈 상태로 시작, `/web/ask`만 호출).
|
||||
- 서버(MongoDB `chat_history`)에는 botId별 이력이 남지만, botId가 매번 새로 발급되므로 사실상 새 세션입니다. 정책 배경·향후 세션 연계(서명 토큰) 옵션: [`../SYSTEM_ARCHITECTURE.md`](../SYSTEM_ARCHITECTURE.md), [`../IMPROVEMENTS.md`](../IMPROVEMENTS.md).
|
||||
|
||||
## 로컬 단독 실행 (nginx :8089)
|
||||
|
||||
> **포트:** `8089` = chatbotApp 전용. Caddy HTTP는 `9089` (`CADDY_HTTP_PORT`).
|
||||
> GoldenChart 로그인이 보이면 `docker stop ex-chatbot-caddy` 후 `./start.sh` 재실행.
|
||||
|
||||
```bash
|
||||
cd chatbotApp
|
||||
./start.sh
|
||||
# → http://localhost:8089/chatbot/app/
|
||||
```
|
||||
|
||||
RAG API(`:28012`)가 기동되어 있어야 질의·SSE가 동작합니다.
|
||||
|
||||
로컬 UI + **원격 API**(기본): `./start.sh` — nginx가 `/chatbot/*`를 `https://chatbot.exdev.co.kr`로 프록시합니다. 로컬 LLM/RAG는 필요 없습니다.
|
||||
|
||||
로컬 RAG로 테스트하려면:
|
||||
|
||||
```bash
|
||||
CHATBOT_API_UPSTREAM=http://host.docker.internal:28012/chatbot/ \
|
||||
CHATBOT_API_HOST=host.docker.internal \
|
||||
./start.sh
|
||||
```
|
||||
|
||||
## 개발
|
||||
|
||||
```bash
|
||||
cd chatbotApp/web
|
||||
npm install
|
||||
npm run dev # http://localhost:3000 — /chatbot/* 는 exdev.co.kr 로 프록시 (next.config rewrites)
|
||||
```
|
||||
|
||||
## 배포 빌드
|
||||
|
||||
```bash
|
||||
cd chatbotApp
|
||||
chmod +x build.sh
|
||||
./build.sh # → public/ (VERSION 3.0.0)
|
||||
```
|
||||
|
||||
프로젝트 루트에서 RAG 반영:
|
||||
|
||||
```bash
|
||||
./scripts/reload-chatbot-app.sh
|
||||
# 또는
|
||||
./remote-start-chatbot.sh
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
| Method | Path | 설명 |
|
||||
|--------|------|------|
|
||||
| POST | `/chatbot/ask/stream` | SSE 스트리밍 질의 (웹앱 기본) |
|
||||
| POST | `/chatbot/ask` | JSON 응답 |
|
||||
| GET | `/chatbot/faq/popular` | FAQ |
|
||||
| POST | `/chatbot/session/start` | 세션 추적 |
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
chatbotApp/
|
||||
├── web/ # Next.js 소스
|
||||
│ ├── src/
|
||||
│ └── scripts/export-public.mjs
|
||||
├── public/ # 빌드 산출물 (배포)
|
||||
├── build.sh
|
||||
├── nginx/
|
||||
└── docker-compose.yml
|
||||
```
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# chatbotApp Next.js 빌드 → public/ + public-ai/ + public-banner/
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WEB_DIR="${SCRIPT_DIR}/web"
|
||||
EMBED_SRC="${SCRIPT_DIR}/embed/chatbot-banner.js"
|
||||
|
||||
build_variant() {
|
||||
local base_path="$1"
|
||||
local ui_mode="$2"
|
||||
local export_dir="$3"
|
||||
|
||||
echo "[build] NEXT_PUBLIC_BASE_PATH=${base_path} NEXT_PUBLIC_UI_MODE=${ui_mode} → ${export_dir}/"
|
||||
(
|
||||
cd "$WEB_DIR"
|
||||
export NEXT_PUBLIC_BASE_PATH="$base_path"
|
||||
export NEXT_PUBLIC_UI_MODE="$ui_mode"
|
||||
export NEXT_PUBLIC_CHATBOT_API_ROOT="${NEXT_PUBLIC_CHATBOT_API_ROOT:-/chatbot}"
|
||||
export EXPORT_TARGET_DIR="$export_dir"
|
||||
npm run build:deploy
|
||||
)
|
||||
}
|
||||
|
||||
build_variant "/chatbot/app" "app" "public"
|
||||
build_variant "/chatbot/ai" "ai" "public-ai"
|
||||
build_variant "/chatbot/banner" "banner" "public-banner"
|
||||
build_variant "/chatbot/voicebot" "voicebot" "public-voicebot"
|
||||
|
||||
if [[ -f "${EMBED_SRC}" ]]; then
|
||||
cp "${EMBED_SRC}" "${SCRIPT_DIR}/public-banner/embed.js"
|
||||
echo "[build] embed.js → public-banner/embed.js"
|
||||
fi
|
||||
|
||||
SAMPLE_SRC="${SCRIPT_DIR}/sample"
|
||||
SAMPLE_DEST="${SCRIPT_DIR}/public-sample"
|
||||
if [[ -d "${SAMPLE_SRC}" ]]; then
|
||||
rm -rf "${SAMPLE_DEST}"
|
||||
mkdir -p "${SAMPLE_DEST}"
|
||||
cp -R "${SAMPLE_SRC}/." "${SAMPLE_DEST}/"
|
||||
echo "[build] sample/ → public-sample/"
|
||||
fi
|
||||
|
||||
echo "[build] ✅ chatbotApp 배포 완료"
|
||||
echo " - /chatbot/app/ → public/"
|
||||
echo " - /chatbot/ai/ → public-ai/"
|
||||
echo " - /chatbot/banner/ → public-banner/"
|
||||
echo " - /chatbot/sample/ → public-sample/"
|
||||
echo " - /chatbot/voicebot/ → public-voicebot/"
|
||||
@@ -0,0 +1,29 @@
|
||||
# chatbotApp 단독 실행 (카카오 인앱·브라우저 UI)
|
||||
# cd chatbotApp && docker compose up -d
|
||||
# → http://localhost:8089/chatbot/app/
|
||||
#
|
||||
# 기본: /chatbot/* → https://chatbot.exdev.co.kr (원격 exAiChatBot)
|
||||
# 로컬 RAG(:28012) 사용:
|
||||
# CHATBOT_API_UPSTREAM=http://host.docker.internal:28012/chatbot/ \
|
||||
# CHATBOT_API_HOST=host.docker.internal docker compose up -d --force-recreate
|
||||
services:
|
||||
chatbot-app-web:
|
||||
image: nginx:1.27-alpine
|
||||
container_name: ex-chatbot-app-web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${CHATBOT_APP_PORT:-8089}:80"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
CHATBOT_API_UPSTREAM: "${CHATBOT_API_UPSTREAM:-https://chatbot.exdev.co.kr/chatbot/}"
|
||||
CHATBOT_API_HOST: "${CHATBOT_API_HOST:-chatbot.exdev.co.kr}"
|
||||
volumes:
|
||||
- ./public:/usr/share/nginx/html:ro
|
||||
- ./public-ai:/usr/share/nginx/html-ai:ro
|
||||
- ./public-banner:/usr/share/nginx/html-banner:ro
|
||||
- ./public-sample:/usr/share/nginx/html-sample:ro
|
||||
- ./public-voicebot:/usr/share/nginx/html-voicebot:ro
|
||||
- ./nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro
|
||||
- ./nginx/docker-entrypoint.sh:/docker-entrypoint.sh:ro
|
||||
entrypoint: ["/bin/sh", "/docker-entrypoint.sh"]
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* ExChatbotBanner — 타 사이트 임베드용 검색 배너 SDK (iframe)
|
||||
* @example
|
||||
* <div id="ex-chatbot-banner"></div>
|
||||
* <script src="https://chatbot.exdev.co.kr/chatbot/banner/embed.js"></script>
|
||||
* <script>
|
||||
* ExChatbotBanner.init({ container: "#ex-chatbot-banner" });
|
||||
* </script>
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var DEFAULT_BASE = "https://chatbot.exdev.co.kr/chatbot/banner/";
|
||||
|
||||
function resolveContainer(el) {
|
||||
if (!el) return null;
|
||||
if (typeof el === "string") return document.querySelector(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function init(options) {
|
||||
options = options || {};
|
||||
var container = resolveContainer(options.container);
|
||||
if (!container) {
|
||||
console.error("[ExChatbotBanner] container not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
var baseUrl = (options.baseUrl || DEFAULT_BASE).replace(/\/?$/, "/");
|
||||
var params = new URLSearchParams();
|
||||
if (options.embed !== false) params.set("embed", "1");
|
||||
if (options.placeholder) params.set("placeholder", String(options.placeholder));
|
||||
var qs = params.toString();
|
||||
var src = baseUrl + (qs ? "?" + qs : "");
|
||||
|
||||
var minHeight = Number(options.minHeight) || 56;
|
||||
var maxHeight = Number(options.maxHeight) || 520;
|
||||
var height = Number(options.height) || minHeight;
|
||||
|
||||
var wrap = document.createElement("div");
|
||||
wrap.className = "ex-chatbot-banner-widget";
|
||||
wrap.style.cssText =
|
||||
"position:relative;width:100%;max-width:" +
|
||||
(options.maxWidth || "720px") +
|
||||
";margin:0 auto;";
|
||||
|
||||
var iframe = document.createElement("iframe");
|
||||
iframe.src = src;
|
||||
iframe.title = options.title || "한국도로공사 AI 검색";
|
||||
iframe.setAttribute("allow", "clipboard-write");
|
||||
iframe.loading = options.loading || "lazy";
|
||||
iframe.style.cssText =
|
||||
"display:block;width:100%;border:0;overflow:hidden;min-height:" +
|
||||
minHeight +
|
||||
"px;height:" +
|
||||
height +
|
||||
"px;transition:height 0.15s ease;";
|
||||
|
||||
function onMessage(ev) {
|
||||
if (!ev.data || ev.data.type !== "ex-chatbot-banner-resize") return;
|
||||
if (ev.source !== iframe.contentWindow) return;
|
||||
var h = Number(ev.data.height);
|
||||
if (h > 0) {
|
||||
iframe.style.height = Math.min(Math.max(h, minHeight), maxHeight) + "px";
|
||||
}
|
||||
}
|
||||
|
||||
global.addEventListener("message", onMessage);
|
||||
wrap.appendChild(iframe);
|
||||
container.appendChild(wrap);
|
||||
|
||||
return {
|
||||
destroy: function () {
|
||||
global.removeEventListener("message", onMessage);
|
||||
wrap.remove();
|
||||
},
|
||||
iframe: iframe,
|
||||
};
|
||||
}
|
||||
|
||||
global.ExChatbotBanner = {
|
||||
init: init,
|
||||
version: "1.0.0",
|
||||
};
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -0,0 +1,2 @@
|
||||
# 이 파일은 참고용입니다. docker compose는 default.conf.template + docker-entrypoint.sh 를 사용합니다.
|
||||
# 기본 upstream: https://chatbot.exdev.co.kr/chatbot/
|
||||
@@ -0,0 +1,104 @@
|
||||
# chatbotApp 단독 웹서버 — Next.js 정적 UI + 챗 API 프록시
|
||||
# 기본 upstream: https://chatbot.exdev.co.kr (Caddy → kakaoChatbotSkill /web/ask)
|
||||
# 로컬 kakao-skill(:8083) 사용 시:
|
||||
# CHATBOT_API_UPSTREAM=http://host.docker.internal:8083/web/
|
||||
# CHATBOT_API_HOST=host.docker.internal
|
||||
# 레거시 RAG 직접(:28012) 사용 시:
|
||||
# CHATBOT_API_UPSTREAM=http://host.docker.internal:28012/chatbot/
|
||||
#
|
||||
# Next.js 빌드(basePath=/chatbot/app)의 CSS·JS는 /chatbot/app/_next/ 경로를 사용합니다.
|
||||
# /chatbot/ API 프록시보다 정적 location을 먼저 두어야 스타일이 적용됩니다.
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
client_max_body_size 1m;
|
||||
|
||||
# Next.js 정적 자산 + SPA (운영과 동일 경로 /chatbot/app/, /chatbot/ai/)
|
||||
location = /chatbot/app {
|
||||
return 301 /chatbot/app/;
|
||||
}
|
||||
|
||||
location ^~ /chatbot/app/ {
|
||||
alias /usr/share/nginx/html/;
|
||||
index index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
location = /chatbot/ai {
|
||||
return 301 /chatbot/ai/;
|
||||
}
|
||||
|
||||
location ^~ /chatbot/ai/ {
|
||||
alias /usr/share/nginx/html-ai/;
|
||||
index index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
|
||||
location = /chatbot/banner {
|
||||
return 301 /chatbot/banner/;
|
||||
}
|
||||
|
||||
location ^~ /chatbot/banner/ {
|
||||
alias /usr/share/nginx/html-banner/;
|
||||
index index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Content-Security-Policy "frame-ancestors *" always;
|
||||
}
|
||||
|
||||
location = /chatbot/sample {
|
||||
return 301 /chatbot/sample/;
|
||||
}
|
||||
|
||||
location ^~ /chatbot/sample/ {
|
||||
alias /usr/share/nginx/html-sample/;
|
||||
index index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Permissions-Policy "microphone=*, camera=()" always;
|
||||
}
|
||||
|
||||
location = /chatbot/voicebot {
|
||||
return 301 /chatbot/voicebot/;
|
||||
}
|
||||
|
||||
location ^~ /chatbot/voicebot/ {
|
||||
alias /usr/share/nginx/html-voicebot/;
|
||||
index index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Content-Security-Policy "frame-ancestors *" always;
|
||||
add_header Permissions-Policy "microphone=*, camera=()" always;
|
||||
}
|
||||
|
||||
# 루트 접속 → basePath와 동일 URL로 이동
|
||||
location = / {
|
||||
return 302 /chatbot/app/;
|
||||
}
|
||||
|
||||
# RAG API (ask/stream/faq/session/health 등) — 원격 exAiChatBot 또는 로컬 RAG
|
||||
location /chatbot/ {
|
||||
proxy_pass ${CHATBOT_API_UPSTREAM};
|
||||
proxy_http_version 1.1;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header Host ${CHATBOT_API_HOST};
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 120s;
|
||||
proxy_send_timeout 120s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
export CHATBOT_API_UPSTREAM="${CHATBOT_API_UPSTREAM:-https://chatbot.exdev.co.kr/chatbot/}"
|
||||
export CHATBOT_API_HOST="${CHATBOT_API_HOST:-chatbot.exdev.co.kr}"
|
||||
|
||||
envsubst '${CHATBOT_API_UPSTREAM} ${CHATBOT_API_HOST}' \
|
||||
< /etc/nginx/templates/default.conf.template \
|
||||
> /etc/nginx/conf.d/default.conf
|
||||
|
||||
exec nginx -g 'daemon off;'
|
||||
@@ -0,0 +1,97 @@
|
||||
.minwon-page .partcptn-header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.minwon-page .partcptn-logo {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.minwon-back-link {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.minwon-back-link:hover {
|
||||
color: #005bac;
|
||||
}
|
||||
|
||||
.minwon-main {
|
||||
flex: 1;
|
||||
padding: 24px 20px 32px;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.minwon-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.minwon-lead {
|
||||
margin: 0 0 24px;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.minwon-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.minwon-card {
|
||||
padding: 18px 16px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.minwon-card h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.minwon-card p {
|
||||
margin: 0 0 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.minwon-card-btn {
|
||||
border: 1px solid #246beb;
|
||||
background: #fff;
|
||||
color: #246beb;
|
||||
border-radius: 999px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.minwon-card-btn:hover {
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.minwon-note {
|
||||
margin: 24px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.minwon-note a {
|
||||
color: #005bac;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/* 국민참여로 (ex.co.kr/partcptn) 샘플 — 모바일 퍼스트 */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: "Pretendard", "Malgun Gothic", "Apple SD Gothic Neo", sans-serif;
|
||||
color: #111;
|
||||
background: #dfe8f0;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.partcptn-page {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
background: #e8eef4;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.partcptn-header {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.partcptn-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.partcptn-logo-ex {
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
background: linear-gradient(90deg, #e60012 0%, #005bac 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.partcptn-logo-text {
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
color: #111;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.partcptn-menu-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.partcptn-menu-icon {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 18px;
|
||||
}
|
||||
|
||||
.partcptn-menu-icon span {
|
||||
display: block;
|
||||
height: 2px;
|
||||
background: #333;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.partcptn-hero {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.partcptn-hero-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(232, 238, 244, 0.2) 0%, rgba(210, 220, 232, 0.45) 100%),
|
||||
radial-gradient(ellipse 70% 50% at 50% 55%, rgba(255, 220, 150, 0.35) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 90% 60% at 50% 40%, rgba(200, 215, 235, 0.9) 0%, rgba(180, 195, 215, 0.6) 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.partcptn-hero-bg::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 42%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 120px;
|
||||
height: 160px;
|
||||
background: radial-gradient(ellipse, rgba(255, 240, 200, 0.5) 0%, transparent 70%);
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
.partcptn-hero-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 28px 20px 24px;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.partcptn-hero-title {
|
||||
margin: 0 0 16px;
|
||||
font-size: clamp(22px, 5.5vw, 28px);
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.03em;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.partcptn-hero-sub {
|
||||
margin: 0 0 28px;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
color: #333;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.partcptn-search-slot {
|
||||
width: 100%;
|
||||
max-width: 340px;
|
||||
background: transparent;
|
||||
line-height: 0;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.partcptn-search-slot iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
min-height: 48px;
|
||||
background: transparent !important;
|
||||
overflow: visible;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.partcptn-search-slot.is-expanded {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.partcptn-page--search-open .partcptn-hero {
|
||||
flex: 1 1 auto;
|
||||
min-height: min(72vh, 640px);
|
||||
}
|
||||
|
||||
.partcptn-bottom-dock {
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: space-around;
|
||||
gap: 0;
|
||||
margin-top: auto;
|
||||
padding: 18px 12px calc(18px + env(safe-area-inset-bottom, 0px));
|
||||
background: rgba(58, 54, 50, 0.62);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.partcptn-dock-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 4px 8px 2px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.partcptn-dock-item:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.partcptn-dock-item:active {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.partcptn-dock-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.partcptn-dock-icon svg {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.partcptn-dock-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
letter-spacing: -0.02em;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.sample-chat-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
}
|
||||
|
||||
.sample-chat-overlay.is-open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.sample-chat-panel {
|
||||
position: relative;
|
||||
width: min(100%, 420px);
|
||||
height: min(88vh, 720px);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: 0 24px 48px rgba(15, 23, 42, 0.2);
|
||||
}
|
||||
|
||||
.sample-chat-panel iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.sample-chat-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
z-index: 2;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
color: #333;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
@media (min-width: 481px) {
|
||||
.partcptn-page {
|
||||
min-height: 100vh;
|
||||
border-left: 1px solid rgba(0, 0, 0, 0.06);
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>국민참여로 - 국민과 함께, 열린 도로, 열린 소통</title>
|
||||
<meta name="description" content="한국도로공사 국민참여로 샘플 — AI 검색 배너 iframe 연동" />
|
||||
<link rel="stylesheet" href="/chatbot/sample/css/partcptn-sample.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="partcptn-page">
|
||||
<header class="partcptn-header">
|
||||
<div class="partcptn-logo" aria-label="ex 국민참여로">
|
||||
<span class="partcptn-logo-ex">ex</span>
|
||||
<span class="partcptn-logo-text">국민참여로</span>
|
||||
</div>
|
||||
<button type="button" class="partcptn-menu-btn" aria-label="전체메뉴">
|
||||
<span class="partcptn-menu-icon" aria-hidden="true">
|
||||
<span></span><span></span><span></span>
|
||||
</span>
|
||||
전체메뉴
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="partcptn-hero">
|
||||
<div class="partcptn-hero-bg" aria-hidden="true"></div>
|
||||
|
||||
<div class="partcptn-hero-content">
|
||||
<h1 class="partcptn-hero-title">국민과 함께, 열린 도로, 열린 소통</h1>
|
||||
<p class="partcptn-hero-sub">
|
||||
국민의 작은 목소리도 정책의 시작이 됩니다.<br />
|
||||
소통하고, 함께 만들어가는 스마트 국민 소통 플랫폼
|
||||
</p>
|
||||
|
||||
<div class="partcptn-search-slot" id="partcptn-search-slot">
|
||||
<iframe
|
||||
id="chatbot-banner-frame"
|
||||
src="/chatbot/banner/?embed=1&theme=partcptn&minwonUrl=%2Fchatbot%2Fsample%2Fminwon%2F&placeholder=%EA%B2%80%EC%83%89%EC%96%B4%EB%A5%BC%20%EC%9E%85%EB%A0%A5%ED%95%B4%20%EC%A3%BC%EC%84%B8%EC%9A%94"
|
||||
title="AI 검색"
|
||||
loading="lazy"
|
||||
allowtransparency="true"
|
||||
style="height: 48px"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<nav class="partcptn-bottom-dock" aria-label="챗봇 바로가기">
|
||||
<button
|
||||
type="button"
|
||||
class="partcptn-dock-item"
|
||||
data-chatbot-url="/chatbot/app/"
|
||||
data-chatbot-title="카톡챗봇"
|
||||
>
|
||||
<span class="partcptn-dock-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M14 10h20a4 4 0 0 1 4 4v10a4 4 0 0 1-4 4H20l-6 4v-4h-4a4 4 0 0 1-4-4V14a4 4 0 0 1 4-4z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle cx="18" cy="19" r="1.2" fill="currentColor" />
|
||||
<circle cx="24" cy="19" r="1.2" fill="currentColor" />
|
||||
<circle cx="30" cy="19" r="1.2" fill="currentColor" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="partcptn-dock-label">카톡챗봇</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="partcptn-dock-item"
|
||||
data-chatbot-url="/chatbot/ai/"
|
||||
data-chatbot-title="AI챗봇"
|
||||
>
|
||||
<span class="partcptn-dock-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="12" y="14" width="24" height="20" rx="3" stroke="currentColor" stroke-width="1.6" />
|
||||
<path d="M20 22h8M20 26h5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
<path d="M18 10h12" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
<circle cx="18" cy="10" r="1.5" fill="currentColor" />
|
||||
<circle cx="30" cy="10" r="1.5" fill="currentColor" />
|
||||
<path d="M16 38h16" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="partcptn-dock-label">AI챗봇</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="partcptn-dock-item"
|
||||
data-chatbot-url="/chatbot/voicebot/"
|
||||
data-chatbot-title="음성챗봇"
|
||||
>
|
||||
<span class="partcptn-dock-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="20" y="12" width="8" height="14" rx="4" stroke="currentColor" stroke-width="1.6" />
|
||||
<path
|
||||
d="M16 24a8 8 0 0 0 16 0"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path d="M24 32v4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
<path d="M20 36h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="partcptn-dock-label">음성챗봇</span>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="sample-chat-overlay"
|
||||
class="sample-chat-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-hidden="true"
|
||||
aria-label="챗봇"
|
||||
>
|
||||
<div class="sample-chat-panel">
|
||||
<iframe
|
||||
id="sample-chat-frame"
|
||||
title="챗봇"
|
||||
src="about:blank"
|
||||
allow="microphone *; autoplay *"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var iframe = document.getElementById("chatbot-banner-frame");
|
||||
var searchSlot = document.getElementById("partcptn-search-slot");
|
||||
var page = document.querySelector(".partcptn-page");
|
||||
if (iframe) {
|
||||
var minH = 48;
|
||||
function maxH() {
|
||||
return Math.min(Math.round(window.innerHeight * 0.72), 640);
|
||||
}
|
||||
window.addEventListener("message", function (ev) {
|
||||
if (!ev.data || ev.data.type !== "ex-chatbot-banner-resize") return;
|
||||
if (ev.source !== iframe.contentWindow) return;
|
||||
var h = Number(ev.data.height);
|
||||
if (h > 0) {
|
||||
var next = Math.min(Math.max(h, minH), maxH());
|
||||
iframe.style.height = next + "px";
|
||||
var expanded = next > minH + 8;
|
||||
if (searchSlot) {
|
||||
searchSlot.classList.toggle("is-expanded", expanded);
|
||||
}
|
||||
if (page) {
|
||||
page.classList.toggle("partcptn-page--search-open", expanded);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var overlay = document.getElementById("sample-chat-overlay");
|
||||
var chatFrame = document.getElementById("sample-chat-frame");
|
||||
|
||||
function withEmbed(url) {
|
||||
if (!url) return url;
|
||||
return url + (url.indexOf("?") >= 0 ? "&" : "?") + "embed=1";
|
||||
}
|
||||
|
||||
function openChatbot(url, title) {
|
||||
if (!overlay || !chatFrame) return;
|
||||
var isVoicebot = url.indexOf("/voicebot") !== -1;
|
||||
var frameUrl = withEmbed(url);
|
||||
|
||||
function showFrame() {
|
||||
chatFrame.src = frameUrl;
|
||||
chatFrame.title = title || "챗봇";
|
||||
overlay.classList.add("is-open");
|
||||
overlay.setAttribute("aria-hidden", "false");
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
if (isVoicebot) {
|
||||
chatFrame.onload = function () {
|
||||
try {
|
||||
chatFrame.contentWindow.postMessage(
|
||||
{ type: "ex-voicebot-autostart" },
|
||||
window.location.origin
|
||||
);
|
||||
} catch (_) {}
|
||||
};
|
||||
} else {
|
||||
chatFrame.onload = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isVoicebot && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ audio: true, video: false })
|
||||
.then(function (stream) {
|
||||
stream.getTracks().forEach(function (track) {
|
||||
track.stop();
|
||||
});
|
||||
showFrame();
|
||||
})
|
||||
.catch(function () {
|
||||
showFrame();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
showFrame();
|
||||
}
|
||||
|
||||
function closeChatbot() {
|
||||
if (!overlay || !chatFrame) return;
|
||||
overlay.classList.remove("is-open");
|
||||
overlay.setAttribute("aria-hidden", "true");
|
||||
chatFrame.src = "about:blank";
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-chatbot-url]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
openChatbot(btn.getAttribute("data-chatbot-url"), btn.getAttribute("data-chatbot-title"));
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("message", function (ev) {
|
||||
if (!ev.data || ev.data.type !== "ex-chatbot-close") return;
|
||||
if (chatFrame && ev.source === chatFrame.contentWindow) {
|
||||
closeChatbot();
|
||||
}
|
||||
});
|
||||
|
||||
if (overlay) {
|
||||
overlay.addEventListener("click", function (e) {
|
||||
if (e.target === overlay) closeChatbot();
|
||||
});
|
||||
}
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape" && overlay && overlay.classList.contains("is-open")) {
|
||||
closeChatbot();
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<title>민원신청 - 국민참여로</title>
|
||||
<link rel="stylesheet" href="/chatbot/sample/css/partcptn-sample.css" />
|
||||
<link rel="stylesheet" href="/chatbot/sample/css/minwon-sample.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="partcptn-page minwon-page">
|
||||
<header class="partcptn-header">
|
||||
<a href="/chatbot/sample/" class="partcptn-logo" aria-label="국민참여로 홈">
|
||||
<span class="partcptn-logo-ex">ex</span>
|
||||
<span class="partcptn-logo-text">국민참여로</span>
|
||||
</a>
|
||||
<a href="/chatbot/sample/" class="minwon-back-link">← 샘플 홈</a>
|
||||
</header>
|
||||
|
||||
<main class="minwon-main">
|
||||
<h1 class="minwon-title">민원신청</h1>
|
||||
<p class="minwon-lead">
|
||||
고속도로 이용과 관련된 민원을 온라인으로 접수·조회하실 수 있습니다.
|
||||
</p>
|
||||
|
||||
<section class="minwon-cards" aria-label="민원 유형">
|
||||
<article class="minwon-card">
|
||||
<h2>포장 파손 피해 배상</h2>
|
||||
<p>고속도로 포장 파손으로 인한 차량 피해 배상을 신청합니다.</p>
|
||||
<button type="button" class="minwon-card-btn">신청하기</button>
|
||||
</article>
|
||||
<article class="minwon-card">
|
||||
<h2>도로 점용 허가</h2>
|
||||
<p>고속도로 구간 내 점용·굴착 등 허가를 신청합니다.</p>
|
||||
<button type="button" class="minwon-card-btn">신청하기</button>
|
||||
</article>
|
||||
<article class="minwon-card">
|
||||
<h2>일반 민원 접수</h2>
|
||||
<p>기타 고속도로 관련 민원을 접수합니다.</p>
|
||||
<button type="button" class="minwon-card-btn">신청하기</button>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<p class="minwon-note">
|
||||
본 페이지는 국민참여로 샘플 연동용 데모 화면입니다. 실제 민원 접수는
|
||||
<a href="https://www.ex.co.kr/partcptn/" target="_blank" rel="noopener noreferrer">한국도로공사 국민참여로</a>
|
||||
를 이용해 주세요.
|
||||
</p>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# chatbotApp 단독 웹서버 기동 (nginx + RAG API 프록시)
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
export PATH="/usr/local/bin:/Applications/Docker.app/Contents/Resources/bin:${PATH}"
|
||||
|
||||
PORT="${CHATBOT_APP_PORT:-8089}"
|
||||
RAG_PORT="${RAG_API_PORT:-28012}"
|
||||
export CHATBOT_API_UPSTREAM="${CHATBOT_API_UPSTREAM:-https://chatbot.exdev.co.kr/chatbot/}"
|
||||
export CHATBOT_API_HOST="${CHATBOT_API_HOST:-chatbot.exdev.co.kr}"
|
||||
|
||||
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
|
||||
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
log "ERROR: Docker Engine 미기동"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# :8089 점유 시 Caddy/GoldenChart 등과 충돌 — chatbotApp 전용 포트 확보
|
||||
if docker ps --format '{{.Names}} {{.Ports}}' 2>/dev/null | grep -E '8089->8089|:8089->8089' | grep -qv 'ex-chatbot-app-web'; then
|
||||
log "⚠️ :8089 다른 컨테이너(Caddy 등) 점유 — docker stop ex-chatbot-caddy 후 재시도"
|
||||
fi
|
||||
|
||||
docker compose up -d --force-recreate
|
||||
|
||||
sleep 2
|
||||
APP_URL="http://127.0.0.1:${PORT}/chatbot/app/"
|
||||
BODY="$(curl -sf --max-time 5 "$APP_URL" 2>/dev/null || true)"
|
||||
|
||||
if echo "$BODY" | grep -q 'data-chatbot-app="next-sse"'; then
|
||||
log "✅ chatbotApp UI: ${APP_URL}"
|
||||
elif echo "$BODY" | grep -qi 'GoldenChart'; then
|
||||
log "❌ GoldenChart 화면 — :${PORT} 포트 충돌"
|
||||
log " 1) docker stop ex-chatbot-caddy (CADDY_HTTP_PORT는 9089 권장)"
|
||||
log " 2) cd chatbotApp && docker compose up -d --force-recreate"
|
||||
log " 3) ${APP_URL} 재접속"
|
||||
exit 1
|
||||
else
|
||||
log "⚠️ UI 미응답 — docker logs ex-chatbot-app-web"
|
||||
fi
|
||||
|
||||
if curl -sf --max-time 10 "http://127.0.0.1:${PORT}/chatbot/health" >/dev/null 2>&1; then
|
||||
log "✅ API 프록시 연결 가능 → ${CHATBOT_API_UPSTREAM}"
|
||||
else
|
||||
log "⚠️ API 프록시 미응답 — 원격 ${CHATBOT_API_UPSTREAM} 또는 네트워크 확인"
|
||||
fi
|
||||
|
||||
if curl -sf --max-time 5 "http://127.0.0.1:${RAG_PORT}/chatbot/health" >/dev/null 2>&1; then
|
||||
log "ℹ️ 로컬 RAG :${RAG_PORT} 기동 중 (프록시 대상 변경 시 CHATBOT_API_UPSTREAM 사용)"
|
||||
fi
|
||||
|
||||
log "원격 API: ${CHATBOT_API_UPSTREAM}"
|
||||
log "운영 URL (Caddy): https://chatbot.exdev.co.kr/chatbot/app/"
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
@@ -0,0 +1,29 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || "";
|
||||
|
||||
const remoteApiOrigin = (
|
||||
process.env.CHATBOT_API_UPSTREAM ||
|
||||
process.env.NEXT_PUBLIC_CHATBOT_API_BASE ||
|
||||
"https://chatbot.exdev.co.kr"
|
||||
).replace(/\/chatbot\/?$/, "");
|
||||
|
||||
const nextConfig = {
|
||||
output: "export",
|
||||
basePath,
|
||||
assetPrefix: basePath || undefined,
|
||||
trailingSlash: true,
|
||||
reactStrictMode: true,
|
||||
poweredByHeader: false,
|
||||
images: { unoptimized: true },
|
||||
async rewrites() {
|
||||
if (process.env.NODE_ENV !== "development") return [];
|
||||
return [
|
||||
{
|
||||
source: "/chatbot/:path*",
|
||||
destination: `${remoteApiOrigin}/chatbot/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
+1993
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "chatbot-app-web",
|
||||
"private": true,
|
||||
"version": "3.0.0",
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3000",
|
||||
"build": "next build",
|
||||
"build:deploy": "next build && node scripts/export-public.mjs",
|
||||
"lint": "next lint",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^14.2.29",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^9.0.3",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
@@ -0,0 +1,48 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const webRoot = path.resolve(scriptDir, "..");
|
||||
const outRoot = path.join(webRoot, "out");
|
||||
const sourceDir = outRoot;
|
||||
const targetName = process.env.EXPORT_TARGET_DIR || "public";
|
||||
const targetDir = path.join(webRoot, "..", targetName);
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || "/chatbot/app";
|
||||
const uiMode = process.env.NEXT_PUBLIC_UI_MODE || "app";
|
||||
|
||||
if (!fs.existsSync(sourceDir)) {
|
||||
console.error(`[export-public] 빌드 출력 없음: ${sourceDir}`);
|
||||
console.error("NEXT_PUBLIC_BASE_PATH=/chatbot/app npm run build:deploy");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function copyRecursive(src, dest) {
|
||||
fs.mkdirSync(dest, { recursive: true });
|
||||
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
||||
const from = path.join(src, entry.name);
|
||||
const to = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
copyRecursive(from, to);
|
||||
} else {
|
||||
fs.copyFileSync(from, to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of fs.existsSync(targetDir) ? fs.readdirSync(targetDir) : []) {
|
||||
const p = path.join(targetDir, name);
|
||||
if (name === ".gitkeep") continue;
|
||||
fs.rmSync(p, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
copyRecursive(sourceDir, targetDir);
|
||||
|
||||
const versionPath = path.join(targetDir, "VERSION");
|
||||
const version = fs.existsSync(versionPath)
|
||||
? fs.readFileSync(versionPath, "utf8").trim()
|
||||
: "3.0.0";
|
||||
const stamp = `${version} (${uiMode}@${basePath})`;
|
||||
fs.writeFileSync(versionPath, `${stamp}\n`, "utf8");
|
||||
|
||||
console.log(`[export-public] ✅ ${sourceDir} → ${targetDir} (${stamp})`);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user