mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +09:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dacf3e617f | ||
|
|
b115663ecf | ||
|
|
cf3ce3f40c | ||
|
|
9af00c3016 | ||
|
|
ecd3192b40 | ||
|
|
352ac2ffd1 |
No files matched your search
+39
-6
@@ -25,13 +25,46 @@ POSTGRES_PASSWORD=
|
|||||||
DATABASE_URL=
|
DATABASE_URL=
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# 선택 — Gemini API 키
|
# 선택 — AI 프로바이더
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Gemini AI 요약 기능을 쓰려면 키가 필요합니다.
|
# AI 요약 기능을 쓰려면 프로바이더 설정이 필요합니다. 두 가지 방법 중 선택:
|
||||||
# 두 가지 방법 중 선택:
|
# (A) 웹 UI 방식 — 앱 기동 후 /settings에서 선택·입력 (브라우저 LocalStorage, 추천)
|
||||||
# (A) 웹 UI 방식 — 앱 기동 후 /settings에서 입력 (브라우저 LocalStorage 저장, 추천)
|
# (B) 환경변수 방식 — 아래 값을 채우면 모든 사용자가 공통으로 사용
|
||||||
# (B) 환경변수 방식 — 아래 값 채우면 모든 사용자가 공통으로 사용
|
|
||||||
#
|
#
|
||||||
|
# 설정이 없어도 "단순 변환" 모드는 정상 동작합니다.
|
||||||
|
|
||||||
|
# --- (기본) Google Gemini 직접 호출 ---
|
||||||
# 무료 발급: https://aistudio.google.com/apikey
|
# 무료 발급: https://aistudio.google.com/apikey
|
||||||
# 키 없이도 "단순 변환" 모드는 정상 동작합니다.
|
|
||||||
GEMINI_API_KEY=
|
GEMINI_API_KEY=
|
||||||
|
|
||||||
|
# --- (선택) OpenAI 호환 엔드포인트 ---
|
||||||
|
# OrcaRouter · OpenAI · Ollama · LM Studio · vLLM 등 OpenAI Chat Completions
|
||||||
|
# 형식을 따르는 엔드포인트라면 무엇이든 연결됩니다.
|
||||||
|
#
|
||||||
|
# LLM_PROVIDER=openai-compatible 로 두었을 때만 아래 값들이 사용됩니다.
|
||||||
|
# OpenAI : LLM_BASE_URL=https://api.openai.com/v1 LLM_MODEL=gpt-4o-mini
|
||||||
|
# OrcaRouter : LLM_BASE_URL=https://api.orcarouter.ai/v1 LLM_MODEL=google/gemini-3.5-flash-lite
|
||||||
|
# Ollama : LLM_BASE_URL=http://localhost:11434/v1 LLM_MODEL=llama3.1 (키 불필요)
|
||||||
|
#
|
||||||
|
# ⚠️ 중계 서비스를 쓰면 회의 전문이 그 회사 서버를 거칩니다. SECURITY.md 참고.
|
||||||
|
# LLM_PROVIDER=
|
||||||
|
# LLM_BASE_URL=
|
||||||
|
# LLM_MODEL=
|
||||||
|
# LLM_API_KEY=
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# 회의 오디오 녹음 저장 위치 (선택)
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# 기본값은 <프로젝트>/uploads/recordings 입니다.
|
||||||
|
# Docker Compose에서는 uploads 볼륨에 저장되어 컨테이너를 재시작해도 남습니다.
|
||||||
|
#
|
||||||
|
# ⚠️ 녹음 파일에는 회의 원음이 그대로 들어 있습니다. 디스크 암호화된 위치에
|
||||||
|
# 두고, 필요 없어진 녹음은 직접 삭제하세요. SECURITY.md 참고.
|
||||||
|
# RECORDINGS_DIR=
|
||||||
|
|
||||||
|
# 음성 전사(STT) 모델 (선택)
|
||||||
|
# 비우면 프로바이더 기본값: OpenAI 호환 → whisper-1, Gemini → gemini-3.6-flash
|
||||||
|
#
|
||||||
|
# ⚠️ 전사는 회의 원음을 프로바이더 서버로 보냅니다. 텍스트보다 훨씬 민감합니다.
|
||||||
|
# 외부로 내보내고 싶지 않다면 로컬 whisper 서버를 LLM_BASE_URL로 지정하세요.
|
||||||
|
# LLM_STT_MODEL=
|
||||||
@@ -28,7 +28,7 @@ docker compose up -d
|
|||||||
- [⚡ 실시간 롤링 요약](#-실시간-롤링-요약)
|
- [⚡ 실시간 롤링 요약](#-실시간-롤링-요약)
|
||||||
- [💾 회의록 저장/조회/검색](#-회의록-저장조회검색)
|
- [💾 회의록 저장/조회/검색](#-회의록-저장조회검색)
|
||||||
- [✅ 액션 아이템 자동 추출](#-액션-아이템-자동-추출)
|
- [✅ 액션 아이템 자동 추출](#-액션-아이템-자동-추출)
|
||||||
- [🔑 웹에서 API 키 설정](#-웹에서-api-키-설정)
|
- [🔑 AI 프로바이더 & API 키 설정](#-ai-프로바이더--api-키-설정)
|
||||||
- [📋 Google Docs 호환 복사](#-google-docs-호환-복사)
|
- [📋 Google Docs 호환 복사](#-google-docs-호환-복사)
|
||||||
- [🚀 시작하기](#-시작하기)
|
- [🚀 시작하기](#-시작하기)
|
||||||
- [Docker Compose (권장)](#docker-compose-권장)
|
- [Docker Compose (권장)](#docker-compose-권장)
|
||||||
@@ -51,10 +51,78 @@ docker compose up -d
|
|||||||
|
|
||||||
**특징**
|
**특징**
|
||||||
- 즉시 시작 — 별도 STT 서버나 키 없이 작동
|
- 즉시 시작 — 별도 STT 서버나 키 없이 작동
|
||||||
|
- **오디오 원본 동시 녹음** — 전사와 무관하게 회의 원음을 파일로 보관
|
||||||
- **자동 재연결** — 네트워크 끊김 시 최대 8회 재시도, 누적 transcript 보존
|
- **자동 재연결** — 네트워크 끊김 시 최대 8회 재시도, 누적 transcript 보존
|
||||||
- 확정 전 텍스트는 회색 이탤릭 + 깜빡이는 커서로 시각화
|
- 확정 전 텍스트는 회색 이탤릭 + 깜빡이는 커서로 시각화
|
||||||
- 녹음 중 `🔴 N단어 · M개 구간` 실시간 카운터
|
- 녹음 중 `🔴 N단어 · M개 구간` 실시간 카운터
|
||||||
|
|
||||||
|
> ⚠️ **Web Speech API는 발화를 상당량 놓칩니다.** 원거리·다인 대면 회의에서
|
||||||
|
> 실측 포착률이 10% 안팎이었습니다(46분 회의 / 489어절). 회의 전사용으로
|
||||||
|
> 설계된 엔진이 아니라 근접 음성 명령용입니다. 그래서 이 패널은 **미리보기**이고,
|
||||||
|
> 확정본은 종료 후 [서버 STT 재전사](#-서버-stt-재전사-정확도의-본선)로 만듭니다.
|
||||||
|
|
||||||
|
**유실 방지** — Chrome이 흘리는 것을 최대한 붙잡습니다.
|
||||||
|
|
||||||
|
- **미확정 발화 보존** — Chrome은 `continuous`여도 침묵마다 세션을 닫으면서 확정 전
|
||||||
|
문장을 버립니다. 세션이 끝날 때마다 직접 확정시켜 살려냅니다
|
||||||
|
- **마지막 발화 보존** — 중지 시점에 확정되지 않은 회의 끝머리도 함께 넘깁니다
|
||||||
|
- **인식 실패 가시화** — Chrome이 "소리는 들었으나 못 알아듣겠다"고 답한 구간
|
||||||
|
(`isFinal: true` + 빈 텍스트)은 요약 프롬프트를 오염시키지 않도록 청크에서 빼되,
|
||||||
|
**`⚠️ 인식 실패 N건` 배지로 세어 보여줍니다.** 실측 46분 회의에서 19건이었습니다
|
||||||
|
- **실제 타임스탬프** — 발화가 처음 들린 시각을 시작으로 잡고, 오디오 녹음과 같은
|
||||||
|
시간축을 씁니다 (예전에는 `결과 이벤트 시각 − 2초` 하드코딩)
|
||||||
|
|
||||||
|
### 🎧 오디오 원본 보관
|
||||||
|
|
||||||
|
녹음을 시작하면 전사와 **별개로** 마이크 입력을 오디오 파일로 저장합니다.
|
||||||
|
|
||||||
|
- 15초마다 조각을 서버에 이어 붙여 **탭이나 서버가 죽어도 그때까지의 오디오는 남습니다**
|
||||||
|
- 서버 저장이 실패해도 녹음은 중단되지 않고, 브라우저 사본을 **⬇️ 내려받기**로 회수할 수 있습니다
|
||||||
|
- 한 조각도 못 받았으면 "보관됨"이라고 하지 않고 오류를 띄웁니다
|
||||||
|
- 저장 위치: `uploads/recordings/` (Docker에서는 `uploads` 볼륨 → 재시작해도 유지)
|
||||||
|
|
||||||
|
**캡처 설정** — 브라우저 기본값은 전화 통화용 튜닝이라 멀리 앉은 화자를
|
||||||
|
노이즈로 지워버립니다. 그래서 노이즈 억제·에코 제거를 끄고 AGC만 남깁니다
|
||||||
|
(`src/lib/recording.ts`의 `RECORDING_AUDIO_CONSTRAINTS`).
|
||||||
|
|
||||||
|
**비트레이트** — 32kbps mono Opus. 감상용이 아니라 STT 입력이므로 인식 정확도를
|
||||||
|
해치지 않는 선에서 최대한 작게 잡았습니다. 46분 회의가 약 11MB로, 전사 API에
|
||||||
|
통째로 넣을 수 있습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔤 서버 STT 재전사 (정확도의 본선)
|
||||||
|
|
||||||
|
보관된 오디오를 Whisper / Gemini로 다시 전사합니다. **Web Speech가 놓친 발화를
|
||||||
|
되살리는 경로이며, 이 앱의 전사 정확도는 사실상 여기서 결정됩니다.**
|
||||||
|
|
||||||
|
**쓰는 법**
|
||||||
|
- 실시간 녹음 → 종료 → **🔤 원본 오디오로 다시 전사**
|
||||||
|
- 또는 파일 업로드 탭 → 파일 선택 → **🔤 전사 시작**
|
||||||
|
|
||||||
|
두 경로 모두 같은 파이프라인(`/api/transcribe`)을 씁니다.
|
||||||
|
|
||||||
|
**실제 타임스탬프** — Whisper의 `verbose_json`은 구간별 실제 오디오 시각을 줍니다.
|
||||||
|
Web Speech 경로가 쓰던 *"결과 이벤트 시각 − 2초"* 추정값과 달리, 구간 재생이나
|
||||||
|
화자 분리에 그대로 쓸 수 있는 진짜 타임라인입니다.
|
||||||
|
|
||||||
|
**참석자 · 용어 힌트** — 홈 화면의 입력란에 이름·제품명·사내 용어를 적어두면
|
||||||
|
전사 요청의 어휘 힌트로 전달되어 고유명사 오인식이 크게 줍니다.
|
||||||
|
|
||||||
|
**프로바이더별 지원**
|
||||||
|
|
||||||
|
| 프로바이더 | 브라우저 녹음(webm) | 업로드 파일 | 기본 모델 |
|
||||||
|
|---|:---:|:---:|---|
|
||||||
|
| OpenAI / OrcaRouter / 로컬 whisper | ✅ | ✅ | `whisper-1` |
|
||||||
|
| Google Gemini | ❌ | ✅ (mp3·wav·flac·m4a·ogg) | `gemini-3.6-flash` |
|
||||||
|
|
||||||
|
Gemini는 webm 오디오를 받지 않습니다. **실시간 녹음을 전사하려면 OpenAI 호환
|
||||||
|
프로바이더를 선택해야 합니다.** 설정 화면에서 이 경고를 함께 안내합니다.
|
||||||
|
|
||||||
|
> 🔴 **전사는 회의 원음을 프로바이더 서버로 보냅니다.** 텍스트보다 훨씬 민감한
|
||||||
|
> 데이터입니다. 외부 전송이 곤란하면 로컬 whisper 서버를 `base URL`로 지정하세요.
|
||||||
|
> 자세한 내용은 [SECURITY.md](SECURITY.md)를 참고하세요.
|
||||||
|
|
||||||
**사용**
|
**사용**
|
||||||
1. 홈에서 **🎤 녹음 시작** 클릭
|
1. 홈에서 **🎤 녹음 시작** 클릭
|
||||||
2. 마이크 권한 허용
|
2. 마이크 권한 허용
|
||||||
@@ -63,20 +131,48 @@ docker compose up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 📏 긴 회의 (map-reduce 요약)
|
||||||
|
|
||||||
|
전사문이 6만 자를 넘으면 한 번에 모델에 넣지 못합니다. 예전에는 10만 자에서
|
||||||
|
**413으로 거부**해, 3시간짜리 회의를 마치고 회의록을 만들려는 순간 아무것도 받지
|
||||||
|
못했습니다. 유실을 막자고 만든 파이프라인 끝에서 결과를 통째로 버리는 셈이었습니다.
|
||||||
|
|
||||||
|
지금은 구간별로 한 번 압축한 뒤(map), 압축본을 모아 **평소와 똑같은 템플릿 경로로**
|
||||||
|
회의록을 만듭니다(reduce). 최종 출력이 짧은 회의와 같은 프롬프트를 타므로 서식이
|
||||||
|
흔들리지 않습니다.
|
||||||
|
|
||||||
|
- 압축 창 4만 자 · 단일 통과 상한 6만 자 · 전체 상한 50만 자(약 14시간 분량)
|
||||||
|
- **한 구간이라도 실패하면 전체를 실패로 돌립니다.** 일부만 빠진 압축본으로 회의록을
|
||||||
|
만들면 사용자는 무엇이 빠졌는지 모른 채 멀쩡해 보이는 문서를 받게 됩니다
|
||||||
|
- 압축을 거치면 회의록 상단에 몇 개 구간으로 나눴는지 안내합니다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 🤖 AI 회의록 (6 템플릿 × 3 강도)
|
### 🤖 AI 회의록 (6 템플릿 × 3 강도)
|
||||||
|
|
||||||
회의 외에도 다양한 용도에 맞춰 출력 구조를 선택할 수 있습니다.
|
회의 외에도 다양한 용도에 맞춰 출력 구조를 선택할 수 있습니다.
|
||||||
|
|
||||||
| 템플릿 | 생성되는 구조 | 기본 강도 | 라이브 요약 |
|
| 템플릿 | 생성되는 구조 | 기본 강도 | 라이브 요약 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 🗂️ 회의록 | 요약 / 논의 / 액션 / 결정 | 표준 | ✅ |
|
| 🗂️ 회의록 | 요약 / **내용 기반 주제 섹션** / 액션 / 결정 | 표준 | ✅ |
|
||||||
| 🎓 강의·세미나 노트 | 핵심 개념 / 예시 / 인용 / 후속 질문 | 상세 | ✅ |
|
| 🎓 강의·세미나 노트 | 핵심 개념 / 예시 / 인용 / 후속 질문 | 상세 | ✅ |
|
||||||
| 🤝 1:1 미팅 | 주제 / 고민 / 피드백 / 다음 액션 | 표준 | ✅ |
|
| 🤝 1:1 미팅 | 요지 / **내용 기반 주제 섹션** / 고민 / 피드백 / 다음 액션 | 표준 | ✅ |
|
||||||
| 💡 브레인스토밍 | 카테고리별 아이디어 / 즉시 시도 / 보류 | 상세 | ✅ |
|
| 💡 브레인스토밍 | 카테고리별 아이디어 / 즉시 시도 / 보류 | 상세 | ✅ |
|
||||||
| 🎤 인터뷰 | Q&A 포맷 / 인상적 발언 / 종합 | 표준 | ❌ |
|
| 🎤 인터뷰 | Q&A 포맷 / 인상적 발언 / 종합 | 표준 | ❌ |
|
||||||
| 📝 원문 정리 | 요약 없이 문단화·오탈자 정리만 | 상세 고정 | ❌ |
|
| 📝 원문 정리 | 요약 없이 문단화·오탈자 정리만 | 상세 고정 | ❌ |
|
||||||
| ⚙️ 커스텀 | 자유 프롬프트 입력 | - | ✅ |
|
| ⚙️ 커스텀 | 자유 프롬프트 입력 | - | ✅ |
|
||||||
|
|
||||||
|
**내용 기반 주제 섹션**
|
||||||
|
|
||||||
|
회의록·1:1 템플릿은 `## 주요 논의 사항` 같은 고정 제목을 쓰지 않습니다. 그 회의가 실제로 무엇을
|
||||||
|
다뤘는지에 따라 AI가 섹션 제목을 직접 짓습니다 — 예를 들어 `## 단계별 개발 계획`,
|
||||||
|
`## 수익화 방안`, `## 기술적 고려사항` 처럼. 제목만 훑어도 회의 내용이 파악됩니다.
|
||||||
|
|
||||||
|
**액션 아이템 담당자**
|
||||||
|
|
||||||
|
발화에서 담당자가 파악되면 `- [ ] (김지훈) 경쟁 사이트 리스트업 — 5/17까지` 형태로 이름과 기한이 붙습니다.
|
||||||
|
담당자가 정해지지 않은 항목도 버리지 않고 그대로 수집합니다.
|
||||||
|
|
||||||
**강도 3단계**
|
**강도 3단계**
|
||||||
- **간결** — 각 섹션 3줄 이내
|
- **간결** — 각 섹션 3줄 이내
|
||||||
- **표준** — 맥락이 이해될 정도
|
- **표준** — 맥락이 이해될 정도
|
||||||
@@ -101,7 +197,7 @@ docker compose up -d
|
|||||||
- 429 쿼터 초과 시 60초 자동 쿨다운, UI에 카운트다운 노출
|
- 429 쿼터 초과 시 60초 자동 쿨다운, UI에 카운트다운 노출
|
||||||
- 일반 실패 시 지수 백오프 (15s → 30s → 최대 120s)
|
- 일반 실패 시 지수 백오프 (15s → 30s → 최대 120s)
|
||||||
|
|
||||||
**비용 가드 (Gemini 2.5 Flash Lite 무료 등급 기준)**
|
**비용 가드 (Gemini Flash Lite 무료 등급 기준)**
|
||||||
- 15 RPM / 1000 RPD / 250K TPM
|
- 15 RPM / 1000 RPD / 250K TPM
|
||||||
- 30초 폴링 + 증분 게이트 → 1시간 회의 ≤ 60회 호출, 발화량 적으면 훨씬 적음
|
- 30초 폴링 + 증분 게이트 → 1시간 회의 ≤ 60회 호출, 발화량 적으면 훨씬 적음
|
||||||
- 개인 사용 시 일일 한도 도달 거의 불가
|
- 개인 사용 시 일일 한도 도달 거의 불가
|
||||||
@@ -154,9 +250,22 @@ docker compose up -d
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 🔑 웹에서 API 키 설정
|
### 🔑 AI 프로바이더 & API 키 설정
|
||||||
|
|
||||||
`.env`를 만지지 않고 `/settings` 페이지에서 Gemini 키를 입력·저장·검증할 수 있습니다.
|
`.env`를 만지지 않고 `/settings` 페이지에서 프로바이더·모델·키를 선택하고 검증할 수 있습니다.
|
||||||
|
|
||||||
|
**선택 가능한 프로바이더**
|
||||||
|
|
||||||
|
| 프리셋 | 엔드포인트 | 비고 |
|
||||||
|
|--------|-----------|------|
|
||||||
|
| **Google Gemini** (기본) | Google 직접 호출 | 무료 티어가 있어 가장 간단 |
|
||||||
|
| **OpenAI** | `https://api.openai.com/v1` | Chat Completions |
|
||||||
|
| **OrcaRouter** | `https://api.orcarouter.ai/v1` | 하나의 키로 여러 제공사 모델 |
|
||||||
|
| **로컬 모델** | `http://localhost:11434/v1` | Ollama · LM Studio · vLLM — 회의 내용이 외부로 나가지 않음 |
|
||||||
|
| **직접 입력** | 사용자 지정 | OpenAI 호환이면 무엇이든 |
|
||||||
|
|
||||||
|
Gemini 외 프리셋은 모두 동일한 **OpenAI Chat Completions** 어댑터를 사용합니다
|
||||||
|
(`src/lib/providers/openai-compatible.ts`). base URL과 모델 ID만 바꾸면 새 서비스가 붙습니다.
|
||||||
|
|
||||||
**저장 위치**
|
**저장 위치**
|
||||||
- 브라우저 LocalStorage (서버 DB에 저장되지 않음)
|
- 브라우저 LocalStorage (서버 DB에 저장되지 않음)
|
||||||
@@ -164,9 +273,9 @@ docker compose up -d
|
|||||||
|
|
||||||
**우선순위**
|
**우선순위**
|
||||||
```
|
```
|
||||||
1. 요청 body의 apiKey (브라우저 LocalStorage)
|
1. 요청 body의 provider/apiKey/baseUrl/model (브라우저 LocalStorage)
|
||||||
↓ 없으면
|
↓ 없으면
|
||||||
2. process.env.GEMINI_API_KEY (서버 환경변수 fallback)
|
2. 서버 환경변수 (GEMINI_API_KEY 또는 LLM_PROVIDER / LLM_BASE_URL / LLM_MODEL / LLM_API_KEY)
|
||||||
↓ 없으면
|
↓ 없으면
|
||||||
3. summarize → 단순 변환 폴백 (warning 표시)
|
3. summarize → 단순 변환 폴백 (warning 표시)
|
||||||
summarize-live → 503 + 안내
|
summarize-live → 503 + 안내
|
||||||
@@ -175,12 +284,14 @@ docker compose up -d
|
|||||||
**기능**
|
**기능**
|
||||||
- 마스킹된 현재 키 표시 (`AIza••••XYZ12`)
|
- 마스킹된 현재 키 표시 (`AIza••••XYZ12`)
|
||||||
- 보이기/숨기기 토글
|
- 보이기/숨기기 토글
|
||||||
- **🧪 테스트 호출** — 가벼운 Gemini 응답으로 즉시 키 검증
|
- **🧪 테스트 호출** — 가벼운 응답으로 즉시 설정 검증 (사용된 모델명 표시)
|
||||||
- 현재 키 소스 배지 (`브라우저` / `환경변수` / `없음`)
|
- 현재 소스 배지 (`브라우저` / `환경변수` / `없음`)
|
||||||
|
- 기존에 Gemini 키만 저장해둔 사용자는 **재입력 없이 그대로 동작** (자동 승계)
|
||||||
|
|
||||||
**보안**
|
**보안**
|
||||||
- HTTPS 권장 (LocalStorage는 동일 출처 정책 의존)
|
- HTTPS 권장 (LocalStorage는 동일 출처 정책 의존)
|
||||||
- GET 응답에 절대 풀 키 노출 안 함
|
- GET 응답에 절대 풀 키 노출 안 함
|
||||||
|
- base URL은 `http`/`https`만 허용 — 자세한 내용은 [SECURITY.md](SECURITY.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -231,12 +342,13 @@ DATABASE_URL=postgresql://meetinguser:<위와_같은_비번>@localhost:5432/meet
|
|||||||
|
|
||||||
> ⚠️ `POSTGRES_PASSWORD`를 비워두면 docker compose가 명시적 에러로 실패합니다. 보안을 위한 의도된 동작.
|
> ⚠️ `POSTGRES_PASSWORD`를 비워두면 docker compose가 명시적 에러로 실패합니다. 보안을 위한 의도된 동작.
|
||||||
|
|
||||||
> 💡 **Gemini API 키는 두 가지 방법 중 선택**
|
> 💡 **AI 프로바이더 설정은 두 가지 방법 중 선택**
|
||||||
>
|
>
|
||||||
> - **(A) 웹 UI** — 앱 기동 후 `/settings`에서 입력 (브라우저 LocalStorage, 추천)
|
> - **(A) 웹 UI** — 앱 기동 후 `/settings`에서 프로바이더 선택 + 키 입력 (브라우저 LocalStorage, 추천)
|
||||||
> - **(B) `.env` 환경변수** — `GEMINI_API_KEY=AIza...` 작성
|
> - **(B) `.env` 환경변수** — `GEMINI_API_KEY=AIza...` 또는 `LLM_PROVIDER` / `LLM_BASE_URL` / `LLM_MODEL` / `LLM_API_KEY`
|
||||||
>
|
>
|
||||||
> [Google AI Studio](https://aistudio.google.com/apikey)에서 무료로 발급. 키 없이도 "단순 변환" 모드는 동작.
|
> 기본값인 Gemini 키는 [Google AI Studio](https://aistudio.google.com/apikey)에서 무료로 발급.
|
||||||
|
> 설정이 없어도 "단순 변환" 모드는 동작.
|
||||||
|
|
||||||
#### 3. DB 마이그레이션 (최초 1회)
|
#### 3. DB 마이그레이션 (최초 1회)
|
||||||
|
|
||||||
@@ -304,11 +416,11 @@ npm run test:coverage # 커버리지 리포트
|
|||||||
|------|------|
|
|------|------|
|
||||||
| 프레임워크 | Next.js 16 (App Router) + React 19 + TypeScript |
|
| 프레임워크 | Next.js 16 (App Router) + React 19 + TypeScript |
|
||||||
| STT (실시간) | Web Speech API — Chrome 내장, 무료 |
|
| STT (실시간) | Web Speech API — Chrome 내장, 무료 |
|
||||||
| AI 요약 | Gemini 2.5 Flash Lite (무료 등급 15 RPM / 1000 RPD) |
|
| AI 요약 | Gemini 3.5 Flash Lite (기본) · OpenAI 호환 엔드포인트 선택 가능 |
|
||||||
| DB | PostgreSQL 16 + Prisma 7 (driver adapter `@prisma/adapter-pg`) |
|
| DB | PostgreSQL 16 + Prisma 7 (driver adapter `@prisma/adapter-pg`) |
|
||||||
| Markdown | `marked` + `isomorphic-dompurify` |
|
| Markdown | `marked` + `isomorphic-dompurify` |
|
||||||
| 스타일 | Tailwind CSS v4 |
|
| 스타일 | Tailwind CSS v4 |
|
||||||
| 테스트 | Vitest 4 (jsdom, 102 tests) |
|
| 테스트 | Vitest 4 (jsdom, 141 tests) |
|
||||||
| 배포 | Docker Compose + standalone Next.js 빌드 |
|
| 배포 | Docker Compose + standalone Next.js 빌드 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -339,14 +451,20 @@ src/
|
|||||||
│ ├── page.tsx # 메인 (녹음/요약)
|
│ ├── page.tsx # 메인 (녹음/요약)
|
||||||
│ └── layout.tsx
|
│ └── layout.tsx
|
||||||
├── lib/
|
├── lib/
|
||||||
|
│ ├── providers/ # AI 프로바이더 어댑터
|
||||||
|
│ │ ├── index.ts # complete() 디스패치
|
||||||
|
│ │ ├── types.ts # ProviderSettings / CompletionResult
|
||||||
|
│ │ ├── presets.ts # 설정 UI용 프리셋 목록
|
||||||
|
│ │ ├── gemini.ts # Google Generative Language API
|
||||||
|
│ │ └── openai-compatible.ts # OpenAI Chat Completions 호환
|
||||||
│ ├── db.ts # Prisma 싱글톤 (pg adapter)
|
│ ├── db.ts # Prisma 싱글톤 (pg adapter)
|
||||||
│ ├── markdown.ts # marked + DOMPurify + Docs용 복사
|
│ ├── markdown.ts # marked + DOMPurify + Docs용 복사
|
||||||
│ ├── action-items.ts # - [ ] 휴리스틱 파서
|
│ ├── action-items.ts # - [ ] 휴리스틱 파서
|
||||||
│ ├── api-keys.ts # 서버: 요청 키 → env fallback
|
│ ├── api-keys.ts # 서버: 요청 설정 → env fallback
|
||||||
│ ├── api-key-storage.ts # 클라: LocalStorage + 마스킹
|
│ ├── api-key-storage.ts # 클라: LocalStorage + 마스킹 + 프로바이더 설정
|
||||||
│ ├── templates.ts # 템플릿 레지스트리 + 프롬프트 빌더
|
│ ├── templates.ts # 템플릿 레지스트리 + 프롬프트 빌더
|
||||||
│ ├── minutes-generator.ts # 최종 요약 생성
|
│ ├── minutes-generator.ts # 최종 요약 생성 (프로바이더 무관)
|
||||||
│ ├── live-summary.ts # 롤링 요약 생성
|
│ ├── live-summary.ts # 롤링 요약 생성 (프로바이더 무관)
|
||||||
│ ├── transcript-formatter.ts
|
│ ├── transcript-formatter.ts
|
||||||
│ ├── audio-validation.ts
|
│ ├── audio-validation.ts
|
||||||
│ ├── upload-handler.ts
|
│ ├── upload-handler.ts
|
||||||
@@ -409,7 +527,7 @@ prisma/
|
|||||||
- 같은 머신을 다른 사람과 공유하지 않는 것을 가정
|
- 같은 머신을 다른 사람과 공유하지 않는 것을 가정
|
||||||
- 회의 transcript / 회의록은 평문으로 PostgreSQL에 저장됨 (디스크 암호화는 호스트 OS에 위임)
|
- 회의 transcript / 회의록은 평문으로 PostgreSQL에 저장됨 (디스크 암호화는 호스트 OS에 위임)
|
||||||
|
|
||||||
### Gemini API 키
|
### API 키
|
||||||
- LocalStorage 저장 (브라우저 동일 출처 정책으로 보호)
|
- LocalStorage 저장 (브라우저 동일 출처 정책으로 보호)
|
||||||
- 서버 DB에 저장되지 않음
|
- 서버 DB에 저장되지 않음
|
||||||
- 요청 시점에만 body로 전송, 일회성 사용
|
- 요청 시점에만 body로 전송, 일회성 사용
|
||||||
@@ -417,10 +535,14 @@ prisma/
|
|||||||
|
|
||||||
### 외부 데이터 송출
|
### 외부 데이터 송출
|
||||||
- **Web Speech API** — Chrome이 마이크 오디오를 Google 서버로 전송하여 전사 (Chrome 자체 동작, 우리 서버 경유 X)
|
- **Web Speech API** — Chrome이 마이크 오디오를 Google 서버로 전송하여 전사 (Chrome 자체 동작, 우리 서버 경유 X)
|
||||||
- **Gemini API** — 사용자가 명시적으로 활성화한 경우에만 transcript를 Google 서버로 전송
|
- **AI 요약 API** — 사용자가 명시적으로 활성화한 경우에만 transcript를 선택한 프로바이더로 전송
|
||||||
|
- Gemini / OpenAI 직접 호출 → 해당 회사 서버 1곳
|
||||||
|
- OrcaRouter 등 중계 라우터 → **라우터 운영사 + 실제 모델 제공사** 양쪽
|
||||||
|
- 로컬 모델 (Ollama / LM Studio / vLLM) → **외부 전송 없음**
|
||||||
- **그 외** — 외부 호출 없음 (텔레메트리 / 분석 도구 미설치)
|
- **그 외** — 외부 호출 없음 (텔레메트리 / 분석 도구 미설치)
|
||||||
|
|
||||||
> ⚠️ **회사 회의 등 민감 정보가 외부 클라우드(Google)로 송출되는 점에 유의.** 사내 컴플라이언스 정책 확인 후 사용 권장.
|
> ⚠️ **회사 회의 등 민감 정보가 외부 클라우드로 송출되는 점에 유의.** 사내 컴플라이언스 정책 확인 후 사용 권장.
|
||||||
|
> 외부 전송이 곤란하다면 `/settings`에서 **로컬 모델**을 선택하세요.
|
||||||
|
|
||||||
### 권장 배포 방식
|
### 권장 배포 방식
|
||||||
|
|
||||||
@@ -446,8 +568,11 @@ prisma/
|
|||||||
|
|
||||||
| 제약 | 설명 | 대응 |
|
| 제약 | 설명 | 대응 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **Chrome 전용** | Web Speech API는 비표준 — Safari / Firefox는 제한적 | Phase 4에서 서버 사이드 전사 검토 |
|
| **Chrome 전용** | Web Speech API는 비표준 — Safari / Firefox는 제한적 | 서버 사이드 STT 전환 예정 |
|
||||||
| **화자 구분 없음** | 의도적 비지원 (Non-goal) | — |
|
| **Web Speech 포착률 낮음** | 원거리·다인 대면 회의 실측 10% 안팎 | 미리보기로 강등, 종료 후 **서버 STT 재전사**로 해결 |
|
||||||
|
| **Gemini는 webm 전사 불가** | Gemini가 받는 오디오 형식에 webm이 없음 | 녹음 전사는 OpenAI 호환 프로바이더 사용 |
|
||||||
|
| **긴 회의 전사 상한** | Whisper 25MB(≈1시간 40분) / Gemini inline 14MB(≈1시간) | 초과 시 오류 안내. 분할 전사는 후속 |
|
||||||
|
| **화자 구분 없음** | 미구현 (PR #16 / #17 검토 중) | — |
|
||||||
| **Gemini 무료 등급 한도** | 15 RPM / 1000 RPD | 한도 초과 시 자동 쿨다운, 단순 변환 폴백 |
|
| **Gemini 무료 등급 한도** | 15 RPM / 1000 RPD | 한도 초과 시 자동 쿨다운, 단순 변환 폴백 |
|
||||||
| **단일 사용자** | 인증 없음, 데이터 격리 없음 | 1인 1인스턴스로 운용 |
|
| **단일 사용자** | 인증 없음, 데이터 격리 없음 | 1인 1인스턴스로 운용 |
|
||||||
| **마크다운 검색** | PostgreSQL `ILIKE` (수천 건 이상에서 느려질 수 있음) | 필요 시 `tsvector` 인덱스 추가 |
|
| **마크다운 검색** | PostgreSQL `ILIKE` (수천 건 이상에서 느려질 수 있음) | 필요 시 `tsvector` 인덱스 추가 |
|
||||||
|
|||||||
+70
-4
@@ -48,9 +48,9 @@
|
|||||||
|
|
||||||
| 데이터 | 저장 위치 | 외부 송출 |
|
| 데이터 | 저장 위치 | 외부 송출 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 회의 transcript / 회의록 | PostgreSQL (`meetings.markdownMinutes`, `rawTranscript`) | Gemini AI 요약 활성화 시 transcript가 Google로 전송 |
|
| 회의 transcript / 회의록 | PostgreSQL (`meetings.markdownMinutes`, `rawTranscript`) | AI 요약 활성화 시 transcript가 **선택한 프로바이더**로 전송 |
|
||||||
| 액션 아이템 | PostgreSQL (`action_items`) | 외부 송출 없음 |
|
| 액션 아이템 | PostgreSQL (`action_items`) | 외부 송출 없음 |
|
||||||
| Gemini API 키 | 브라우저 LocalStorage 또는 `.env` | 요청 시 Google에만 전송 |
|
| API 키 | 브라우저 LocalStorage 또는 `.env` | 요청 시 선택한 프로바이더에만 전송 |
|
||||||
| 음성 데이터 | 메모리 (실시간), `/app/uploads` (파일 업로드) | **Chrome Web Speech API → Google 서버** ⚠️ |
|
| 음성 데이터 | 메모리 (실시간), `/app/uploads` (파일 업로드) | **Chrome Web Speech API → Google 서버** ⚠️ |
|
||||||
|
|
||||||
### ⚠️ 주의: Web Speech API의 음성 외부 전송
|
### ⚠️ 주의: Web Speech API의 음성 외부 전송
|
||||||
@@ -63,6 +63,51 @@ Chrome의 `SpeechRecognition` API는 **음성 데이터를 Google 서버로 전
|
|||||||
|
|
||||||
이 경우 **파일 업로드 탭**도 같은 한계가 있으므로(현재 업로드 후 전사는 미구현, Phase 4에서 자체 STT 검토 예정), 이 도구의 사용을 보류하는 것을 권장합니다.
|
이 경우 **파일 업로드 탭**도 같은 한계가 있으므로(현재 업로드 후 전사는 미구현, Phase 4에서 자체 STT 검토 예정), 이 도구의 사용을 보류하는 것을 권장합니다.
|
||||||
|
|
||||||
|
### 🔴 주의: 서버 전사(STT)는 회의 **원음**을 외부로 보냅니다
|
||||||
|
|
||||||
|
"원본 오디오로 다시 전사" 또는 파일 업로드 전사를 실행하면, 회의 오디오 파일이
|
||||||
|
**통째로** 선택한 프로바이더 서버로 전송됩니다.
|
||||||
|
|
||||||
|
이것은 이 앱에서 가장 민감도가 높은 데이터 흐름입니다. 전사 텍스트는 Web Speech가
|
||||||
|
대부분 놓치지만, **오디오에는 회의에서 오간 모든 말과 목소리가 그대로 담겨 있습니다.**
|
||||||
|
|
||||||
|
| 프로바이더 | 오디오가 가는 곳 |
|
||||||
|
|---|---|
|
||||||
|
| Gemini | Google 서버 |
|
||||||
|
| OpenAI | OpenAI 서버 |
|
||||||
|
| OrcaRouter 등 중계 | 중계사 → 실제 모델 제공사 (2단계) |
|
||||||
|
| **로컬 whisper** | **나가지 않음** (whisper.cpp / faster-whisper 등을 `LLM_BASE_URL`로 지정) |
|
||||||
|
|
||||||
|
**외부 전송이 곤란한 회의라면 로컬 whisper 서버를 쓰세요.** 설정 → 프로바이더에서
|
||||||
|
"로컬 모델"을 고르고 base URL을 로컬 whisper 엔드포인트로 지정하면 오디오가
|
||||||
|
머신 밖으로 나가지 않습니다.
|
||||||
|
|
||||||
|
전사는 사용자가 버튼을 눌러야만 실행됩니다. 녹음만으로는 오디오가 전송되지 않습니다.
|
||||||
|
|
||||||
|
### ⚠️ 주의: AI 프로바이더 선택에 따른 전송 경로
|
||||||
|
|
||||||
|
`/settings`에서 고른 프로바이더에 따라 **회의 전문이 지나가는 회사가 달라집니다.**
|
||||||
|
|
||||||
|
| 선택 | transcript를 보게 되는 주체 |
|
||||||
|
|---|---|
|
||||||
|
| Google Gemini (기본) | Google |
|
||||||
|
| OpenAI | OpenAI |
|
||||||
|
| OrcaRouter 등 중계 라우터 | **라우터 운영사 + 라우터가 고른 실제 모델 제공사** (2단계) |
|
||||||
|
| 로컬 모델 (Ollama / LM Studio / vLLM) | **없음** — 요청이 내 머신 밖으로 나가지 않음 |
|
||||||
|
|
||||||
|
- 중계 라우터는 요청을 대신 전달하는 구조상 **평문 프롬프트를 볼 수 있는 주체가 한 곳 늘어납니다.** 로깅·보관 정책은 각 서비스 약관을 직접 확인하세요.
|
||||||
|
- 사내 컴플라이언스가 외부 전송을 제한한다면 **로컬 모델** 프리셋을 사용하세요.
|
||||||
|
|
||||||
|
### base URL은 서버가 대신 호출합니다 (SSRF 주의)
|
||||||
|
|
||||||
|
OpenAI 호환 프리셋의 base URL은 브라우저가 아니라 **Next.js Route Handler(서버)** 가 fetch 합니다.
|
||||||
|
|
||||||
|
- `http` / `https` 스킴만 허용합니다 (`normalizeBaseUrl`).
|
||||||
|
- 그 외 호스트 제한은 없습니다. 이 앱은 `127.0.0.1` 단일 사용자 실행을 전제로 하므로 의도된 설계이지만,
|
||||||
|
**앱을 LAN이나 인터넷에 노출하면 요청자가 서버 내부망 주소를 base URL로 넣어 스캔할 수 있습니다.**
|
||||||
|
- 노출 배포가 필요하다면 리버스 프록시에서 egress를 제한하거나, `LLM_BASE_URL`을 환경변수로 고정하고
|
||||||
|
요청 body의 `baseUrl`을 무시하도록 수정하세요.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ 구현된 보안 조치
|
## ✅ 구현된 보안 조치
|
||||||
@@ -91,9 +136,10 @@ Chrome의 `SpeechRecognition` API는 **음성 데이터를 Google 서버로 전
|
|||||||
|
|
||||||
### API 키 보호
|
### API 키 보호
|
||||||
- `/api/settings/status`: 키 존재 여부만 응답 (값 노출 X)
|
- `/api/settings/status`: 키 존재 여부만 응답 (값 노출 X)
|
||||||
- `/api/settings/test`: 401/403/429 분류, detail 200자로 제한
|
- `/api/settings/test`: 401/403/429 분류, 업스트림 응답 본문을 그대로 돌려주지 않음
|
||||||
- `getStoredApiKey` GET 요청에 절대 포함 안 함 (body 전송만)
|
- 키는 GET 요청에 절대 포함 안 함 (body 전송만)
|
||||||
- `maskApiKey()`: UI에 표시 시 `AIza••••XYZ12` 형태로 마스킹
|
- `maskApiKey()`: UI에 표시 시 `AIza••••XYZ12` 형태로 마스킹
|
||||||
|
- Gemini는 `x-goog-api-key` 헤더, OpenAI 호환은 `Authorization: Bearer` 헤더 — **키를 URL에 넣지 않음** (로그/리퍼러 유출 방지)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -107,6 +153,26 @@ Chrome의 `SpeechRecognition` API는 **음성 데이터를 Google 서버로 전
|
|||||||
- 추가 보호가 필요하면 reverse proxy(nginx, Caddy)에 Basic Auth 추가
|
- 추가 보호가 필요하면 reverse proxy(nginx, Caddy)에 Basic Auth 추가
|
||||||
- 회사 환경에서는 SSO 통합이 필요하지만 현재 비목표
|
- 회사 환경에서는 SSO 통합이 필요하지만 현재 비목표
|
||||||
|
|
||||||
|
### MEDIUM — 회의 원음이 디스크에 평문으로 남습니다
|
||||||
|
|
||||||
|
**현황**: 실시간 녹음을 시작하면 회의 오디오 원본이 `uploads/recordings/`에 저장됩니다
|
||||||
|
(Docker에서는 `uploads` 볼륨). 암호화하지 않은 평문 오디오이며, 회의에서 오간
|
||||||
|
말이 그대로 들어 있습니다. **전사 텍스트보다 민감도가 높습니다** — 텍스트는
|
||||||
|
Web Speech가 대부분 놓치지만 오디오에는 전부 남습니다.
|
||||||
|
|
||||||
|
이 저장은 의도된 설계입니다. Web Speech의 포착률이 낮아(실측 10% 안팎) 원음을
|
||||||
|
남기지 않으면 놓친 발화를 복구할 방법이 없습니다.
|
||||||
|
|
||||||
|
**완화**:
|
||||||
|
- 디스크 암호화(FileVault / BitLocker)가 켜진 머신에서 운용
|
||||||
|
- 불필요해진 녹음은 `uploads/recordings/`에서 직접 삭제
|
||||||
|
- `RECORDINGS_DIR` 환경변수로 저장 위치를 별도 암호화 볼륨으로 지정 가능
|
||||||
|
- 녹음 파일 자동 만료/삭제는 **미구현** — 수동 관리가 필요합니다
|
||||||
|
|
||||||
|
**세션 ID 추측**: 녹음 조회·추가 API는 128비트 난수 ID만으로 접근을 가릅니다.
|
||||||
|
인증이 없으므로, 같은 머신에서 앱에 접근 가능한 주체는 ID를 알면 오디오를
|
||||||
|
내려받을 수 있습니다. 단일 사용자 로컬 실행 전제입니다.
|
||||||
|
|
||||||
### MEDIUM — 파일 업로드 매직 바이트 미검증
|
### MEDIUM — 파일 업로드 매직 바이트 미검증
|
||||||
**현황**: `audio-validation.ts`는 `file.type`(클라이언트 제공)과 파일명 확장자만 검사. 매직 바이트 검증 없음.
|
**현황**: `audio-validation.ts`는 `file.type`(클라이언트 제공)과 파일명 확장자만 검사. 매직 바이트 검증 없음.
|
||||||
|
|
||||||
|
|||||||
+73
-1
@@ -155,6 +155,79 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🔴 실사용 진단 (2026-09-08 대면 회의)
|
||||||
|
|
||||||
|
46분 대면 회의를 실제로 녹음해 본 결과다. 로드맵의 우선순위를 바꾼 근거.
|
||||||
|
|
||||||
|
| 지표 | 값 |
|
||||||
|
|---|---|
|
||||||
|
| 회의 길이 | 46분 6초 |
|
||||||
|
| 총 청크 | 122개 |
|
||||||
|
| 총 어절 | **489** |
|
||||||
|
| 분당 어절 | **10.6** (한국어 대화 통상 100~150) |
|
||||||
|
| 추정 포착률 | **7~10%** |
|
||||||
|
| 30초 이상 공백 | 26곳 / 합계 26분 29초 (회의의 57%) |
|
||||||
|
| 빈 텍스트 청크 | 19개 (16%) |
|
||||||
|
|
||||||
|
**빈 청크 19개가 결정적 증거다.** Chrome이 `isFinal: true`에 `transcript: ""`를
|
||||||
|
준 경우로, "소리는 감지했으나 인식 실패"를 뜻한다. 마이크 입력 문제가 아니라
|
||||||
|
Web Speech가 원거리·다인 한국어를 못 알아듣고 버린 것이다.
|
||||||
|
|
||||||
|
**결론: Web Speech API는 회의 전사에 쓸 수 없다.** 재시작 갭을 메우고 interim을
|
||||||
|
flush해서 7%를 20~30%로는 올려도 90%로는 못 간다. 구현 결함이 아니라 엔진의 한계다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 P0 — 유실을 멈춘다
|
||||||
|
|
||||||
|
- [x] **오디오 원본 녹음** — `MediaRecorder`로 회의 원음을 파일로 보관
|
||||||
|
- [x] 15초 조각 단위로 서버에 append → 탭/서버가 죽어도 그때까지는 남음
|
||||||
|
- [x] 서버 실패해도 녹음 계속 + 브라우저 사본 내려받기
|
||||||
|
- [x] 0바이트일 때 "보관됨"이라고 하지 않음
|
||||||
|
- [x] 캡처 제약에서 노이즈 억제·에코 제거 해제 (원거리 화자 보존)
|
||||||
|
- [x] 회의록 저장 시 `audioFileName`/`audioMimeType`/`audioDuration` 연결
|
||||||
|
- [x] **서버 STT 파이프라인** — 녹음 파일 → Whisper / Gemini audio → transcript
|
||||||
|
- [x] `/api/upload`를 막다른 길에서 본선 경로로 승격 (업로드 → recordingId → 전사)
|
||||||
|
- [x] 프로바이더 레이어에 `/v1/audio/transcriptions` 추가 (#12 구조 확장)
|
||||||
|
- [x] Gemini 오디오 inline 입력 (webm 미지원은 명시적으로 안내)
|
||||||
|
- [x] `verbose_json`으로 실제 오디오 타임스탬프 확보
|
||||||
|
- [x] 참석자·용어 힌트를 전사 프롬프트로 전달
|
||||||
|
- [x] 녹음 비트레이트 32kbps로 하향 — 46분 회의가 11MB로 API 상한 안에 들어옴
|
||||||
|
- [ ] 같은 오디오로 Web Speech vs STT 포착률 실측 비교 ← **다음 회의에서**
|
||||||
|
- [ ] 상한 초과 회의 분할 전사 (Whisper 25MB / Gemini inline 14MB)
|
||||||
|
- [ ] Gemini Files API 경로 (큰 파일 + webm 우회)
|
||||||
|
- [ ] Web Speech를 "실시간 미리보기"로 명시적 강등 (현재는 둘 다 노출)
|
||||||
|
- [x] **Web Speech 유실 지점 정리**
|
||||||
|
- [x] 빈 확정 결과를 청크에서 빼고 "인식 실패 N건"으로 노출 (실측 19건)
|
||||||
|
- [x] 세션 재시작 시 미확정 발화 보존 — Chrome이 버리던 것을 직접 확정
|
||||||
|
- [x] 중지 시 마지막 발화 보존 — `stopListening()`이 최종 청크를 돌려줌
|
||||||
|
- [x] 실시간 텍스트 패널을 "미리보기"로 강등
|
||||||
|
- [x] **타임스탬프 실측화** — `startTime: now - 2` 하드코딩 제거
|
||||||
|
- [x] interim이 처음 뜬 시각을 발화 시작으로 사용
|
||||||
|
- [x] `startRecording()`이 캡처 시작 시각을 돌려주고 인식기가 같은 시간축 사용
|
||||||
|
- [x] **10만 자 하드 실패 → 분할 요약**
|
||||||
|
- [x] 구간 압축(map) → 기존 템플릿 경로로 회의록(reduce)
|
||||||
|
- [x] 상한 10만 → 50만 자 (약 14시간 분량)
|
||||||
|
- [x] 한 구간이라도 실패하면 전체 실패 — 조용히 빠진 회의록을 만들지 않음
|
||||||
|
|
||||||
|
## 🎯 P1 — 입력단 개선
|
||||||
|
|
||||||
|
- [ ] 대면 회의용 USB 전방향 마이크 도입 (코드로 못 푸는 물리적 한계)
|
||||||
|
- [ ] 원격 회의용 `echoCancellation` 재활성 경로 분리
|
||||||
|
|
||||||
|
## 🎯 P2 — 화자 분리 재설계
|
||||||
|
|
||||||
|
- [ ] **PR #17은 현재 형태로 머지 보류** — 실사용에서 46분 회의를 화자 1명으로
|
||||||
|
판정했고, 5개 청크는 배정조차 실패했다. `assignSpeakers()`가 쓰는 시간축이
|
||||||
|
가짜(`now - 2`)라 diarization의 실제 타임라인과 맞지 않는다
|
||||||
|
- [ ] 서버 STT 도입 후 재설계 — Whisper의 단어 단위 실제 타임스탬프 위에서
|
||||||
|
diarization을 돌리면 그때 비로소 겹침 기반 배정이 의미를 갖는다
|
||||||
|
- [ ] #17의 sherpa-onnx 래퍼·모델 셋업 스크립트는 그때 재활용
|
||||||
|
- [ ] 참석자 수 힌트는 유지 (#17 실측에서 효과 확인됨)
|
||||||
|
- [ ] PR #16(dual-stream)은 원격 회의 전용으로 분리 검증
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🎯 Phase 3 — 참석자 + 태그 (3~5일)
|
## 🎯 Phase 3 — 참석자 + 태그 (3~5일)
|
||||||
|
|
||||||
### Issues
|
### Issues
|
||||||
@@ -199,7 +272,6 @@
|
|||||||
|
|
||||||
다음 기능은 개인 사용 목적에 부합하지 않아 **의도적으로 범위 밖**:
|
다음 기능은 개인 사용 목적에 부합하지 않아 **의도적으로 범위 밖**:
|
||||||
|
|
||||||
- **화자 구분 (Speaker Diarization)** — 유료 API 비용/복잡도 대비 개인용에 과함
|
|
||||||
- **실시간 공동 편집 (CRDT)** — Yjs/Liveblocks 도입 복잡도 대비 이득 없음
|
- **실시간 공동 편집 (CRDT)** — Yjs/Liveblocks 도입 복잡도 대비 이득 없음
|
||||||
- **권한 관리 / 워크스페이스** — 단일 사용자 가정
|
- **권한 관리 / 워크스페이스** — 단일 사용자 가정
|
||||||
- **모바일 네이티브 앱** — 웹 PWA로 충분
|
- **모바일 네이티브 앱** — 웹 PWA로 충분
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import {
|
|||||||
setStoredApiKey,
|
setStoredApiKey,
|
||||||
clearStoredApiKey,
|
clearStoredApiKey,
|
||||||
maskApiKey,
|
maskApiKey,
|
||||||
|
getStoredProviderConfig,
|
||||||
|
setStoredProviderConfig,
|
||||||
|
clearStoredProviderConfig,
|
||||||
|
getProviderRequestPayload,
|
||||||
} from '@/lib/api-key-storage'
|
} from '@/lib/api-key-storage'
|
||||||
|
|
||||||
describe('LocalStorage api key helpers', () => {
|
describe('LocalStorage api key helpers', () => {
|
||||||
@@ -48,3 +52,104 @@ describe('maskApiKey', () => {
|
|||||||
expect(maskApiKey('123456789')).toBe('1234••••6789')
|
expect(maskApiKey('123456789')).toBe('1234••••6789')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('프로바이더 설정 저장', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('저장 후 동일한 설정을 돌려준다', () => {
|
||||||
|
setStoredProviderConfig({
|
||||||
|
presetId: 'orcarouter',
|
||||||
|
apiKey: 'sk-test',
|
||||||
|
baseUrl: 'https://api.orcarouter.ai/v1',
|
||||||
|
model: 'google/gemini-2.5-flash-lite',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getStoredProviderConfig()).toEqual({
|
||||||
|
presetId: 'orcarouter',
|
||||||
|
apiKey: 'sk-test',
|
||||||
|
baseUrl: 'https://api.orcarouter.ai/v1',
|
||||||
|
model: 'google/gemini-2.5-flash-lite',
|
||||||
|
// 예전에 저장된 설정에는 sttModel이 없다. 빈 값으로 채워 마이그레이션 없이 읽힌다.
|
||||||
|
sttModel: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('미설정이거나 깨진 값이면 null', () => {
|
||||||
|
expect(getStoredProviderConfig()).toBeNull()
|
||||||
|
window.localStorage.setItem('meeting-minutes:llm-provider', '{not json')
|
||||||
|
expect(getStoredProviderConfig()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clear 시 삭제된다', () => {
|
||||||
|
setStoredProviderConfig({
|
||||||
|
presetId: 'openai',
|
||||||
|
apiKey: 'sk',
|
||||||
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
})
|
||||||
|
clearStoredProviderConfig()
|
||||||
|
expect(getStoredProviderConfig()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getProviderRequestPayload', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('프로바이더 설정이 없으면 기존 Gemini 키를 그대로 사용한다', () => {
|
||||||
|
setStoredApiKey('AIza-legacy')
|
||||||
|
|
||||||
|
expect(getProviderRequestPayload()).toEqual({
|
||||||
|
provider: 'gemini',
|
||||||
|
apiKey: 'AIza-legacy',
|
||||||
|
baseUrl: '',
|
||||||
|
model: '',
|
||||||
|
sttModel: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('아무것도 없으면 빈 gemini 설정을 돌려준다', () => {
|
||||||
|
expect(getProviderRequestPayload()).toEqual({
|
||||||
|
provider: 'gemini',
|
||||||
|
apiKey: '',
|
||||||
|
baseUrl: '',
|
||||||
|
model: '',
|
||||||
|
sttModel: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('프리셋 id로부터 provider를 결정한다', () => {
|
||||||
|
setStoredProviderConfig({
|
||||||
|
presetId: 'local',
|
||||||
|
apiKey: '',
|
||||||
|
baseUrl: 'http://localhost:11434/v1',
|
||||||
|
model: 'llama3.1',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getProviderRequestPayload()).toEqual({
|
||||||
|
provider: 'openai-compatible',
|
||||||
|
apiKey: '',
|
||||||
|
baseUrl: 'http://localhost:11434/v1',
|
||||||
|
model: 'llama3.1',
|
||||||
|
sttModel: '',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gemini 프리셋에서 키를 비워두면 기존 키로 폴백한다', () => {
|
||||||
|
setStoredApiKey('AIza-legacy')
|
||||||
|
setStoredProviderConfig({
|
||||||
|
presetId: 'gemini',
|
||||||
|
apiKey: '',
|
||||||
|
baseUrl: '',
|
||||||
|
model: 'gemini-2.5-pro',
|
||||||
|
})
|
||||||
|
|
||||||
|
const payload = getProviderRequestPayload()
|
||||||
|
expect(payload.provider).toBe('gemini')
|
||||||
|
expect(payload.apiKey).toBe('AIza-legacy')
|
||||||
|
expect(payload.model).toBe('gemini-2.5-pro')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||||
import { resolveGeminiApiKey, isEnvKeyConfigured } from '@/lib/api-keys'
|
import {
|
||||||
|
resolveGeminiApiKey,
|
||||||
|
isEnvKeyConfigured,
|
||||||
|
isEnvProviderConfigured,
|
||||||
|
resolveProviderSettings,
|
||||||
|
} from '@/lib/api-keys'
|
||||||
|
|
||||||
describe('resolveGeminiApiKey', () => {
|
describe('resolveGeminiApiKey', () => {
|
||||||
const originalEnv = process.env.GEMINI_API_KEY
|
const originalEnv = process.env.GEMINI_API_KEY
|
||||||
@@ -70,3 +75,139 @@ describe('isEnvKeyConfigured', () => {
|
|||||||
expect(isEnvKeyConfigured()).toBe(false)
|
expect(isEnvKeyConfigured()).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('resolveProviderSettings', () => {
|
||||||
|
const ENV_KEYS = [
|
||||||
|
'GEMINI_API_KEY',
|
||||||
|
'LLM_PROVIDER',
|
||||||
|
'LLM_BASE_URL',
|
||||||
|
'LLM_API_KEY',
|
||||||
|
'LLM_MODEL',
|
||||||
|
] as const
|
||||||
|
const saved: Record<string, string | undefined> = {}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const key of ENV_KEYS) {
|
||||||
|
saved[key] = process.env[key]
|
||||||
|
delete process.env[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key of ENV_KEYS) {
|
||||||
|
if (saved[key] === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = saved[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('provider를 지정하지 않으면 gemini로 간주한다', () => {
|
||||||
|
expect(resolveProviderSettings({ apiKey: 'req-key' })).toEqual({
|
||||||
|
provider: 'gemini',
|
||||||
|
apiKey: 'req-key',
|
||||||
|
model: undefined,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gemini인데 키가 아무데도 없으면 null (단순 변환 폴백)', () => {
|
||||||
|
expect(resolveProviderSettings({})).toBeNull()
|
||||||
|
expect(resolveProviderSettings(null)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gemini 키는 요청 → GEMINI_API_KEY 순으로 해석한다', () => {
|
||||||
|
process.env.GEMINI_API_KEY = 'env-key'
|
||||||
|
expect(resolveProviderSettings({ apiKey: 'req-key' })?.apiKey).toBe('req-key')
|
||||||
|
expect(resolveProviderSettings({})?.apiKey).toBe('env-key')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('openai-compatible은 baseUrl과 model이 모두 있어야 한다', () => {
|
||||||
|
const base = { provider: 'openai-compatible', apiKey: 'sk-x' }
|
||||||
|
expect(resolveProviderSettings(base)).toBeNull()
|
||||||
|
expect(
|
||||||
|
resolveProviderSettings({ ...base, baseUrl: 'https://a.example/v1' }),
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
resolveProviderSettings({ ...base, model: 'gpt-4o-mini' }),
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
resolveProviderSettings({
|
||||||
|
...base,
|
||||||
|
baseUrl: 'https://a.example/v1',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: 'openai-compatible',
|
||||||
|
apiKey: 'sk-x',
|
||||||
|
baseUrl: 'https://a.example/v1',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('openai-compatible은 키가 비어도 허용한다 (로컬 모델 서버)', () => {
|
||||||
|
const settings = resolveProviderSettings({
|
||||||
|
provider: 'openai-compatible',
|
||||||
|
apiKey: '',
|
||||||
|
baseUrl: 'http://localhost:11434/v1',
|
||||||
|
model: 'llama3.1',
|
||||||
|
})
|
||||||
|
expect(settings?.apiKey).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('LLM_* 환경변수만으로도 설정된다', () => {
|
||||||
|
process.env.LLM_PROVIDER = 'openai-compatible'
|
||||||
|
process.env.LLM_BASE_URL = 'https://api.orcarouter.ai/v1'
|
||||||
|
process.env.LLM_MODEL = 'google/gemini-2.5-flash-lite'
|
||||||
|
process.env.LLM_API_KEY = 'sk-env'
|
||||||
|
|
||||||
|
expect(resolveProviderSettings({})).toEqual({
|
||||||
|
provider: 'openai-compatible',
|
||||||
|
apiKey: 'sk-env',
|
||||||
|
baseUrl: 'https://api.orcarouter.ai/v1',
|
||||||
|
model: 'google/gemini-2.5-flash-lite',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('알 수 없는 provider 값은 무시하고 기본값으로 떨어진다', () => {
|
||||||
|
process.env.GEMINI_API_KEY = 'env-key'
|
||||||
|
expect(resolveProviderSettings({ provider: 'anthropic' })?.provider).toBe(
|
||||||
|
'gemini',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isEnvProviderConfigured', () => {
|
||||||
|
const ENV_KEYS = ['GEMINI_API_KEY', 'LLM_PROVIDER', 'LLM_BASE_URL', 'LLM_MODEL', 'LLM_API_KEY'] as const
|
||||||
|
const saved: Record<string, string | undefined> = {}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const key of ENV_KEYS) {
|
||||||
|
saved[key] = process.env[key]
|
||||||
|
delete process.env[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key of ENV_KEYS) {
|
||||||
|
if (saved[key] === undefined) delete process.env[key]
|
||||||
|
else process.env[key] = saved[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('아무 것도 없으면 false', () => {
|
||||||
|
expect(isEnvProviderConfigured()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('GEMINI_API_KEY만 있어도 true', () => {
|
||||||
|
process.env.GEMINI_API_KEY = 'k'
|
||||||
|
expect(isEnvProviderConfigured()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('openai-compatible은 base URL과 모델까지 있어야 true', () => {
|
||||||
|
process.env.LLM_PROVIDER = 'openai-compatible'
|
||||||
|
process.env.LLM_API_KEY = 'k'
|
||||||
|
expect(isEnvProviderConfigured()).toBe(false)
|
||||||
|
|
||||||
|
process.env.LLM_BASE_URL = 'https://a.example/v1'
|
||||||
|
process.env.LLM_MODEL = 'm'
|
||||||
|
expect(isEnvProviderConfigured()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import { generateLiveSummary } from '@/lib/live-summary'
|
import {
|
||||||
|
generateLiveSummary,
|
||||||
|
planLiveSummaryRequest,
|
||||||
|
} from '@/lib/live-summary'
|
||||||
|
|
||||||
describe('generateLiveSummary', () => {
|
describe('generateLiveSummary', () => {
|
||||||
it('Gemini API 응답을 받아 중간 요약 마크다운을 반환한다', async () => {
|
it('Gemini API 응답을 받아 중간 요약 마크다운을 반환한다', async () => {
|
||||||
@@ -128,3 +131,158 @@ describe('generateLiveSummary', () => {
|
|||||||
expect(init.headers['x-goog-api-key']).toBe('secret-key')
|
expect(init.headers['x-goog-api-key']).toBe('secret-key')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('generateLiveSummary — openai-compatible', () => {
|
||||||
|
it('OpenAI 호환 엔드포인트로 중간 요약을 만든다', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
choices: [{ message: { content: '## 요약\n진행 중' } }],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await generateLiveSummary('회의 내용', {
|
||||||
|
provider: {
|
||||||
|
provider: 'openai-compatible',
|
||||||
|
apiKey: 'sk-test',
|
||||||
|
baseUrl: 'http://localhost:11434/v1',
|
||||||
|
model: 'llama3.1',
|
||||||
|
},
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
expect(mockFetch.mock.calls[0][0]).toBe(
|
||||||
|
'http://localhost:11434/v1/chat/completions',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('planLiveSummaryRequest', () => {
|
||||||
|
const base = {
|
||||||
|
totalChunks: 50,
|
||||||
|
lastSummarizedIndex: 30,
|
||||||
|
incrementsSinceFull: 3,
|
||||||
|
fullRefreshEvery: 20,
|
||||||
|
hasPreviousSummary: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('평상시에는 직전 지점부터 증분으로 보낸다', () => {
|
||||||
|
expect(planLiveSummaryRequest(base)).toEqual({
|
||||||
|
mode: 'incremental',
|
||||||
|
startIndex: 30,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('첫 호출은 전체를 보낸다', () => {
|
||||||
|
expect(
|
||||||
|
planLiveSummaryRequest({ ...base, lastSummarizedIndex: 0 }),
|
||||||
|
).toEqual({ mode: 'full', startIndex: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('갱신할 직전 요약이 없으면 전체를 보낸다', () => {
|
||||||
|
expect(
|
||||||
|
planLiveSummaryRequest({ ...base, hasPreviousSummary: false }),
|
||||||
|
).toEqual({ mode: 'full', startIndex: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('증분이 누적되면 전체 재요약으로 오차를 끊는다', () => {
|
||||||
|
expect(
|
||||||
|
planLiveSummaryRequest({ ...base, incrementsSinceFull: 20 }),
|
||||||
|
).toEqual({ mode: 'full', startIndex: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fullRefreshEvery=0이면 전체 재요약을 하지 않는다', () => {
|
||||||
|
expect(
|
||||||
|
planLiveSummaryRequest({
|
||||||
|
...base,
|
||||||
|
fullRefreshEvery: 0,
|
||||||
|
incrementsSinceFull: 999,
|
||||||
|
}).mode,
|
||||||
|
).toBe('incremental')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('전사가 초기화되어 인덱스가 범위를 벗어나면 전체를 보낸다', () => {
|
||||||
|
expect(
|
||||||
|
planLiveSummaryRequest({ ...base, totalChunks: 5 }),
|
||||||
|
).toEqual({ mode: 'full', startIndex: 0 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateLiveSummary — 증분 모드', () => {
|
||||||
|
function mockOk(text = '## 요약\n갱신됨') {
|
||||||
|
return vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
candidates: [{ content: { parts: [{ text }] } }],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function sentPrompt(mockFetch: ReturnType<typeof vi.fn>): string {
|
||||||
|
const body = JSON.parse(mockFetch.mock.calls[0][1].body)
|
||||||
|
return body.contents[0].parts[0].text
|
||||||
|
}
|
||||||
|
|
||||||
|
it('previousSummary가 있으면 요약과 신규 발화를 나눠 전달한다', async () => {
|
||||||
|
const mockFetch = mockOk()
|
||||||
|
|
||||||
|
await generateLiveSummary('새로 나온 이야기', {
|
||||||
|
apiKey: 'k',
|
||||||
|
previousSummary: '## 요약\n이전까지의 내용',
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
const prompt = sentPrompt(mockFetch)
|
||||||
|
expect(prompt).toContain('[지금까지의 요약]')
|
||||||
|
expect(prompt).toContain('이전까지의 내용')
|
||||||
|
expect(prompt).toContain('[새로 추가된 발화]')
|
||||||
|
expect(prompt).toContain('새로 나온 이야기')
|
||||||
|
expect(prompt).toContain('갱신')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('previousSummary가 없으면 기존 전체 요약 형식을 유지한다', async () => {
|
||||||
|
const mockFetch = mockOk()
|
||||||
|
|
||||||
|
await generateLiveSummary('전체 전사', { apiKey: 'k', fetchFn: mockFetch })
|
||||||
|
|
||||||
|
const prompt = sentPrompt(mockFetch)
|
||||||
|
expect(prompt).toContain('음성 인식 텍스트:')
|
||||||
|
expect(prompt).not.toContain('[지금까지의 요약]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('빈 문자열 previousSummary는 증분으로 취급하지 않는다', async () => {
|
||||||
|
const mockFetch = mockOk()
|
||||||
|
|
||||||
|
await generateLiveSummary('전체 전사', {
|
||||||
|
apiKey: 'k',
|
||||||
|
previousSummary: ' ',
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(sentPrompt(mockFetch)).not.toContain('[지금까지의 요약]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('긴 회의에서 증분 프롬프트가 전체 프롬프트보다 짧다', async () => {
|
||||||
|
const longTranscript = '회의 발화 한 줄입니다.\n'.repeat(500)
|
||||||
|
|
||||||
|
const fullFetch = mockOk()
|
||||||
|
await generateLiveSummary(longTranscript, {
|
||||||
|
apiKey: 'k',
|
||||||
|
fetchFn: fullFetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
const incFetch = mockOk()
|
||||||
|
await generateLiveSummary('마지막 30초에 나온 이야기', {
|
||||||
|
apiKey: 'k',
|
||||||
|
previousSummary: '## 요약\n지금까지의 요약 본문',
|
||||||
|
fetchFn: incFetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(sentPrompt(incFetch).length).toBeLessThan(
|
||||||
|
sentPrompt(fullFetch).length / 5,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
CONDENSE_WINDOW_CHARS,
|
||||||
|
MAX_TRANSCRIPT_CHARS,
|
||||||
|
SINGLE_PASS_CHAR_LIMIT,
|
||||||
|
condenseTranscript,
|
||||||
|
planTranscriptWindows,
|
||||||
|
} from '@/lib/long-transcript'
|
||||||
|
|
||||||
|
function okResponse(text: string) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
candidates: [{ content: { parts: [{ text }] } }],
|
||||||
|
}),
|
||||||
|
} as unknown as Response
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = { provider: 'gemini' as const, apiKey: 'AIza-test' }
|
||||||
|
|
||||||
|
describe('planTranscriptWindows', () => {
|
||||||
|
it('상한 안이면 통째로 하나', () => {
|
||||||
|
expect(planTranscriptWindows('짧은 전사문', 100)).toEqual(['짧은 전사문'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('빈 입력은 구간이 없다', () => {
|
||||||
|
expect(planTranscriptWindows(' ', 100)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('줄 경계에서 나눈다', () => {
|
||||||
|
const lines = ['[00:00] 가나다', '[00:05] 라마바', '[00:10] 사아자']
|
||||||
|
const windows = planTranscriptWindows(lines.join('\n'), 25)
|
||||||
|
|
||||||
|
expect(windows.length).toBeGreaterThan(1)
|
||||||
|
for (const w of windows) {
|
||||||
|
// 줄 중간에서 끊기지 않았다
|
||||||
|
for (const line of w.split('\n')) {
|
||||||
|
expect(lines).toContain(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('어떤 내용도 잃지 않는다', () => {
|
||||||
|
const lines = Array.from({ length: 500 }, (_, i) => `[00:${i % 60}] 발화 ${i}`)
|
||||||
|
const source = lines.join('\n')
|
||||||
|
|
||||||
|
const windows = planTranscriptWindows(source, 200)
|
||||||
|
|
||||||
|
expect(windows.join('\n')).toBe(source)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('상한을 넘는 구간은 없다', () => {
|
||||||
|
const source = Array.from({ length: 300 }, (_, i) => `줄 ${i}`).join('\n')
|
||||||
|
for (const w of planTranscriptWindows(source, 50)) {
|
||||||
|
expect(w.length).toBeLessThanOrEqual(50)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('한 줄이 통째로 상한보다 길면 그 줄만 강제로 자른다', () => {
|
||||||
|
const long = 'ㄱ'.repeat(250)
|
||||||
|
const windows = planTranscriptWindows(long, 100)
|
||||||
|
|
||||||
|
expect(windows).toHaveLength(3)
|
||||||
|
expect(windows.join('')).toBe(long)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maxChars가 0 이하면 거부한다', () => {
|
||||||
|
expect(() => planTranscriptWindows('가', 0)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('압축 창은 단일 통과 상한보다 작다', () => {
|
||||||
|
// 압축한 결과를 다시 한 번에 넣을 수 있어야 map-reduce가 성립한다.
|
||||||
|
expect(CONDENSE_WINDOW_CHARS).toBeLessThan(SINGLE_PASS_CHAR_LIMIT)
|
||||||
|
expect(SINGLE_PASS_CHAR_LIMIT).toBeLessThan(MAX_TRANSCRIPT_CHARS)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('condenseTranscript', () => {
|
||||||
|
it('구간마다 한 번씩 호출하고 결과를 이어 붙인다', async () => {
|
||||||
|
const fetchFn = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(okResponse('앞부분 정리'))
|
||||||
|
.mockResolvedValueOnce(okResponse('뒷부분 정리'))
|
||||||
|
|
||||||
|
const source = Array.from({ length: 40 }, (_, i) => `줄 ${i}`).join('\n')
|
||||||
|
const result = await condenseTranscript(source, {
|
||||||
|
provider: settings,
|
||||||
|
windowChars: 120,
|
||||||
|
fetchFn,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (!result.success) return
|
||||||
|
|
||||||
|
expect(fetchFn).toHaveBeenCalledTimes(2)
|
||||||
|
expect(result.windows).toBe(2)
|
||||||
|
expect(result.text).toContain('앞부분 정리')
|
||||||
|
expect(result.text).toContain('뒷부분 정리')
|
||||||
|
expect(result.text).toContain('구간 1/2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('프롬프트에 구간 위치를 알려 앞뒤를 지어내지 않게 한다', async () => {
|
||||||
|
const fetchFn = vi.fn().mockResolvedValue(okResponse('정리'))
|
||||||
|
const source = Array.from({ length: 40 }, (_, i) => `줄 ${i}`).join('\n')
|
||||||
|
|
||||||
|
await condenseTranscript(source, {
|
||||||
|
provider: settings,
|
||||||
|
windowChars: 120,
|
||||||
|
fetchFn,
|
||||||
|
})
|
||||||
|
|
||||||
|
const body = JSON.parse(
|
||||||
|
(fetchFn.mock.calls[0][1] as RequestInit).body as string,
|
||||||
|
)
|
||||||
|
const prompt = body.contents[0].parts[0].text
|
||||||
|
expect(prompt).toContain('1/2 구간')
|
||||||
|
expect(prompt).toContain('추측해 채우지 않습니다')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('한 구간이라도 실패하면 전체를 실패로 돌린다', async () => {
|
||||||
|
// 일부만 빠진 압축본으로 회의록을 만들면 사용자는 무엇이 빠졌는지 모른다.
|
||||||
|
const fetchFn = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(okResponse('앞부분'))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({}),
|
||||||
|
} as unknown as Response)
|
||||||
|
|
||||||
|
const source = Array.from({ length: 40 }, (_, i) => `줄 ${i}`).join('\n')
|
||||||
|
const result = await condenseTranscript(source, {
|
||||||
|
provider: settings,
|
||||||
|
windowChars: 120,
|
||||||
|
fetchFn,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (result.success) return
|
||||||
|
expect(result.error).toContain('2/2 구간')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('빈 응답도 실패로 본다', async () => {
|
||||||
|
const fetchFn = vi.fn().mockResolvedValue(okResponse(' '))
|
||||||
|
const result = await condenseTranscript('짧은 글', {
|
||||||
|
provider: settings,
|
||||||
|
fetchFn,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('429는 rateLimited로 올려보낸다', async () => {
|
||||||
|
const fetchFn = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 429,
|
||||||
|
json: async () => ({}),
|
||||||
|
} as unknown as Response)
|
||||||
|
|
||||||
|
const result = await condenseTranscript('짧은 글', {
|
||||||
|
provider: settings,
|
||||||
|
fetchFn,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (result.success) return
|
||||||
|
expect(result.rateLimited).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('빈 전사문은 호출조차 하지 않는다', async () => {
|
||||||
|
const fetchFn = vi.fn()
|
||||||
|
const result = await condenseTranscript(' ', {
|
||||||
|
provider: settings,
|
||||||
|
fetchFn,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fetchFn).not.toHaveBeenCalled()
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
generateSimpleMinutes,
|
generateSimpleMinutes,
|
||||||
|
generateAiMinutes,
|
||||||
generateGeminiMinutes,
|
generateGeminiMinutes,
|
||||||
type MinutesInput,
|
type MinutesInput,
|
||||||
} from '@/lib/minutes-generator'
|
} from '@/lib/minutes-generator'
|
||||||
@@ -105,3 +106,49 @@ describe('generateGeminiMinutes', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('generateAiMinutes — openai-compatible', () => {
|
||||||
|
const settings = {
|
||||||
|
provider: 'openai-compatible' as const,
|
||||||
|
apiKey: 'sk-test',
|
||||||
|
baseUrl: 'https://api.orcarouter.ai/v1',
|
||||||
|
model: 'google/gemini-2.5-flash-lite',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('OpenAI 호환 엔드포인트 응답으로 회의록을 만든다', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
choices: [{ message: { content: '## 요약\n- 리팩토링 완료' } }],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await generateAiMinutes(sampleInput, {
|
||||||
|
provider: settings,
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.markdown).toContain('# 주간 스프린트 회의')
|
||||||
|
expect(result.markdown).toContain('리팩토링 완료')
|
||||||
|
// 하단 문구는 실제 사용한 모델을 표기한다
|
||||||
|
expect(result.markdown).toContain('google/gemini-2.5-flash-lite')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('응답이 비어 있으면 실패로 처리해 단순 변환 폴백을 유도한다', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ choices: [{ message: { content: ' ' } }] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await generateAiMinutes(sampleInput, {
|
||||||
|
provider: settings,
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
complete,
|
||||||
|
describeModel,
|
||||||
|
normalizeBaseUrl,
|
||||||
|
toProviderSettings,
|
||||||
|
DEFAULT_GEMINI_MODEL,
|
||||||
|
findPreset,
|
||||||
|
PROVIDER_PRESETS,
|
||||||
|
} from '@/lib/providers'
|
||||||
|
|
||||||
|
function okResponse(content: string) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ choices: [{ message: { content } }] }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPENAI_SETTINGS = {
|
||||||
|
provider: 'openai-compatible' as const,
|
||||||
|
apiKey: 'sk-test',
|
||||||
|
baseUrl: 'https://api.orcarouter.ai/v1',
|
||||||
|
model: 'google/gemini-2.5-flash-lite',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('normalizeBaseUrl', () => {
|
||||||
|
it('끝의 슬래시를 제거한다', () => {
|
||||||
|
expect(normalizeBaseUrl('https://api.example.com/v1/')).toBe(
|
||||||
|
'https://api.example.com/v1',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('앞뒤 공백을 제거한다', () => {
|
||||||
|
expect(normalizeBaseUrl(' https://api.example.com/v1 ')).toBe(
|
||||||
|
'https://api.example.com/v1',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('http/https 이외의 스킴은 거부한다', () => {
|
||||||
|
expect(normalizeBaseUrl('file:///etc/passwd')).toBeNull()
|
||||||
|
expect(normalizeBaseUrl('ftp://example.com')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('URL이 아니거나 비어 있으면 null', () => {
|
||||||
|
expect(normalizeBaseUrl('not-a-url')).toBeNull()
|
||||||
|
expect(normalizeBaseUrl(' ')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('complete — openai-compatible', () => {
|
||||||
|
it('/chat/completions로 OpenAI 형식 요청을 보낸다', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue(okResponse('## 요약'))
|
||||||
|
|
||||||
|
const result = await complete('프롬프트', OPENAI_SETTINGS, {
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) expect(result.text).toBe('## 요약')
|
||||||
|
|
||||||
|
const [url, init] = mockFetch.mock.calls[0]
|
||||||
|
expect(url).toBe('https://api.orcarouter.ai/v1/chat/completions')
|
||||||
|
expect(init.headers.Authorization).toBe('Bearer sk-test')
|
||||||
|
|
||||||
|
const body = JSON.parse(init.body)
|
||||||
|
expect(body.model).toBe('google/gemini-2.5-flash-lite')
|
||||||
|
expect(body.messages).toEqual([{ role: 'user', content: '프롬프트' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('키가 없으면 Authorization 헤더를 붙이지 않는다 (로컬 모델 서버)', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue(okResponse('요약'))
|
||||||
|
|
||||||
|
await complete(
|
||||||
|
'프롬프트',
|
||||||
|
{ ...OPENAI_SETTINGS, apiKey: '', baseUrl: 'http://localhost:11434/v1' },
|
||||||
|
{ fetchFn: mockFetch },
|
||||||
|
)
|
||||||
|
|
||||||
|
const [, init] = mockFetch.mock.calls[0]
|
||||||
|
expect(init.headers.Authorization).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('base URL이 없으면 호출하지 않고 에러를 반환한다', async () => {
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
|
||||||
|
const result = await complete(
|
||||||
|
'프롬프트',
|
||||||
|
{ ...OPENAI_SETTINGS, baseUrl: '' },
|
||||||
|
{ fetchFn: mockFetch },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('모델이 없으면 호출하지 않고 에러를 반환한다', async () => {
|
||||||
|
const mockFetch = vi.fn()
|
||||||
|
|
||||||
|
const result = await complete(
|
||||||
|
'프롬프트',
|
||||||
|
{ ...OPENAI_SETTINGS, model: ' ' },
|
||||||
|
{ fetchFn: mockFetch },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) expect(result.error).toContain('모델')
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('429는 rateLimited 플래그를 세운다', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 429 })
|
||||||
|
|
||||||
|
const result = await complete('프롬프트', OPENAI_SETTINGS, {
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) expect(result.rateLimited).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('401은 인증 실패로 안내한다', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 401 })
|
||||||
|
|
||||||
|
const result = await complete('프롬프트', OPENAI_SETTINGS, {
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) expect(result.error).toContain('인증')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('네트워크 예외를 결과 객체로 감싼다', async () => {
|
||||||
|
const mockFetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))
|
||||||
|
|
||||||
|
const result = await complete('프롬프트', OPENAI_SETTINGS, {
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (!result.success) expect(result.error).toContain('ECONNREFUSED')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('complete — gemini 라우팅', () => {
|
||||||
|
it('provider가 gemini면 Google 엔드포인트를 호출한다', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
candidates: [{ content: { parts: [{ text: '요약' }] } }],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
await complete(
|
||||||
|
'프롬프트',
|
||||||
|
{ provider: 'gemini', apiKey: 'AIza-test' },
|
||||||
|
{ fetchFn: mockFetch },
|
||||||
|
)
|
||||||
|
|
||||||
|
const [url, init] = mockFetch.mock.calls[0]
|
||||||
|
expect(url).toContain('generativelanguage.googleapis.com')
|
||||||
|
expect(url).toContain(DEFAULT_GEMINI_MODEL)
|
||||||
|
expect(init.headers['x-goog-api-key']).toBe('AIza-test')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('모델을 지정하면 해당 모델 엔드포인트를 호출한다', async () => {
|
||||||
|
const mockFetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
candidates: [{ content: { parts: [{ text: '요약' }] } }],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
await complete(
|
||||||
|
'프롬프트',
|
||||||
|
{ provider: 'gemini', apiKey: 'AIza-test', model: 'gemini-2.5-pro' },
|
||||||
|
{ fetchFn: mockFetch },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(mockFetch.mock.calls[0][0]).toContain('gemini-2.5-pro')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('toProviderSettings', () => {
|
||||||
|
it('provider가 없으면 기존 Gemini 호출부와 동일하게 동작한다', () => {
|
||||||
|
expect(toProviderSettings(undefined, 'legacy-key')).toEqual({
|
||||||
|
provider: 'gemini',
|
||||||
|
apiKey: 'legacy-key',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('provider가 있으면 그대로 사용한다', () => {
|
||||||
|
expect(toProviderSettings(OPENAI_SETTINGS, 'ignored')).toBe(OPENAI_SETTINGS)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('describeModel', () => {
|
||||||
|
it('gemini는 기본 모델명을 돌려준다', () => {
|
||||||
|
expect(describeModel({ provider: 'gemini', apiKey: 'k' })).toBe(
|
||||||
|
DEFAULT_GEMINI_MODEL,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('openai-compatible은 설정한 모델명을 돌려준다', () => {
|
||||||
|
expect(describeModel(OPENAI_SETTINGS)).toBe('google/gemini-2.5-flash-lite')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('프리셋', () => {
|
||||||
|
it('알 수 없는 id는 기본 프리셋으로 폴백한다', () => {
|
||||||
|
expect(findPreset('없는-프리셋').id).toBe('gemini')
|
||||||
|
expect(findPreset(null).id).toBe('gemini')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('openai-compatible 프리셋은 base URL 또는 직접 입력 안내를 갖는다', () => {
|
||||||
|
for (const preset of PROVIDER_PRESETS) {
|
||||||
|
if (preset.provider !== 'openai-compatible') continue
|
||||||
|
expect(preset.id === 'custom' || preset.baseUrl.length > 0).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { mkdtemp, rm, readFile } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
type Store = typeof import('@/lib/recording-store')
|
||||||
|
|
||||||
|
let dir: string
|
||||||
|
let store: Store
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
dir = await mkdtemp(path.join(tmpdir(), 'recordings-'))
|
||||||
|
process.env.RECORDINGS_DIR = dir
|
||||||
|
vi.resetModules()
|
||||||
|
store = await import('@/lib/recording-store')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
delete process.env.RECORDINGS_DIR
|
||||||
|
await rm(dir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createRecording', () => {
|
||||||
|
it('세션 메타를 만들고 확장자를 붙인다', async () => {
|
||||||
|
const meta = await store.createRecording('audio/webm;codecs=opus')
|
||||||
|
|
||||||
|
expect(meta.id).toMatch(/^[0-9a-f]{32}$/)
|
||||||
|
expect(meta.fileName).toBe(`${meta.id}.webm`)
|
||||||
|
expect(meta.finalizedAt).toBeNull()
|
||||||
|
expect(meta.durationMs).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('매번 다른 id를 준다', async () => {
|
||||||
|
const a = await store.createRecording('audio/webm')
|
||||||
|
const b = await store.createRecording('audio/webm')
|
||||||
|
expect(a.id).not.toBe(b.id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('appendChunk', () => {
|
||||||
|
it('조각을 받은 순서대로 이어 붙인다', async () => {
|
||||||
|
const { id } = await store.createRecording('audio/webm')
|
||||||
|
|
||||||
|
await store.appendChunk(id, Buffer.from('AAA'))
|
||||||
|
await store.appendChunk(id, Buffer.from('BBB'))
|
||||||
|
const last = await store.appendChunk(id, Buffer.from('CC'))
|
||||||
|
|
||||||
|
expect(last).toEqual({ ok: true, bytes: 8 })
|
||||||
|
|
||||||
|
const meta = await store.getRecording(id)
|
||||||
|
const written = await readFile(path.join(dir, meta!.fileName), 'utf-8')
|
||||||
|
expect(written).toBe('AAABBBCC')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('동시에 들어와도 순서가 섞이지 않는다', async () => {
|
||||||
|
const { id } = await store.createRecording('audio/webm')
|
||||||
|
|
||||||
|
// 클라이언트가 직렬로 보내도 서버에서 겹칠 수 있다. 겹쳐도 파일이
|
||||||
|
// 깨지지 않아야 한다 — 순서가 어긋나면 컨테이너를 못 읽는다.
|
||||||
|
await Promise.all([
|
||||||
|
store.appendChunk(id, Buffer.from('1')),
|
||||||
|
store.appendChunk(id, Buffer.from('2')),
|
||||||
|
store.appendChunk(id, Buffer.from('3')),
|
||||||
|
store.appendChunk(id, Buffer.from('4')),
|
||||||
|
])
|
||||||
|
|
||||||
|
const meta = await store.getRecording(id)
|
||||||
|
const written = await readFile(path.join(dir, meta!.fileName), 'utf-8')
|
||||||
|
expect(written).toHaveLength(4)
|
||||||
|
expect(written.split('').sort().join('')).toBe('1234')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('없는 세션은 404로 거절한다', async () => {
|
||||||
|
const result = await store.appendChunk('f'.repeat(32), Buffer.from('x'))
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: '녹음 세션을 찾을 수 없습니다.',
|
||||||
|
status: 404,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('누적 크기를 정확히 보고한다', async () => {
|
||||||
|
const { id } = await store.createRecording('audio/webm')
|
||||||
|
await store.appendChunk(id, Buffer.from('keep'))
|
||||||
|
|
||||||
|
const result = await store.appendChunk(id, Buffer.alloc(1024))
|
||||||
|
expect(result).toEqual({ ok: true, bytes: 4 + 1024 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('finalizeRecording', () => {
|
||||||
|
it('길이를 확정하고 최종 크기를 돌려준다', async () => {
|
||||||
|
const { id } = await store.createRecording('audio/webm')
|
||||||
|
await store.appendChunk(id, Buffer.from('0123456789'))
|
||||||
|
|
||||||
|
const status = await store.finalizeRecording(id, 46_000)
|
||||||
|
|
||||||
|
expect(status?.bytes).toBe(10)
|
||||||
|
expect(status?.durationMs).toBe(46_000)
|
||||||
|
expect(status?.finalizedAt).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('확정 정보가 디스크에도 남는다', async () => {
|
||||||
|
const { id } = await store.createRecording('audio/webm')
|
||||||
|
await store.finalizeRecording(id, 1_234)
|
||||||
|
|
||||||
|
const reloaded = await store.getRecording(id)
|
||||||
|
expect(reloaded?.durationMs).toBe(1_234)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('길이가 숫자가 아니면 null로 둔다', async () => {
|
||||||
|
const { id } = await store.createRecording('audio/webm')
|
||||||
|
const status = await store.finalizeRecording(id, null)
|
||||||
|
expect(status?.durationMs).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('없는 세션은 null', async () => {
|
||||||
|
expect(await store.finalizeRecording('e'.repeat(32), 1)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getRecording', () => {
|
||||||
|
it('잘못된 id는 파일을 찾아보지도 않는다', async () => {
|
||||||
|
expect(await store.getRecording('../../etc/passwd')).toBeNull()
|
||||||
|
expect(await store.getRecording('..')).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('openRecording', () => {
|
||||||
|
it('조각이 하나도 없으면 null', async () => {
|
||||||
|
const { id } = await store.createRecording('audio/webm')
|
||||||
|
expect(await store.openRecording(id)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('저장된 바이트를 그대로 읽어준다', async () => {
|
||||||
|
const { id } = await store.createRecording('audio/webm')
|
||||||
|
await store.appendChunk(id, Buffer.from('hello'))
|
||||||
|
|
||||||
|
const opened = await store.openRecording(id)
|
||||||
|
expect(opened?.bytes).toBe(5)
|
||||||
|
expect(opened?.meta.mimeType).toBe('audio/webm')
|
||||||
|
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of opened!.stream) {
|
||||||
|
chunks.push(Buffer.from(chunk as Buffer))
|
||||||
|
}
|
||||||
|
expect(Buffer.concat(chunks).toString()).toBe('hello')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('크래시 내성', () => {
|
||||||
|
it('종료 처리 없이 중단돼도 그때까지의 오디오는 남는다', async () => {
|
||||||
|
const { id } = await store.createRecording('audio/webm')
|
||||||
|
await store.appendChunk(id, Buffer.from('part1'))
|
||||||
|
await store.appendChunk(id, Buffer.from('part2'))
|
||||||
|
// finalize 없이 프로세스가 죽었다고 치고 모듈을 새로 읽는다.
|
||||||
|
vi.resetModules()
|
||||||
|
const reloaded: Store = await import('@/lib/recording-store')
|
||||||
|
|
||||||
|
const status = await reloaded.getRecordingStatus(id)
|
||||||
|
expect(status?.bytes).toBe(10)
|
||||||
|
expect(status?.finalizedAt).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import {
|
||||||
|
PREFERRED_MIME_TYPES,
|
||||||
|
RECORDING_AUDIO_CONSTRAINTS,
|
||||||
|
extensionForMimeType,
|
||||||
|
formatBytes,
|
||||||
|
formatDuration,
|
||||||
|
isValidRecordingId,
|
||||||
|
pickRecorderMimeType,
|
||||||
|
recordingDownloadName,
|
||||||
|
} from '@/lib/recording'
|
||||||
|
|
||||||
|
describe('pickRecorderMimeType', () => {
|
||||||
|
it('가장 앞선 후보를 고른다', () => {
|
||||||
|
const picked = pickRecorderMimeType(() => true)
|
||||||
|
expect(picked).toBe(PREFERRED_MIME_TYPES[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('지원하지 않는 후보는 건너뛴다', () => {
|
||||||
|
const picked = pickRecorderMimeType((type) => type === 'audio/mp4')
|
||||||
|
expect(picked).toBe('audio/mp4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('아무것도 지원하지 않으면 null', () => {
|
||||||
|
expect(pickRecorderMimeType(() => false)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('extensionForMimeType', () => {
|
||||||
|
it('코덱 파라미터를 떼고 판단한다', () => {
|
||||||
|
expect(extensionForMimeType('audio/webm;codecs=opus')).toBe('webm')
|
||||||
|
expect(extensionForMimeType('audio/ogg; codecs=opus')).toBe('ogg')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('주요 컨테이너를 매핑한다', () => {
|
||||||
|
expect(extensionForMimeType('audio/webm')).toBe('webm')
|
||||||
|
expect(extensionForMimeType('audio/mp4')).toBe('m4a')
|
||||||
|
expect(extensionForMimeType('audio/mpeg')).toBe('mp3')
|
||||||
|
expect(extensionForMimeType('audio/wav')).toBe('wav')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('대소문자를 가리지 않는다', () => {
|
||||||
|
expect(extensionForMimeType('AUDIO/WEBM')).toBe('webm')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('모르는 형식은 bin으로 떨어진다', () => {
|
||||||
|
expect(extensionForMimeType('application/octet-stream')).toBe('bin')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isValidRecordingId', () => {
|
||||||
|
const valid = 'a'.repeat(32)
|
||||||
|
|
||||||
|
it('32자 hex만 통과시킨다', () => {
|
||||||
|
expect(isValidRecordingId(valid)).toBe(true)
|
||||||
|
expect(isValidRecordingId('0123456789abcdef0123456789abcdef')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('경로 조작 시도를 막는다', () => {
|
||||||
|
expect(isValidRecordingId('../../etc/passwd')).toBe(false)
|
||||||
|
expect(isValidRecordingId(`${valid}/../x`)).toBe(false)
|
||||||
|
expect(isValidRecordingId('..')).toBe(false)
|
||||||
|
expect(isValidRecordingId(`../${valid}`)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('길이나 문자셋이 어긋나면 거부한다', () => {
|
||||||
|
expect(isValidRecordingId('a'.repeat(31))).toBe(false)
|
||||||
|
expect(isValidRecordingId('a'.repeat(33))).toBe(false)
|
||||||
|
expect(isValidRecordingId('A'.repeat(32))).toBe(false)
|
||||||
|
expect(isValidRecordingId('g'.repeat(32))).toBe(false)
|
||||||
|
expect(isValidRecordingId('')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('문자열이 아니면 거부한다', () => {
|
||||||
|
expect(isValidRecordingId(null)).toBe(false)
|
||||||
|
expect(isValidRecordingId(undefined)).toBe(false)
|
||||||
|
expect(isValidRecordingId(123)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('recordingDownloadName', () => {
|
||||||
|
const at = new Date(2026, 8, 8, 14, 5)
|
||||||
|
|
||||||
|
it('날짜와 제목을 붙인다', () => {
|
||||||
|
expect(recordingDownloadName('주간 회의', at, 'audio/webm')).toBe(
|
||||||
|
'20260908-1405_주간_회의.webm',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('제목이 없으면 날짜만 쓴다', () => {
|
||||||
|
expect(recordingDownloadName(' ', at, 'audio/webm')).toBe(
|
||||||
|
'20260908-1405.webm',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('파일명에 못 쓰는 문자를 지운다', () => {
|
||||||
|
const name = recordingDownloadName('a/b\\c:d*e?f"g<h>i|j', at, 'audio/webm')
|
||||||
|
expect(name).toBe('20260908-1405_abcdefghij.webm')
|
||||||
|
expect(name).not.toMatch(/[\\/:*?"<>|]/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('아주 긴 제목을 자른다', () => {
|
||||||
|
const name = recordingDownloadName('가'.repeat(200), at, 'audio/webm')
|
||||||
|
expect(name.length).toBeLessThan(90)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatBytes', () => {
|
||||||
|
it('단위를 바꿔가며 표기한다', () => {
|
||||||
|
expect(formatBytes(512)).toBe('512B')
|
||||||
|
expect(formatBytes(2048)).toBe('2KB')
|
||||||
|
expect(formatBytes(5 * 1024 * 1024)).toBe('5.0MB')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('비정상 값은 0으로 떨어진다', () => {
|
||||||
|
expect(formatBytes(-1)).toBe('0B')
|
||||||
|
expect(formatBytes(NaN)).toBe('0B')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatDuration', () => {
|
||||||
|
it('한 시간 미만은 분:초', () => {
|
||||||
|
expect(formatDuration(0)).toBe('00:00')
|
||||||
|
expect(formatDuration(65_000)).toBe('01:05')
|
||||||
|
expect(formatDuration(46 * 60_000 + 6_000)).toBe('46:06')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('한 시간 이상은 시:분:초', () => {
|
||||||
|
expect(formatDuration(3_600_000)).toBe('1:00:00')
|
||||||
|
expect(formatDuration(3_725_000)).toBe('1:02:05')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('음수는 0으로 본다', () => {
|
||||||
|
expect(formatDuration(-5_000)).toBe('00:00')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('RECORDING_AUDIO_CONSTRAINTS', () => {
|
||||||
|
it('원거리 화자를 지우는 전처리를 끈다', () => {
|
||||||
|
// 46분 대면 회의에서 포착률이 10% 안팎에 그친 원인 중 하나.
|
||||||
|
// 이 값이 다시 true로 돌아가면 회귀다.
|
||||||
|
expect(RECORDING_AUDIO_CONSTRAINTS.noiseSuppression).toBe(false)
|
||||||
|
expect(RECORDING_AUDIO_CONSTRAINTS.echoCancellation).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('조용한 화자를 끌어올리는 AGC는 남긴다', () => {
|
||||||
|
expect(RECORDING_AUDIO_CONSTRAINTS.autoGainControl).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -164,3 +164,119 @@ describe('liveEnabledFor / depthAdjustableFor', () => {
|
|||||||
expect(depthAdjustableFor('custom')).toBe(false)
|
expect(depthAdjustableFor('custom')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('내용 기반 섹션 구조', () => {
|
||||||
|
const transcript = '이번에는 가격 비교 사이트를 만들어 봅시다'
|
||||||
|
|
||||||
|
it('meeting은 섹션 제목을 직접 짓도록 지시한다', () => {
|
||||||
|
const prompt = buildPrompt({
|
||||||
|
templateId: 'meeting',
|
||||||
|
depth: 'standard',
|
||||||
|
transcript,
|
||||||
|
})
|
||||||
|
expect(prompt).toContain('섹션 제목을 직접 지어서')
|
||||||
|
// 고정 제목을 쓰지 말라는 지시가 함께 있어야 한다
|
||||||
|
expect(prompt).toContain('일반적인 제목은 쓰지 마세요')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('one_on_one도 주제별 제목을 직접 짓도록 지시한다', () => {
|
||||||
|
const prompt = buildPrompt({
|
||||||
|
templateId: 'one_on_one',
|
||||||
|
depth: 'standard',
|
||||||
|
transcript,
|
||||||
|
})
|
||||||
|
expect(prompt).toContain('제목을 직접 지어')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('raw는 구조적 제목을 덧붙이지 말라는 지시를 유지한다', () => {
|
||||||
|
const prompt = buildPrompt({
|
||||||
|
templateId: 'raw',
|
||||||
|
depth: 'detailed',
|
||||||
|
transcript,
|
||||||
|
})
|
||||||
|
expect(prompt).toContain('구조적 제목(## 섹션)을 덧붙이지 마세요')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('액션 아이템 담당자 표기', () => {
|
||||||
|
const transcript = '다음 주까지 정리해 주세요'
|
||||||
|
|
||||||
|
it('담당자 표기를 요구한다', () => {
|
||||||
|
const prompt = buildPrompt({
|
||||||
|
templateId: 'meeting',
|
||||||
|
depth: 'standard',
|
||||||
|
transcript,
|
||||||
|
})
|
||||||
|
expect(prompt).toContain('(담당자)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('담당자가 불분명해도 항목을 버리지 않도록 지시한다', () => {
|
||||||
|
const prompt = buildPrompt({
|
||||||
|
templateId: 'meeting',
|
||||||
|
depth: 'standard',
|
||||||
|
transcript,
|
||||||
|
})
|
||||||
|
expect(prompt).toContain('담당자가 불분명해도')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('체크박스 형식을 유지해 액션 아이템 파서와 호환된다', () => {
|
||||||
|
for (const id of ['meeting', 'one_on_one', 'brainstorm'] as const) {
|
||||||
|
const prompt = buildPrompt({ templateId: id, depth: 'standard', transcript })
|
||||||
|
expect(prompt).toContain('- [ ]')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('공통 규칙', () => {
|
||||||
|
const transcript = '테스트 발화'
|
||||||
|
|
||||||
|
it('프리셋 템플릿 전체에 공통 규칙이 붙는다', () => {
|
||||||
|
for (const id of Object.keys(TEMPLATES) as (keyof typeof TEMPLATES)[]) {
|
||||||
|
const prompt = buildPrompt({ templateId: id, depth: 'standard', transcript })
|
||||||
|
expect(prompt).toContain('지어내지 마세요')
|
||||||
|
expect(prompt).toContain('한국어로 작성하세요')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('발화자 표기가 있으면 활용하도록 지시한다', () => {
|
||||||
|
const prompt = buildPrompt({
|
||||||
|
templateId: 'meeting',
|
||||||
|
depth: 'standard',
|
||||||
|
transcript,
|
||||||
|
})
|
||||||
|
expect(prompt).toContain('발화자 표기가 있다면')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('영어 기술 용어는 원문 표기를 유지하도록 지시한다', () => {
|
||||||
|
const prompt = buildPrompt({
|
||||||
|
templateId: 'meeting',
|
||||||
|
depth: 'standard',
|
||||||
|
transcript,
|
||||||
|
})
|
||||||
|
expect(prompt).toContain('원문 표기를 유지하세요')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('custom 프롬프트에는 공통 규칙을 덧붙이지 않는다', () => {
|
||||||
|
const prompt = buildPrompt({
|
||||||
|
templateId: 'custom',
|
||||||
|
depth: 'standard',
|
||||||
|
transcript,
|
||||||
|
customPrompt: '한 줄로만 요약해줘',
|
||||||
|
})
|
||||||
|
expect(prompt).toContain('한 줄로만 요약해줘')
|
||||||
|
expect(prompt).not.toContain('지어내지 마세요')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('custom 프롬프트가 비어 있으면 meeting 템플릿 + 공통 규칙으로 대체한다', () => {
|
||||||
|
const prompt = buildPrompt({
|
||||||
|
templateId: 'custom',
|
||||||
|
depth: 'standard',
|
||||||
|
transcript,
|
||||||
|
customPrompt: ' ',
|
||||||
|
})
|
||||||
|
expect(prompt).toContain('회의록 작성 전문가')
|
||||||
|
expect(prompt).toContain('지어내지 마세요')
|
||||||
|
// 기존 동작 유지 — custom 경로에는 강도/라이브 모디파이어를 적용하지 않는다
|
||||||
|
expect(prompt).not.toContain('작성 강도')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,335 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
isGeminiSupportedAudioMimeType,
|
||||||
|
isWhisperSupportedMimeType,
|
||||||
|
parseTimestampedTranscript,
|
||||||
|
resolveSttModel,
|
||||||
|
transcribe,
|
||||||
|
GEMINI_INLINE_AUDIO_LIMIT,
|
||||||
|
DEFAULT_OPENAI_STT_MODEL,
|
||||||
|
DEFAULT_GEMINI_STT_MODEL,
|
||||||
|
} from '@/lib/providers'
|
||||||
|
import { resolveTranscriptionSettings } from '@/lib/api-keys'
|
||||||
|
import {
|
||||||
|
formatTranscriptionSegments,
|
||||||
|
segmentsToChunks,
|
||||||
|
} from '@/lib/transcript-formatter'
|
||||||
|
import type { TranscriptionInput } from '@/lib/providers/types'
|
||||||
|
|
||||||
|
function audio(overrides: Partial<TranscriptionInput> = {}): TranscriptionInput {
|
||||||
|
return {
|
||||||
|
bytes: new Uint8Array([1, 2, 3, 4]),
|
||||||
|
mimeType: 'audio/webm;codecs=opus',
|
||||||
|
fileName: 'meeting.webm',
|
||||||
|
language: 'ko',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function okResponse(body: unknown) {
|
||||||
|
return { ok: true, status: 200, json: async () => body } as unknown as Response
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 목의 호출 인자를 좁혀서 읽는다. vi.fn의 추론 타입은 fetch 오버로드와 안 맞는다. */
|
||||||
|
function callArgs(
|
||||||
|
mock: { mock: { calls: unknown[][] } },
|
||||||
|
index = 0,
|
||||||
|
): { url: string; init: RequestInit } {
|
||||||
|
const [url, init] = mock.mock.calls[index] as [string, RequestInit]
|
||||||
|
return { url, init }
|
||||||
|
}
|
||||||
|
|
||||||
|
function errResponse(status: number) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status,
|
||||||
|
json: async () => ({ error: 'nope' }),
|
||||||
|
} as unknown as Response
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mime 지원 판정', () => {
|
||||||
|
it('Whisper는 브라우저 녹음(webm)을 받는다', () => {
|
||||||
|
expect(isWhisperSupportedMimeType('audio/webm;codecs=opus')).toBe(true)
|
||||||
|
expect(isWhisperSupportedMimeType('audio/mpeg')).toBe(true)
|
||||||
|
expect(isWhisperSupportedMimeType('audio/x-aiff')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Gemini는 webm을 받지 않는다', () => {
|
||||||
|
// 이게 true로 바뀌면 UI의 안내 문구도 같이 고쳐야 한다.
|
||||||
|
expect(isGeminiSupportedAudioMimeType('audio/webm')).toBe(false)
|
||||||
|
expect(isGeminiSupportedAudioMimeType('audio/mpeg')).toBe(true)
|
||||||
|
expect(isGeminiSupportedAudioMimeType('audio/flac')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('transcribe — OpenAI 호환', () => {
|
||||||
|
const settings = {
|
||||||
|
provider: 'openai-compatible' as const,
|
||||||
|
apiKey: 'sk-test',
|
||||||
|
baseUrl: 'https://api.example.com/v1',
|
||||||
|
}
|
||||||
|
|
||||||
|
it('multipart로 보내고 verbose_json 구간을 파싱한다', async () => {
|
||||||
|
const fetchFn = vi.fn(async () =>
|
||||||
|
okResponse({
|
||||||
|
text: '안녕하세요 회의 시작합니다',
|
||||||
|
segments: [
|
||||||
|
{ start: 0, end: 2.5, text: ' 안녕하세요' },
|
||||||
|
{ start: 2.5, end: 5, text: ' 회의 시작합니다' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await transcribe(audio(), settings, { fetchFn })
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (!result.success) return
|
||||||
|
|
||||||
|
expect(result.text).toBe('안녕하세요 회의 시작합니다')
|
||||||
|
expect(result.segments).toEqual([
|
||||||
|
{ start: 0, end: 2.5, text: '안녕하세요' },
|
||||||
|
{ start: 2.5, end: 5, text: '회의 시작합니다' },
|
||||||
|
])
|
||||||
|
expect(result.model).toBe(DEFAULT_OPENAI_STT_MODEL)
|
||||||
|
|
||||||
|
const { url, init } = callArgs(fetchFn)
|
||||||
|
expect(url).toBe('https://api.example.com/v1/audio/transcriptions')
|
||||||
|
expect(init.body).toBeInstanceOf(FormData)
|
||||||
|
|
||||||
|
const form = init.body as FormData
|
||||||
|
expect(form.get('model')).toBe(DEFAULT_OPENAI_STT_MODEL)
|
||||||
|
expect(form.get('response_format')).toBe('verbose_json')
|
||||||
|
expect(form.get('language')).toBe('ko')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('어휘 힌트를 prompt로 보낸다', async () => {
|
||||||
|
const fetchFn = vi.fn(async () =>
|
||||||
|
okResponse({ text: '내용' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
await transcribe(audio({ vocabularyHint: '김효천, PACS' }), settings, {
|
||||||
|
fetchFn,
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = callArgs(fetchFn).init.body as FormData
|
||||||
|
expect(form.get('prompt')).toBe('김효천, PACS')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verbose_json 미지원(400)이면 json으로 한 번 더 시도한다', async () => {
|
||||||
|
const fetchFn = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(errResponse(400))
|
||||||
|
.mockResolvedValueOnce(okResponse({ text: '타임스탬프 없는 결과' }))
|
||||||
|
|
||||||
|
const result = await transcribe(audio(), settings, { fetchFn })
|
||||||
|
|
||||||
|
expect(fetchFn).toHaveBeenCalledTimes(2)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (!result.success) return
|
||||||
|
expect(result.segments).toEqual([])
|
||||||
|
|
||||||
|
const second = callArgs(fetchFn, 1).init.body as FormData
|
||||||
|
expect(second.get('response_format')).toBe('json')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('429는 rateLimited로 표시한다', async () => {
|
||||||
|
const fetchFn = vi.fn(async () => errResponse(429))
|
||||||
|
const result = await transcribe(audio(), settings, { fetchFn })
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (result.success) return
|
||||||
|
expect(result.rateLimited).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('빈 전사 결과를 성공으로 넘기지 않는다', async () => {
|
||||||
|
const fetchFn = vi.fn(async () => okResponse({ text: ' ' }))
|
||||||
|
const result = await transcribe(audio(), settings, { fetchFn })
|
||||||
|
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (result.success) return
|
||||||
|
expect(result.error).toContain('비어 있습니다')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('base URL이 잘못되면 호출조차 하지 않는다', async () => {
|
||||||
|
const fetchFn = vi.fn()
|
||||||
|
const result = await transcribe(
|
||||||
|
audio(),
|
||||||
|
{ ...settings, baseUrl: 'file:///etc/passwd' },
|
||||||
|
{ fetchFn },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(fetchFn).not.toHaveBeenCalled()
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('망가진 구간은 버리고 정상 구간만 남긴다', async () => {
|
||||||
|
const fetchFn = vi.fn(async () =>
|
||||||
|
okResponse({
|
||||||
|
text: 'ok',
|
||||||
|
segments: [
|
||||||
|
{ start: 0, end: 1, text: '정상' },
|
||||||
|
{ start: 'x', end: 2, text: '시작이 숫자가 아님' },
|
||||||
|
{ start: 3, end: 4, text: ' ' },
|
||||||
|
{ start: 5, end: 6, text: '또 정상' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await transcribe(audio(), settings, { fetchFn })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (!result.success) return
|
||||||
|
expect(result.segments.map((s) => s.text)).toEqual(['정상', '또 정상'])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('transcribe — Gemini', () => {
|
||||||
|
const settings = { provider: 'gemini' as const, apiKey: 'AIza-test' }
|
||||||
|
|
||||||
|
it('webm은 거부하고 대안을 안내한다', async () => {
|
||||||
|
const fetchFn = vi.fn()
|
||||||
|
const result = await transcribe(audio(), settings, { fetchFn })
|
||||||
|
|
||||||
|
expect(fetchFn).not.toHaveBeenCalled()
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (result.success) return
|
||||||
|
expect(result.error).toContain('OpenAI 호환')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('지원 형식은 inline_data로 보낸다', async () => {
|
||||||
|
const fetchFn = vi.fn(async () =>
|
||||||
|
okResponse({
|
||||||
|
candidates: [{ content: { parts: [{ text: '[00:03] 안녕하세요' }] } }],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await transcribe(
|
||||||
|
audio({ mimeType: 'audio/mpeg', fileName: 'a.mp3' }),
|
||||||
|
settings,
|
||||||
|
{ fetchFn },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (!result.success) return
|
||||||
|
expect(result.segments).toEqual([{ start: 3, end: 4, text: '안녕하세요' }])
|
||||||
|
|
||||||
|
const body = JSON.parse(callArgs(fetchFn).init.body as string)
|
||||||
|
expect(body.contents[0].parts[1].inline_data.mime_type).toBe('audio/mpeg')
|
||||||
|
expect(body.contents[0].parts[1].inline_data.data).toBe(
|
||||||
|
Buffer.from([1, 2, 3, 4]).toString('base64'),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('inline 상한을 넘으면 호출하지 않고 대안을 안내한다', async () => {
|
||||||
|
const fetchFn = vi.fn()
|
||||||
|
const result = await transcribe(
|
||||||
|
audio({
|
||||||
|
mimeType: 'audio/mpeg',
|
||||||
|
bytes: new Uint8Array(GEMINI_INLINE_AUDIO_LIMIT + 1),
|
||||||
|
}),
|
||||||
|
settings,
|
||||||
|
{ fetchFn },
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(fetchFn).not.toHaveBeenCalled()
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
if (result.success) return
|
||||||
|
expect(result.error).toContain('너무 큽니다')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('키가 없으면 거부한다', async () => {
|
||||||
|
const fetchFn = vi.fn()
|
||||||
|
const result = await transcribe(
|
||||||
|
audio({ mimeType: 'audio/mpeg' }),
|
||||||
|
{ provider: 'gemini', apiKey: ' ' },
|
||||||
|
{ fetchFn },
|
||||||
|
)
|
||||||
|
expect(fetchFn).not.toHaveBeenCalled()
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('parseTimestampedTranscript', () => {
|
||||||
|
it('MM:SS와 HH:MM:SS를 모두 읽는다', () => {
|
||||||
|
const segments = parseTimestampedTranscript(
|
||||||
|
['[00:03] 첫 발화', '[01:10] 두 번째', '[1:02:05] 한참 뒤'].join('\n'),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(segments.map((s) => s.start)).toEqual([3, 70, 3725])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('다음 구간 시작을 이전 구간의 끝으로 잡는다', () => {
|
||||||
|
const segments = parseTimestampedTranscript('[00:00] 가\n[00:10] 나')
|
||||||
|
expect(segments[0]).toEqual({ start: 0, end: 10, text: '가' })
|
||||||
|
expect(segments[1].end).toBeGreaterThan(segments[1].start)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('타임스탬프 없는 줄은 무시한다', () => {
|
||||||
|
expect(parseTimestampedTranscript('그냥 텍스트\n또 텍스트')).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatTranscriptionSegments', () => {
|
||||||
|
it('실제 오디오 타임라인으로 전사문을 만든다', () => {
|
||||||
|
const text = formatTranscriptionSegments([
|
||||||
|
{ start: 3, text: '어떤 거죠' },
|
||||||
|
{ start: 3725, text: '한참 뒤' },
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(text).toBe('[00:03] 어떤 거죠\n[01:02:05] 한참 뒤')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('빈 구간은 넣지 않는다', () => {
|
||||||
|
// Web Speech 경로에서 빈 청크 19개가 요약 프롬프트를 오염시켰다.
|
||||||
|
expect(
|
||||||
|
formatTranscriptionSegments([
|
||||||
|
{ start: 0, text: ' ' },
|
||||||
|
{ start: 1, text: '내용' },
|
||||||
|
]),
|
||||||
|
).toBe('[00:01] 내용')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('segmentsToChunks', () => {
|
||||||
|
it('청크 구조로 옮기면서 시간을 보존한다', () => {
|
||||||
|
expect(
|
||||||
|
segmentsToChunks([{ start: 1.5, end: 4.25, text: ' 발화 ' }]),
|
||||||
|
).toEqual([{ text: '발화', startTime: 1.5, endTime: 4.25, isFinal: true }])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resolveTranscriptionSettings', () => {
|
||||||
|
it('대화 모델이 없어도 전사는 가능하다', () => {
|
||||||
|
// 요약용 resolveProviderSettings는 model이 비면 null을 준다.
|
||||||
|
const settings = resolveTranscriptionSettings({
|
||||||
|
provider: 'openai-compatible',
|
||||||
|
baseUrl: 'https://api.example.com/v1',
|
||||||
|
apiKey: 'sk-x',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(settings).not.toBeNull()
|
||||||
|
expect(resolveSttModel(settings!)).toBe(DEFAULT_OPENAI_STT_MODEL)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sttModel을 지정하면 그것을 쓴다', () => {
|
||||||
|
const settings = resolveTranscriptionSettings({
|
||||||
|
provider: 'openai-compatible',
|
||||||
|
baseUrl: 'https://api.example.com/v1',
|
||||||
|
sttModel: 'gpt-4o-transcribe',
|
||||||
|
})
|
||||||
|
expect(resolveSttModel(settings!)).toBe('gpt-4o-transcribe')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gemini 기본 STT 모델은 오디오를 받는 모델이다', () => {
|
||||||
|
const settings = resolveTranscriptionSettings({
|
||||||
|
provider: 'gemini',
|
||||||
|
apiKey: 'AIza-x',
|
||||||
|
})
|
||||||
|
expect(resolveSttModel(settings!)).toBe(DEFAULT_GEMINI_STT_MODEL)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('base URL이 없으면 null', () => {
|
||||||
|
expect(
|
||||||
|
resolveTranscriptionSettings({ provider: 'openai-compatible' }),
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||||
|
import { useAudioRecorder } from '@/hooks/useAudioRecorder'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* jsdom에는 MediaRecorder도 getUserMedia도 없다. 훅이 다루는 것은
|
||||||
|
* "조각이 언제 오고 어디로 가는가"이므로 그 둘만 흉내 내면 충분하다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
class FakeMediaRecorder {
|
||||||
|
static isTypeSupported = () => true
|
||||||
|
|
||||||
|
state: 'inactive' | 'recording' = 'inactive'
|
||||||
|
ondataavailable: ((event: { data: Blob }) => void) | null = null
|
||||||
|
onerror: (() => void) | null = null
|
||||||
|
private listeners: Record<string, Array<() => void>> = {}
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public stream: MediaStream,
|
||||||
|
public options?: MediaRecorderOptions,
|
||||||
|
) {
|
||||||
|
instances.push(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.state = 'recording'
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.state = 'inactive'
|
||||||
|
// 실제 MediaRecorder는 stop() 시 남은 버퍼를 한 번 더 내보낸 뒤 stop을 쏜다.
|
||||||
|
for (const fn of this.listeners.stop ?? []) fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(type: string, fn: () => void) {
|
||||||
|
;(this.listeners[type] ??= []).push(fn)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(bytes: number) {
|
||||||
|
this.ondataavailable?.({ data: new Blob([new Uint8Array(bytes)]) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let instances: FakeMediaRecorder[] = []
|
||||||
|
let stopTracks: ReturnType<typeof vi.fn>
|
||||||
|
let fetchMock: ReturnType<typeof vi.fn>
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, ok = true, status = 200) {
|
||||||
|
return { ok, status, json: async () => body }
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
instances = []
|
||||||
|
stopTracks = vi.fn()
|
||||||
|
|
||||||
|
vi.stubGlobal('MediaRecorder', FakeMediaRecorder)
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
mediaDevices: {
|
||||||
|
getUserMedia: vi.fn(async () => ({
|
||||||
|
getTracks: () => [{ stop: stopTracks }],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
fetchMock = vi.fn(async (url: string) => {
|
||||||
|
if (url === '/api/recordings') {
|
||||||
|
return jsonResponse({ id: 'a'.repeat(32), mimeType: 'audio/webm' }, true, 201)
|
||||||
|
}
|
||||||
|
if (url.endsWith('/chunk')) return jsonResponse({ bytes: 1024 })
|
||||||
|
return jsonResponse({ bytes: 1024 })
|
||||||
|
})
|
||||||
|
vi.stubGlobal('fetch', fetchMock)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function startedHook() {
|
||||||
|
const view = renderHook(() => useAudioRecorder())
|
||||||
|
await waitFor(() => expect(view.result.current.isSupported).toBe(true))
|
||||||
|
await act(async () => {
|
||||||
|
await view.result.current.startRecording()
|
||||||
|
})
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useAudioRecorder — 캡처 제약', () => {
|
||||||
|
it('원거리 화자를 지우는 전처리를 끄고 마이크를 연다', async () => {
|
||||||
|
await startedHook()
|
||||||
|
|
||||||
|
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({
|
||||||
|
audio: expect.objectContaining({
|
||||||
|
noiseSuppression: false,
|
||||||
|
echoCancellation: false,
|
||||||
|
autoGainControl: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useAudioRecorder — 시간축', () => {
|
||||||
|
it('캡처 시작 시각을 돌려준다', async () => {
|
||||||
|
// 이 값을 인식기에 넘겨야 전사 타임스탬프가 오디오 파일과 같은 축을 쓴다.
|
||||||
|
const before = Date.now()
|
||||||
|
const view = renderHook(() => useAudioRecorder())
|
||||||
|
await waitFor(() => expect(view.result.current.isSupported).toBe(true))
|
||||||
|
|
||||||
|
let startedAt: number | null = null
|
||||||
|
await act(async () => {
|
||||||
|
startedAt = await view.result.current.startRecording()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(startedAt).not.toBeNull()
|
||||||
|
expect(startedAt!).toBeGreaterThanOrEqual(before)
|
||||||
|
expect(startedAt!).toBeLessThanOrEqual(Date.now())
|
||||||
|
})
|
||||||
|
|
||||||
|
it('마이크를 못 열면 null', async () => {
|
||||||
|
vi.stubGlobal('navigator', {
|
||||||
|
mediaDevices: {
|
||||||
|
getUserMedia: vi.fn(async () => {
|
||||||
|
throw Object.assign(new Error('denied'), { name: 'NotAllowedError' })
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const view = renderHook(() => useAudioRecorder())
|
||||||
|
await waitFor(() => expect(view.result.current.isSupported).toBe(true))
|
||||||
|
|
||||||
|
let startedAt: number | null = 0
|
||||||
|
await act(async () => {
|
||||||
|
startedAt = await view.result.current.startRecording()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(startedAt).toBeNull()
|
||||||
|
expect(view.result.current.error).toContain('마이크 권한')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useAudioRecorder — 조각 업로드', () => {
|
||||||
|
it('조각이 생길 때마다 서버로 올린다', async () => {
|
||||||
|
const view = await startedHook()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
instances[0].emit(2048)
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const chunkCalls = fetchMock.mock.calls.filter((c) =>
|
||||||
|
String(c[0]).endsWith('/chunk'),
|
||||||
|
)
|
||||||
|
expect(chunkCalls).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(view.result.current.localBytes).toBe(2048)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('빈 조각은 올리지 않는다', async () => {
|
||||||
|
await startedHook()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
instances[0].emit(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const chunkCalls = fetchMock.mock.calls.filter((c) =>
|
||||||
|
String(c[0]).endsWith('/chunk'),
|
||||||
|
)
|
||||||
|
expect(chunkCalls).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('업로드가 끝내 실패하면 뒤 조각을 이어 붙이지 않고 경고한다', async () => {
|
||||||
|
// 중간이 빈 파일은 짧은 파일보다 나쁘다 — 재생도 전사도 안 된다.
|
||||||
|
fetchMock.mockImplementation(async (url: string) => {
|
||||||
|
if (url === '/api/recordings') {
|
||||||
|
return jsonResponse({ id: 'a'.repeat(32) }, true, 201)
|
||||||
|
}
|
||||||
|
if (url.endsWith('/chunk')) return jsonResponse({ error: 'nope' }, false, 500)
|
||||||
|
return jsonResponse({})
|
||||||
|
})
|
||||||
|
|
||||||
|
const view = await startedHook()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
instances[0].emit(1024)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 500은 일시 장애일 수 있으므로 백오프를 두고 3회까지 재시도한다.
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(view.result.current.uploadWarning).toContain('서버 저장이 중단')
|
||||||
|
},
|
||||||
|
{ timeout: 5_000 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const callsAfterBreak = fetchMock.mock.calls.filter((c) =>
|
||||||
|
String(c[0]).endsWith('/chunk'),
|
||||||
|
).length
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
instances[0].emit(1024)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fetchMock.mock.calls.filter((c) => String(c[0]).endsWith('/chunk')).length,
|
||||||
|
).toBe(callsAfterBreak)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('세션 생성이 실패해도 녹음은 계속된다', async () => {
|
||||||
|
fetchMock.mockImplementation(async (url: string) => {
|
||||||
|
if (url === '/api/recordings') {
|
||||||
|
return jsonResponse({ error: 'down' }, false, 500)
|
||||||
|
}
|
||||||
|
return jsonResponse({})
|
||||||
|
})
|
||||||
|
|
||||||
|
const view = await startedHook()
|
||||||
|
|
||||||
|
expect(view.result.current.isRecording).toBe(true)
|
||||||
|
expect(view.result.current.uploadWarning).toContain('내려받아')
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
instances[0].emit(4096)
|
||||||
|
})
|
||||||
|
expect(view.result.current.localBytes).toBe(4096)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('useAudioRecorder — 종료', () => {
|
||||||
|
it('오디오를 한 조각도 못 받았으면 보관됐다고 말하지 않는다', async () => {
|
||||||
|
const view = await startedHook()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await view.result.current.stopRecording()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(view.result.current.recording).toBeNull()
|
||||||
|
expect(view.result.current.error).toContain('한 조각도 캡처되지 않았습니다')
|
||||||
|
expect(view.result.current.isRecording).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('받은 조각이 있으면 결과를 넘기고 마이크를 놓는다', async () => {
|
||||||
|
const view = await startedHook()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
instances[0].emit(1024)
|
||||||
|
})
|
||||||
|
await act(async () => {
|
||||||
|
await view.result.current.stopRecording()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(view.result.current.recording?.blob.size).toBe(1024)
|
||||||
|
expect(view.result.current.recording?.recordingId).toBe('a'.repeat(32))
|
||||||
|
expect(stopTracks).toHaveBeenCalled()
|
||||||
|
expect(view.result.current.isRecording).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('서버 사본이 로컬보다 짧으면 경고한다', async () => {
|
||||||
|
fetchMock.mockImplementation(async (url: string) => {
|
||||||
|
if (url === '/api/recordings') {
|
||||||
|
return jsonResponse({ id: 'a'.repeat(32) }, true, 201)
|
||||||
|
}
|
||||||
|
if (url.endsWith('/chunk')) return jsonResponse({ bytes: 10 })
|
||||||
|
return jsonResponse({ bytes: 10 })
|
||||||
|
})
|
||||||
|
|
||||||
|
const view = await startedHook()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
instances[0].emit(4096)
|
||||||
|
})
|
||||||
|
await act(async () => {
|
||||||
|
await view.result.current.stopRecording()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(view.result.current.uploadWarning).toContain('서버 사본이 로컬보다 짧습니다')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||||
|
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||||
|
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* jsdom에는 SpeechRecognition이 없다. 여기서 검증하려는 것은 인식 품질이 아니라
|
||||||
|
* **Chrome이 준 것을 우리가 흘리지 않는가**이므로, 이벤트를 직접 쏠 수 있는
|
||||||
|
* 최소 구현이면 충분하다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface FakeResult {
|
||||||
|
isFinal: boolean
|
||||||
|
0: { transcript: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeSpeechRecognition {
|
||||||
|
lang = ''
|
||||||
|
continuous = false
|
||||||
|
interimResults = false
|
||||||
|
|
||||||
|
onresult: ((event: { resultIndex: number; results: FakeResult[] }) => void) | null = null
|
||||||
|
onend: (() => void) | null = null
|
||||||
|
onerror: ((event: { error: string }) => void) | null = null
|
||||||
|
|
||||||
|
started = 0
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
instances.push(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.started += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
// 실제 Chrome은 stop() 뒤에도 onend를 쏘지만, 훅이 ref를 먼저 끊어
|
||||||
|
// 재시작을 막는다. 그 동작을 그대로 흉내 낸다.
|
||||||
|
this.onend?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chrome이 결과를 내보내는 것을 흉내 낸다. */
|
||||||
|
emit(...results: Array<{ text: string; final: boolean }>) {
|
||||||
|
this.onresult?.({
|
||||||
|
resultIndex: 0,
|
||||||
|
results: results.map((r) => ({
|
||||||
|
isFinal: r.final,
|
||||||
|
0: { transcript: r.text },
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let instances: FakeSpeechRecognition[] = []
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
instances = []
|
||||||
|
vi.stubGlobal('SpeechRecognition', FakeSpeechRecognition)
|
||||||
|
vi.stubGlobal('webkitSpeechRecognition', FakeSpeechRecognition)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function listening(timeOrigin?: number) {
|
||||||
|
const view = renderHook(() => useSpeechRecognition())
|
||||||
|
await waitFor(() => expect(view.result.current.isSupported).toBe(true))
|
||||||
|
act(() => view.result.current.startListening(timeOrigin))
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('확정된 발화', () => {
|
||||||
|
it('청크로 쌓인다', async () => {
|
||||||
|
const view = await listening()
|
||||||
|
|
||||||
|
act(() => instances[0].emit({ text: '안녕하세요', final: true }))
|
||||||
|
|
||||||
|
expect(view.result.current.chunks).toHaveLength(1)
|
||||||
|
expect(view.result.current.chunks[0].text).toBe('안녕하세요')
|
||||||
|
expect(view.result.current.chunks[0].isFinal).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('빈 확정 결과는 청크로 넣지 않고 유실로 센다', async () => {
|
||||||
|
// 실측 46분 회의에서 19번 나온 케이스. Chrome이 "소리는 들었는데
|
||||||
|
// 못 알아듣겠다"고 답한 것으로, 청크로 넣으면 요약 프롬프트가 오염된다.
|
||||||
|
const view = await listening()
|
||||||
|
|
||||||
|
act(() => instances[0].emit({ text: ' ', final: true }))
|
||||||
|
act(() => instances[0].emit({ text: '', final: true }))
|
||||||
|
|
||||||
|
expect(view.result.current.chunks).toHaveLength(0)
|
||||||
|
expect(view.result.current.missedCount).toBe(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('미확정 발화 보존', () => {
|
||||||
|
it('세션이 끊길 때 확정 전 발화를 살려낸다', async () => {
|
||||||
|
// Chrome은 continuous여도 침묵마다 세션을 닫고, 그때 확정 전 문장을 버린다.
|
||||||
|
const view = await listening()
|
||||||
|
|
||||||
|
act(() => instances[0].emit({ text: '아직 확정 안 된 말', final: false }))
|
||||||
|
expect(view.result.current.interimText).toBe('아직 확정 안 된 말')
|
||||||
|
expect(view.result.current.chunks).toHaveLength(0)
|
||||||
|
|
||||||
|
act(() => instances[0].onend?.())
|
||||||
|
|
||||||
|
expect(view.result.current.chunks).toHaveLength(1)
|
||||||
|
expect(view.result.current.chunks[0].text).toBe('아직 확정 안 된 말')
|
||||||
|
// 우리가 살려낸 값이지 Chrome이 확정해 준 값이 아니다.
|
||||||
|
expect(view.result.current.chunks[0].isFinal).toBe(false)
|
||||||
|
expect(view.result.current.interimText).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('중지할 때도 마지막 발화를 살려낸다', async () => {
|
||||||
|
// 회의 끝머리가 통째로 사라지던 자리.
|
||||||
|
const view = await listening()
|
||||||
|
|
||||||
|
act(() => instances[0].emit({ text: '수고하셨습니다', final: false }))
|
||||||
|
|
||||||
|
let finalChunks: ReturnType<typeof view.result.current.stopListening> = []
|
||||||
|
act(() => {
|
||||||
|
finalChunks = view.result.current.stopListening()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(finalChunks).toHaveLength(1)
|
||||||
|
expect(finalChunks[0].text).toBe('수고하셨습니다')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stopListening이 렌더 시점이 아닌 최신 청크를 돌려준다', async () => {
|
||||||
|
const view = await listening()
|
||||||
|
|
||||||
|
act(() => instances[0].emit({ text: '첫 발화', final: true }))
|
||||||
|
act(() => instances[0].emit({ text: '끝 발화', final: false }))
|
||||||
|
|
||||||
|
let finalChunks: ReturnType<typeof view.result.current.stopListening> = []
|
||||||
|
act(() => {
|
||||||
|
finalChunks = view.result.current.stopListening()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(finalChunks.map((c) => c.text)).toEqual(['첫 발화', '끝 발화'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('살려낼 미확정 발화가 없으면 아무것도 추가하지 않는다', async () => {
|
||||||
|
const view = await listening()
|
||||||
|
|
||||||
|
act(() => instances[0].emit({ text: '확정된 말', final: true }))
|
||||||
|
act(() => instances[0].onend?.())
|
||||||
|
|
||||||
|
expect(view.result.current.chunks).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('재시작이 반복돼도 확정 전 발화가 매번 보존된다', async () => {
|
||||||
|
const view = await listening()
|
||||||
|
const recognition = instances[0]
|
||||||
|
|
||||||
|
// flushInterim은 재시작 타이머보다 먼저, 동기로 돈다.
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
act(() => recognition.emit({ text: `구간 ${i}`, final: false }))
|
||||||
|
act(() => recognition.onend?.())
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(view.result.current.chunks.map((c) => c.text)).toEqual([
|
||||||
|
'구간 0',
|
||||||
|
'구간 1',
|
||||||
|
'구간 2',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('타임스탬프', () => {
|
||||||
|
it('발화가 처음 들린 시각을 시작으로 잡는다', async () => {
|
||||||
|
const origin = Date.now()
|
||||||
|
const view = await listening(origin)
|
||||||
|
|
||||||
|
const nowSpy = vi.spyOn(Date, 'now')
|
||||||
|
|
||||||
|
nowSpy.mockReturnValue(origin + 5_000)
|
||||||
|
act(() => instances[0].emit({ text: '말하는 중', final: false }))
|
||||||
|
|
||||||
|
nowSpy.mockReturnValue(origin + 12_000)
|
||||||
|
act(() => instances[0].emit({ text: '말하는 중입니다', final: true }))
|
||||||
|
|
||||||
|
const chunk = view.result.current.chunks[0]
|
||||||
|
// 하드코딩된 `끝 − 2초`(10초)가 아니라 실제로 들리기 시작한 5초여야 한다.
|
||||||
|
expect(chunk.startTime).toBeCloseTo(5, 1)
|
||||||
|
expect(chunk.endTime).toBeCloseTo(12, 1)
|
||||||
|
|
||||||
|
nowSpy.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('timeOrigin을 주면 그 기준으로 시각을 잰다', async () => {
|
||||||
|
// 오디오 녹음 시작 시각을 넘기면 전사 타임스탬프가 파일 재생 위치와 맞는다.
|
||||||
|
const origin = Date.now() - 30_000
|
||||||
|
const view = await listening(origin)
|
||||||
|
|
||||||
|
act(() => instances[0].emit({ text: '발화', final: true }))
|
||||||
|
|
||||||
|
expect(view.result.current.chunks[0].endTime).toBeGreaterThan(29)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('interim 없이 확정만 오면 기본 길이로 되돌아간다', async () => {
|
||||||
|
const origin = Date.now()
|
||||||
|
const view = await listening(origin)
|
||||||
|
|
||||||
|
const nowSpy = vi.spyOn(Date, 'now')
|
||||||
|
nowSpy.mockReturnValue(origin + 8_000)
|
||||||
|
act(() => instances[0].emit({ text: '갑자기 확정', final: true }))
|
||||||
|
|
||||||
|
const chunk = view.result.current.chunks[0]
|
||||||
|
expect(chunk.endTime).toBeCloseTo(8, 1)
|
||||||
|
expect(chunk.startTime).toBeCloseTo(6, 1)
|
||||||
|
|
||||||
|
nowSpy.mockRestore()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('resetChunks', () => {
|
||||||
|
it('청크와 유실 카운트를 함께 지운다', async () => {
|
||||||
|
const view = await listening()
|
||||||
|
|
||||||
|
act(() => instances[0].emit({ text: '발화', final: true }))
|
||||||
|
act(() => instances[0].emit({ text: '', final: true }))
|
||||||
|
act(() => view.result.current.resetChunks())
|
||||||
|
|
||||||
|
expect(view.result.current.chunks).toHaveLength(0)
|
||||||
|
expect(view.result.current.missedCount).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('초기화 후 stopListening은 빈 배열', async () => {
|
||||||
|
const view = await listening()
|
||||||
|
|
||||||
|
act(() => instances[0].emit({ text: '발화', final: true }))
|
||||||
|
act(() => view.result.current.resetChunks())
|
||||||
|
|
||||||
|
let finalChunks: ReturnType<typeof view.result.current.stopListening> = []
|
||||||
|
act(() => {
|
||||||
|
finalChunks = view.result.current.stopListening()
|
||||||
|
})
|
||||||
|
expect(finalChunks).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -79,6 +79,9 @@ export async function POST(request: NextRequest) {
|
|||||||
customPrompt,
|
customPrompt,
|
||||||
attendees,
|
attendees,
|
||||||
tags,
|
tags,
|
||||||
|
audioFileName,
|
||||||
|
audioMimeType,
|
||||||
|
audioDuration,
|
||||||
} = body
|
} = body
|
||||||
|
|
||||||
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
||||||
@@ -112,6 +115,14 @@ export async function POST(request: NextRequest) {
|
|||||||
tags: Array.isArray(tags)
|
tags: Array.isArray(tags)
|
||||||
? tags.filter((t) => typeof t === 'string')
|
? tags.filter((t) => typeof t === 'string')
|
||||||
: [],
|
: [],
|
||||||
|
audioFileName:
|
||||||
|
typeof audioFileName === 'string' ? audioFileName : null,
|
||||||
|
audioMimeType:
|
||||||
|
typeof audioMimeType === 'string' ? audioMimeType : null,
|
||||||
|
audioDuration:
|
||||||
|
typeof audioDuration === 'number' && Number.isFinite(audioDuration)
|
||||||
|
? Math.max(0, Math.round(audioDuration))
|
||||||
|
: null,
|
||||||
status: 'COMPLETED',
|
status: 'COMPLETED',
|
||||||
actionItems: {
|
actionItems: {
|
||||||
create: parsed.map((item, index) => ({
|
create: parsed.map((item, index) => ({
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { appendChunk } from '@/lib/recording-store'
|
||||||
|
import { MAX_CHUNK_BYTES, isValidRecordingId } from '@/lib/recording'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 녹음 조각 하나를 이어 붙인다.
|
||||||
|
*
|
||||||
|
* 본문은 MediaRecorder가 준 바이트 그대로다. JSON이나 multipart로 감싸면
|
||||||
|
* base64 팽창이나 파싱 비용만 늘어난다.
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const { id } = await context.params
|
||||||
|
|
||||||
|
if (!isValidRecordingId(id)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: '잘못된 녹음 세션 ID입니다.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await request.arrayBuffer())
|
||||||
|
|
||||||
|
if (buffer.byteLength === 0) {
|
||||||
|
return Response.json({ error: '빈 조각입니다.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer.byteLength > MAX_CHUNK_BYTES) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: '조각이 너무 큽니다.' },
|
||||||
|
{ status: 413 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await appendChunk(id, buffer)
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return Response.json({ error: result.error }, { status: result.status })
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json({ bytes: result.bytes })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||||
|
return Response.json(
|
||||||
|
{ error: `녹음 조각 저장 실패: ${message}` },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { Readable } from 'stream'
|
||||||
|
import { finalizeRecording, getRecordingStatus, openRecording } from '@/lib/recording-store'
|
||||||
|
import { extensionForMimeType, isValidRecordingId } from '@/lib/recording'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
interface RouteContext {
|
||||||
|
params: Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 녹음 파일 내려받기. 헤더에는 사용자 입력을 넣지 않는다. */
|
||||||
|
export async function GET(_request: NextRequest, context: RouteContext) {
|
||||||
|
const { id } = await context.params
|
||||||
|
|
||||||
|
if (!isValidRecordingId(id)) {
|
||||||
|
return Response.json({ error: '잘못된 녹음 세션 ID입니다.' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const opened = await openRecording(id)
|
||||||
|
if (!opened) {
|
||||||
|
return Response.json({ error: '녹음을 찾을 수 없습니다.' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { meta, bytes, stream } = opened
|
||||||
|
const stamp = meta.startedAt.slice(0, 16).replace(/[-:]/g, '').replace('T', '-')
|
||||||
|
const fileName = `meeting-${stamp}.${extensionForMimeType(meta.mimeType)}`
|
||||||
|
|
||||||
|
return new Response(Readable.toWeb(stream as Readable) as ReadableStream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': meta.mimeType,
|
||||||
|
'Content-Length': String(bytes),
|
||||||
|
'Content-Disposition': `attachment; filename="${fileName}"`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 녹음 종료. 길이를 확정하고 최종 상태를 돌려준다. */
|
||||||
|
export async function POST(request: NextRequest, context: RouteContext) {
|
||||||
|
try {
|
||||||
|
const { id } = await context.params
|
||||||
|
|
||||||
|
if (!isValidRecordingId(id)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: '잘못된 녹음 세션 ID입니다.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}))
|
||||||
|
const durationMs =
|
||||||
|
typeof body.durationMs === 'number' ? body.durationMs : null
|
||||||
|
|
||||||
|
const status = await finalizeRecording(id, durationMs)
|
||||||
|
if (!status) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: '녹음을 찾을 수 없습니다.' },
|
||||||
|
{ status: 404 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(status)
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||||
|
return Response.json(
|
||||||
|
{ error: `녹음 종료 처리 실패: ${message}` },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 현재까지 저장된 크기 확인용. */
|
||||||
|
export async function HEAD(_request: NextRequest, context: RouteContext) {
|
||||||
|
const { id } = await context.params
|
||||||
|
|
||||||
|
if (!isValidRecordingId(id)) return new Response(null, { status: 400 })
|
||||||
|
|
||||||
|
const status = await getRecordingStatus(id)
|
||||||
|
if (!status) return new Response(null, { status: 404 })
|
||||||
|
|
||||||
|
return new Response(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Length': String(status.bytes) },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { createRecording } from '@/lib/recording-store'
|
||||||
|
import { PREFERRED_MIME_TYPES } from '@/lib/recording'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/** 브라우저가 고른 mimeType만 허용한다. 임의 문자열이 확장자로 새어들면 안 된다. */
|
||||||
|
const ALLOWED_MIME_TYPES: readonly string[] = PREFERRED_MIME_TYPES
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}))
|
||||||
|
const { mimeType } = body
|
||||||
|
|
||||||
|
if (typeof mimeType !== 'string' || !ALLOWED_MIME_TYPES.includes(mimeType)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: '지원하지 않는 오디오 형식입니다.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = await createRecording(mimeType)
|
||||||
|
return Response.json(meta, { status: 201 })
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||||
|
return Response.json(
|
||||||
|
{ error: `녹음 세션 생성 실패: ${message}` },
|
||||||
|
{ status: 500 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { isEnvKeyConfigured } from '@/lib/api-keys'
|
import { isEnvKeyConfigured, isEnvProviderConfigured } from '@/lib/api-keys'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return Response.json({
|
return Response.json({
|
||||||
envConfigured: isEnvKeyConfigured(),
|
envConfigured: isEnvKeyConfigured(),
|
||||||
|
envProviderConfigured: isEnvProviderConfigured(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { resolveGeminiApiKey } from '@/lib/api-keys'
|
import { resolveProviderSettings } from '@/lib/api-keys'
|
||||||
|
import { complete, describeModel } from '@/lib/providers'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
@@ -8,60 +9,32 @@ const TEST_PROMPT = '"OK"라고만 한 단어로 답하세요.'
|
|||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json().catch(() => ({}))
|
const body = await request.json().catch(() => ({}))
|
||||||
const apiKey = resolveGeminiApiKey(body.apiKey)
|
const settings = resolveProviderSettings(body)
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!settings) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
ok: false,
|
ok: false,
|
||||||
error: '키가 비어 있습니다.',
|
error: '설정이 비어 있습니다. 키(또는 base URL과 모델)를 확인해주세요.',
|
||||||
},
|
},
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const url =
|
const result = await complete(TEST_PROMPT, settings)
|
||||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
|
|
||||||
|
|
||||||
const response = await fetch(url, {
|
if (!result.success) {
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-goog-api-key': apiKey,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
contents: [{ parts: [{ text: TEST_PROMPT }] }],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text().catch(() => '')
|
|
||||||
const message =
|
|
||||||
response.status === 400
|
|
||||||
? '잘못된 API 키 형식입니다.'
|
|
||||||
: response.status === 403
|
|
||||||
? '권한이 거부되었습니다. 키를 확인해주세요.'
|
|
||||||
: response.status === 429
|
|
||||||
? '요청 한도를 초과했지만 키 자체는 유효해 보입니다.'
|
|
||||||
: `Gemini API 오류: ${response.status}`
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{ ok: false, error: result.error },
|
||||||
ok: false,
|
|
||||||
error: message,
|
|
||||||
detail: errorText.slice(0, 200),
|
|
||||||
},
|
|
||||||
{ status: 200 },
|
{ status: 200 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
const reply: string =
|
|
||||||
data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? ''
|
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
ok: true,
|
ok: true,
|
||||||
message: '키가 정상적으로 동작합니다.',
|
message: '설정이 정상적으로 동작합니다.',
|
||||||
reply: reply.slice(0, 50),
|
model: describeModel(settings),
|
||||||
|
reply: result.text.trim().slice(0, 50),
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { generateLiveSummary } from '@/lib/live-summary'
|
import { generateLiveSummary } from '@/lib/live-summary'
|
||||||
import { resolveGeminiApiKey } from '@/lib/api-keys'
|
import { resolveProviderSettings } from '@/lib/api-keys'
|
||||||
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
||||||
|
|
||||||
const MAX_TRANSCRIPT_CHARS = 40_000
|
const MAX_TRANSCRIPT_CHARS = 40_000
|
||||||
|
const MAX_PREVIOUS_SUMMARY_CHARS = 8_000
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const { transcript, template, depth, customPrompt, apiKey } = body
|
const { transcript, template, depth, customPrompt, previousSummary } = body
|
||||||
|
|
||||||
if (!transcript || typeof transcript !== 'string') {
|
if (!transcript || typeof transcript !== 'string') {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
@@ -17,12 +18,12 @@ export async function POST(request: NextRequest) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolvedKey = resolveGeminiApiKey(apiKey)
|
const settings = resolveProviderSettings(body)
|
||||||
if (!resolvedKey) {
|
if (!settings) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{
|
{
|
||||||
error:
|
error:
|
||||||
'Gemini API 키가 설정되지 않았습니다. /settings에서 키를 입력해주세요.',
|
'AI 프로바이더가 설정되지 않았습니다. /settings에서 키를 입력해주세요.',
|
||||||
},
|
},
|
||||||
{ status: 503 },
|
{ status: 503 },
|
||||||
)
|
)
|
||||||
@@ -33,8 +34,15 @@ export async function POST(request: NextRequest) {
|
|||||||
? transcript.slice(-MAX_TRANSCRIPT_CHARS)
|
? transcript.slice(-MAX_TRANSCRIPT_CHARS)
|
||||||
: transcript
|
: transcript
|
||||||
|
|
||||||
|
// 증분 모드: 직전 요약 + 새 발화만 보내므로 호출당 토큰이 일정하다.
|
||||||
|
const previous =
|
||||||
|
typeof previousSummary === 'string'
|
||||||
|
? previousSummary.slice(-MAX_PREVIOUS_SUMMARY_CHARS)
|
||||||
|
: undefined
|
||||||
|
|
||||||
const result = await generateLiveSummary(truncated, {
|
const result = await generateLiveSummary(truncated, {
|
||||||
apiKey: resolvedKey,
|
provider: settings,
|
||||||
|
previousSummary: previous,
|
||||||
template: (template as TemplateId | undefined) ?? 'meeting',
|
template: (template as TemplateId | undefined) ?? 'meeting',
|
||||||
depth: depth as SummaryDepth | undefined,
|
depth: depth as SummaryDepth | undefined,
|
||||||
customPrompt:
|
customPrompt:
|
||||||
|
|||||||
@@ -1,26 +1,20 @@
|
|||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import {
|
import {
|
||||||
generateGeminiMinutes,
|
generateAiMinutes,
|
||||||
generateSimpleMinutes,
|
generateSimpleMinutes,
|
||||||
} from '@/lib/minutes-generator'
|
} from '@/lib/minutes-generator'
|
||||||
import { resolveGeminiApiKey } from '@/lib/api-keys'
|
import { resolveProviderSettings } from '@/lib/api-keys'
|
||||||
|
import {
|
||||||
|
MAX_TRANSCRIPT_CHARS,
|
||||||
|
SINGLE_PASS_CHAR_LIMIT,
|
||||||
|
condenseTranscript,
|
||||||
|
} from '@/lib/long-transcript'
|
||||||
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
||||||
|
|
||||||
const MAX_TRANSCRIPT_CHARS = 100_000
|
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json()
|
||||||
const {
|
const { title, transcript, mode, date, template, depth, customPrompt } = body
|
||||||
title,
|
|
||||||
transcript,
|
|
||||||
mode,
|
|
||||||
date,
|
|
||||||
template,
|
|
||||||
depth,
|
|
||||||
customPrompt,
|
|
||||||
apiKey,
|
|
||||||
} = body
|
|
||||||
|
|
||||||
if (!transcript || typeof transcript !== 'string') {
|
if (!transcript || typeof transcript !== 'string') {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
@@ -48,30 +42,59 @@ export async function POST(request: NextRequest) {
|
|||||||
typeof customPrompt === 'string' ? customPrompt : undefined,
|
typeof customPrompt === 'string' ? customPrompt : undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'gemini') {
|
// 'gemini'는 DB에 저장된 기존 값과의 호환을 위해 유지되는 AI 모드 식별자다.
|
||||||
const resolvedKey = resolveGeminiApiKey(apiKey)
|
if (mode === 'gemini' || mode === 'ai') {
|
||||||
if (!resolvedKey) {
|
const settings = resolveProviderSettings(body)
|
||||||
|
if (!settings) {
|
||||||
const fallback = generateSimpleMinutes(input)
|
const fallback = generateSimpleMinutes(input)
|
||||||
return Response.json({
|
return Response.json({
|
||||||
markdown: fallback,
|
markdown: fallback,
|
||||||
mode: 'simple',
|
mode: 'simple',
|
||||||
warning:
|
warning:
|
||||||
'Gemini API 키가 설정되지 않아 단순 변환으로 대체되었습니다. /settings에서 키를 설정하세요.',
|
'AI 프로바이더가 설정되지 않아 단순 변환으로 대체되었습니다. /settings에서 설정하세요.',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await generateGeminiMinutes(input, { apiKey: resolvedKey })
|
// 한 번에 넣기엔 긴 회의는 구간별로 압축한 뒤 평소 경로로 회의록을 만든다.
|
||||||
|
// 예전에는 여기서 413으로 거부해, 3시간 회의가 마지막에 통째로 실패했다.
|
||||||
|
let condensedNotice: string | undefined
|
||||||
|
|
||||||
|
if (transcript.length > SINGLE_PASS_CHAR_LIMIT) {
|
||||||
|
const condensed = await condenseTranscript(transcript, {
|
||||||
|
provider: settings,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!condensed.success) {
|
||||||
|
const fallback = generateSimpleMinutes(input)
|
||||||
|
return Response.json({
|
||||||
|
markdown: fallback,
|
||||||
|
mode: 'simple',
|
||||||
|
warning: `긴 회의 정리에 실패하여 단순 변환으로 대체되었습니다: ${condensed.error}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
input.transcript = condensed.text
|
||||||
|
condensedNotice =
|
||||||
|
`긴 회의(${transcript.length.toLocaleString()}자)라 ${condensed.windows}개 구간으로 ` +
|
||||||
|
'나눠 정리한 뒤 회의록을 작성했습니다.'
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await generateAiMinutes(input, { provider: settings })
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
const fallback = generateSimpleMinutes(input)
|
const fallback = generateSimpleMinutes(input)
|
||||||
return Response.json({
|
return Response.json({
|
||||||
markdown: fallback,
|
markdown: fallback,
|
||||||
mode: 'simple',
|
mode: 'simple',
|
||||||
warning: `Gemini 요약에 실패하여 단순 변환으로 대체되었습니다: ${result.error}`,
|
warning: `AI 요약에 실패하여 단순 변환으로 대체되었습니다: ${result.error}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return Response.json({ markdown: result.markdown, mode: 'gemini' })
|
return Response.json({
|
||||||
|
markdown: result.markdown,
|
||||||
|
mode: 'gemini',
|
||||||
|
...(condensedNotice ? { warning: condensedNotice } : {}),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const markdown = generateSimpleMinutes(input)
|
const markdown = generateSimpleMinutes(input)
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { NextRequest } from 'next/server'
|
||||||
|
import { readFile } from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { resolveTranscriptionSettings } from '@/lib/api-keys'
|
||||||
|
import { resolveSttModel, transcribe } from '@/lib/providers'
|
||||||
|
import { getRecording, RECORDINGS_DIR } from '@/lib/recording-store'
|
||||||
|
import { isValidRecordingId } from '@/lib/recording'
|
||||||
|
import { formatTranscriptionSegments } from '@/lib/transcript-formatter'
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/** 어휘 힌트는 Whisper가 약 224토큰까지만 본다. 넘겨도 버려지므로 잘라 보낸다. */
|
||||||
|
const MAX_VOCABULARY_HINT_CHARS = 800
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json().catch(() => ({}))
|
||||||
|
const { recordingId, language, vocabularyHint } = body
|
||||||
|
|
||||||
|
if (!isValidRecordingId(recordingId)) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: '잘못된 녹음 세션 ID입니다.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = await getRecording(recordingId)
|
||||||
|
if (!meta) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: '녹음을 찾을 수 없습니다.' },
|
||||||
|
{ status: 404 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = resolveTranscriptionSettings(body)
|
||||||
|
if (!settings) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
'AI 프로바이더가 설정되지 않았습니다. /settings에서 먼저 설정해주세요.',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes: Buffer
|
||||||
|
try {
|
||||||
|
bytes = await readFile(path.join(RECORDINGS_DIR, meta.fileName))
|
||||||
|
} catch {
|
||||||
|
return Response.json(
|
||||||
|
{ error: '녹음 파일을 읽을 수 없습니다. 오디오가 저장되지 않았을 수 있습니다.' },
|
||||||
|
{ status: 404 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes.byteLength === 0) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: '녹음 파일이 비어 있습니다.' },
|
||||||
|
{ status: 400 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await transcribe(
|
||||||
|
{
|
||||||
|
bytes,
|
||||||
|
mimeType: meta.mimeType,
|
||||||
|
fileName: meta.fileName,
|
||||||
|
language: typeof language === 'string' && language ? language : 'ko',
|
||||||
|
vocabularyHint:
|
||||||
|
typeof vocabularyHint === 'string' && vocabularyHint.trim().length > 0
|
||||||
|
? vocabularyHint.trim().slice(0, MAX_VOCABULARY_HINT_CHARS)
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return Response.json(
|
||||||
|
{ error: result.error },
|
||||||
|
{ status: result.rateLimited ? 429 : 502 },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 구간 타임스탬프가 있으면 `[MM:SS]` 형식으로 맞춘다. 없으면 원문 그대로.
|
||||||
|
const transcript =
|
||||||
|
result.segments.length > 0
|
||||||
|
? formatTranscriptionSegments(result.segments)
|
||||||
|
: result.text
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
transcript,
|
||||||
|
text: result.text,
|
||||||
|
segments: result.segments,
|
||||||
|
model: result.model,
|
||||||
|
hasTimestamps: result.segments.length > 0,
|
||||||
|
audioBytes: bytes.byteLength,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||||
|
return Response.json({ error: `전사 실패: ${message}` }, { status: 500 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이 프로바이더 설정으로 전사가 가능한지 미리 확인. */
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const settings = resolveTranscriptionSettings({
|
||||||
|
provider: url.searchParams.get('provider'),
|
||||||
|
baseUrl: url.searchParams.get('baseUrl'),
|
||||||
|
apiKey: url.searchParams.get('apiKey'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return Response.json({
|
||||||
|
configured: settings !== null,
|
||||||
|
model: settings ? resolveSttModel(settings) : null,
|
||||||
|
})
|
||||||
|
}
|
||||||
+22
-10
@@ -1,10 +1,20 @@
|
|||||||
import { NextRequest } from 'next/server'
|
import { NextRequest } from 'next/server'
|
||||||
import { handleUpload } from '@/lib/upload-handler'
|
import { handleUpload } from '@/lib/upload-handler'
|
||||||
import { writeFile, mkdir } from 'fs/promises'
|
import {
|
||||||
import path from 'path'
|
appendChunk,
|
||||||
|
createRecording,
|
||||||
|
finalizeRecording,
|
||||||
|
} from '@/lib/recording-store'
|
||||||
|
|
||||||
const UPLOAD_DIR = path.join(process.cwd(), 'uploads')
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 오디오 파일 업로드.
|
||||||
|
*
|
||||||
|
* 예전에는 `uploads/`에 파일만 떨궈 두고 아무것도 하지 않는 막다른 길이었다.
|
||||||
|
* 지금은 녹음 저장소에 그대로 넣어 `recordingId`를 돌려주므로, 브라우저 녹음과
|
||||||
|
* 똑같이 `/api/transcribe`로 전사할 수 있다. 두 경로가 한 파이프라인으로 만난다.
|
||||||
|
*/
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const formData = await request.formData()
|
const formData = await request.formData()
|
||||||
@@ -15,19 +25,21 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const audioFile = formData.get('audio') as File
|
const audioFile = formData.get('audio') as File
|
||||||
const buffer = Buffer.from(await audioFile.arrayBuffer())
|
const bytes = Buffer.from(await audioFile.arrayBuffer())
|
||||||
|
|
||||||
await mkdir(UPLOAD_DIR, { recursive: true })
|
const meta = await createRecording(result.data.mimeType)
|
||||||
|
const appended = await appendChunk(meta.id, bytes)
|
||||||
|
|
||||||
const timestamp = Date.now()
|
if (!appended.ok) {
|
||||||
const safeFileName = `${timestamp}_${result.data.fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`
|
return Response.json({ error: appended.error }, { status: appended.status })
|
||||||
const filePath = path.join(UPLOAD_DIR, safeFileName)
|
}
|
||||||
|
|
||||||
await writeFile(filePath, buffer)
|
await finalizeRecording(meta.id, null)
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
...result.data,
|
...result.data,
|
||||||
savedAs: safeFileName,
|
recordingId: meta.id,
|
||||||
|
savedAs: meta.fileName,
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||||
|
|||||||
+95
-8
@@ -1,11 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { AudioUploader } from '@/components/upload/AudioUploader'
|
import { AudioUploader } from '@/components/upload/AudioUploader'
|
||||||
import { LiveRecorder } from '@/components/recorder/LiveRecorder'
|
import { LiveRecorder } from '@/components/recorder/LiveRecorder'
|
||||||
|
import type { CompletedRecording } from '@/hooks/useAudioRecorder'
|
||||||
|
import { extensionForMimeType } from '@/lib/recording'
|
||||||
|
import { useTranscription } from '@/hooks/useTranscription'
|
||||||
import { MinutesViewer } from '@/components/minutes/MinutesViewer'
|
import { MinutesViewer } from '@/components/minutes/MinutesViewer'
|
||||||
import { getStoredApiKey } from '@/lib/api-key-storage'
|
import { getProviderRequestPayload } from '@/lib/api-key-storage'
|
||||||
import {
|
import {
|
||||||
TEMPLATES,
|
TEMPLATES,
|
||||||
DEFAULT_TEMPLATE_ID,
|
DEFAULT_TEMPLATE_ID,
|
||||||
@@ -43,6 +46,9 @@ export default function HomePage() {
|
|||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
|
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
|
||||||
|
const [audio, setAudio] = useState<CompletedRecording | null>(null)
|
||||||
|
const [uploadedRecordingId, setUploadedRecordingId] = useState<string | null>(null)
|
||||||
|
const [attendees, setAttendees] = useState('')
|
||||||
|
|
||||||
const templateList = useMemo(
|
const templateList = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -77,6 +83,7 @@ export default function HomePage() {
|
|||||||
|
|
||||||
async function handleUpload(file: File) {
|
async function handleUpload(file: File) {
|
||||||
setUploadedFile(file)
|
setUploadedFile(file)
|
||||||
|
setUploadedRecordingId(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
@@ -93,6 +100,7 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!title) setTitle(data.title)
|
if (!title) setTitle(data.title)
|
||||||
|
setUploadedRecordingId(data.recordingId ?? null)
|
||||||
} catch {
|
} catch {
|
||||||
setError('파일 업로드에 실패했습니다.')
|
setError('파일 업로드에 실패했습니다.')
|
||||||
}
|
}
|
||||||
@@ -119,7 +127,7 @@ export default function HomePage() {
|
|||||||
template,
|
template,
|
||||||
depth,
|
depth,
|
||||||
customPrompt: template === 'custom' ? customPrompt : undefined,
|
customPrompt: template === 'custom' ? customPrompt : undefined,
|
||||||
apiKey: getStoredApiKey(),
|
...getProviderRequestPayload(),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -146,6 +154,37 @@ export default function HomePage() {
|
|||||||
generateMinutes(text, summaryMode)
|
generateMinutes(text, summaryMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRecordingReady = useCallback((recording: CompletedRecording) => {
|
||||||
|
setAudio(recording)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const {
|
||||||
|
isTranscribing: isTranscribingUpload,
|
||||||
|
error: uploadTranscribeError,
|
||||||
|
result: uploadTranscription,
|
||||||
|
transcribeRecording: transcribeUpload,
|
||||||
|
} = useTranscription()
|
||||||
|
|
||||||
|
async function transcribeUploadedFile() {
|
||||||
|
if (!uploadedRecordingId) return
|
||||||
|
const outcome = await transcribeUpload(uploadedRecordingId, {
|
||||||
|
vocabularyHint: attendees,
|
||||||
|
})
|
||||||
|
if (outcome?.transcript) {
|
||||||
|
setTranscript(outcome.transcript)
|
||||||
|
generateMinutes(outcome.transcript, summaryMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 회의록과 함께 저장할 오디오 정보. 서버 사본이 없으면 붙일 게 없다.
|
||||||
|
const audioMeta = audio?.recordingId
|
||||||
|
? {
|
||||||
|
audioFileName: `${audio.recordingId}.${extensionForMimeType(audio.mimeType)}`,
|
||||||
|
audioMimeType: audio.mimeType,
|
||||||
|
audioDuration: Math.round(audio.durationMs / 1000),
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
const liveSummaryActive =
|
const liveSummaryActive =
|
||||||
summaryMode === 'gemini' && activeTemplateMeta.live && tab === 'record'
|
summaryMode === 'gemini' && activeTemplateMeta.live && tab === 'record'
|
||||||
|
|
||||||
@@ -188,6 +227,22 @@ export default function HomePage() {
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="mb-8">
|
||||||
|
<label className="block text-sm font-medium text-neutral-600 mb-2">
|
||||||
|
참석자 · 용어 <span className="font-normal text-neutral-400">(선택)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={attendees}
|
||||||
|
onChange={(e) => setAttendees(e.target.value)}
|
||||||
|
placeholder="예: 김효천, 이지안, 계명대동산병원, PACS"
|
||||||
|
className="w-full rounded-xl border border-neutral-300 px-4 py-3 text-sm text-neutral-800 placeholder:text-neutral-400 focus:border-blue-400 focus:outline-none focus:ring-2 focus:ring-blue-100 transition-all"
|
||||||
|
/>
|
||||||
|
<p className="mt-1.5 text-xs text-neutral-500">
|
||||||
|
이름·제품명·사내 용어를 적어두면 전사가 고유명사를 훨씬 정확히 잡습니다.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="mb-8 space-y-5">
|
<section className="mb-8 space-y-5">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<label className="text-sm font-medium text-neutral-600 min-w-20">
|
<label className="text-sm font-medium text-neutral-600 min-w-20">
|
||||||
@@ -212,7 +267,7 @@ export default function HomePage() {
|
|||||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
|
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Gemini AI 요약
|
AI 요약
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -328,6 +383,10 @@ export default function HomePage() {
|
|||||||
{tab === 'record' ? (
|
{tab === 'record' ? (
|
||||||
<LiveRecorder
|
<LiveRecorder
|
||||||
onTranscriptReady={handleTranscriptReady}
|
onTranscriptReady={handleTranscriptReady}
|
||||||
|
onRecordingReady={handleRecordingReady}
|
||||||
|
onTranscriptReplaced={setTranscript}
|
||||||
|
title={title}
|
||||||
|
vocabularyHint={attendees}
|
||||||
liveSummaryEnabled={liveSummaryActive}
|
liveSummaryEnabled={liveSummaryActive}
|
||||||
template={template}
|
template={template}
|
||||||
depth={depth}
|
depth={depth}
|
||||||
@@ -352,12 +411,39 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{uploadedFile && !transcript && (
|
{uploadedFile && (
|
||||||
<section className="mb-8">
|
<section className="mb-8 space-y-3">
|
||||||
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-700">
|
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-700">
|
||||||
<strong>{uploadedFile.name}</strong> 업로드 완료.
|
<strong>{uploadedFile.name}</strong> 업로드 완료
|
||||||
실시간 녹음 탭에서 음성 인식을 시작하거나, 텍스트를 직접 입력해주세요.
|
{uploadedRecordingId
|
||||||
|
? ' — 아래 버튼으로 전사를 시작하세요.'
|
||||||
|
: ' — 서버 저장에 실패해 전사할 수 없습니다.'}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{uploadedRecordingId && (
|
||||||
|
<button
|
||||||
|
onClick={transcribeUploadedFile}
|
||||||
|
disabled={isTranscribingUpload}
|
||||||
|
className="w-full rounded-xl bg-neutral-900 px-6 py-3 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{isTranscribingUpload
|
||||||
|
? '전사 중… 회의 길이에 따라 몇 분 걸릴 수 있습니다'
|
||||||
|
: '🔤 전사 시작'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadTranscribeError && (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
<strong>전사 오류:</strong> {uploadTranscribeError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadTranscription && (
|
||||||
|
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">
|
||||||
|
🔤 전사 완료 — {uploadTranscription.model} 모델,{' '}
|
||||||
|
{uploadTranscription.segments.length}개 구간
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -391,6 +477,7 @@ export default function HomePage() {
|
|||||||
depth={depth}
|
depth={depth}
|
||||||
customPrompt={template === 'custom' ? customPrompt : undefined}
|
customPrompt={template === 'custom' ? customPrompt : undefined}
|
||||||
summaryMode={summaryMode}
|
summaryMode={summaryMode}
|
||||||
|
audio={audioMeta}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+228
-78
@@ -4,71 +4,131 @@ import Link from 'next/link'
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
clearStoredApiKey,
|
clearStoredApiKey,
|
||||||
|
clearStoredProviderConfig,
|
||||||
getStoredApiKey,
|
getStoredApiKey,
|
||||||
|
getStoredProviderConfig,
|
||||||
maskApiKey,
|
maskApiKey,
|
||||||
setStoredApiKey,
|
setStoredProviderConfig,
|
||||||
} from '@/lib/api-key-storage'
|
} from '@/lib/api-key-storage'
|
||||||
|
import {
|
||||||
|
DEFAULT_PRESET_ID,
|
||||||
|
PROVIDER_PRESETS,
|
||||||
|
findPreset,
|
||||||
|
} from '@/lib/providers'
|
||||||
|
|
||||||
type TestState =
|
type TestState =
|
||||||
| { status: 'idle' }
|
| { status: 'idle' }
|
||||||
| { status: 'testing' }
|
| { status: 'testing' }
|
||||||
| { status: 'success'; message: string; reply?: string }
|
| { status: 'success'; message: string; model?: string; reply?: string }
|
||||||
| { status: 'failed'; message: string }
|
| { status: 'failed'; message: string }
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const [storedKey, setStoredKey] = useState<string | null>(null)
|
const [presetId, setPresetId] = useState(DEFAULT_PRESET_ID)
|
||||||
const [draftKey, setDraftKey] = useState('')
|
const [apiKey, setApiKey] = useState('')
|
||||||
|
const [baseUrl, setBaseUrl] = useState('')
|
||||||
|
const [model, setModel] = useState('')
|
||||||
|
const [sttModel, setSttModel] = useState('')
|
||||||
const [showKey, setShowKey] = useState(false)
|
const [showKey, setShowKey] = useState(false)
|
||||||
|
const [savedKey, setSavedKey] = useState<string | null>(null)
|
||||||
const [envConfigured, setEnvConfigured] = useState<boolean | null>(null)
|
const [envConfigured, setEnvConfigured] = useState<boolean | null>(null)
|
||||||
const [testState, setTestState] = useState<TestState>({ status: 'idle' })
|
const [testState, setTestState] = useState<TestState>({ status: 'idle' })
|
||||||
const [savedToast, setSavedToast] = useState(false)
|
const [savedToast, setSavedToast] = useState(false)
|
||||||
|
|
||||||
|
const preset = findPreset(presetId)
|
||||||
|
const isOpenAiCompatible = preset.provider === 'openai-compatible'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setStoredKey(getStoredApiKey())
|
const stored = getStoredProviderConfig()
|
||||||
|
if (stored) {
|
||||||
|
const storedPreset = findPreset(stored.presetId)
|
||||||
|
setPresetId(storedPreset.id)
|
||||||
|
setApiKey(stored.apiKey)
|
||||||
|
setBaseUrl(stored.baseUrl)
|
||||||
|
setModel(stored.model)
|
||||||
|
setSttModel(stored.sttModel ?? '')
|
||||||
|
setSavedKey(stored.apiKey || null)
|
||||||
|
} else {
|
||||||
|
// 프로바이더 설정 이전에 저장해둔 Gemini 키를 그대로 이어받는다.
|
||||||
|
const legacyKey = getStoredApiKey()
|
||||||
|
setApiKey(legacyKey ?? '')
|
||||||
|
const initial = findPreset(DEFAULT_PRESET_ID)
|
||||||
|
setModel(initial.defaultModel)
|
||||||
|
setSttModel(initial.defaultSttModel)
|
||||||
|
setSavedKey(legacyKey)
|
||||||
|
}
|
||||||
|
|
||||||
fetch('/api/settings/status')
|
fetch('/api/settings/status')
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((d) => setEnvConfigured(!!d.envConfigured))
|
.then((d) => setEnvConfigured(!!d.envConfigured || !!d.envProviderConfigured))
|
||||||
.catch(() => setEnvConfigured(false))
|
.catch(() => setEnvConfigured(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
function handlePresetChange(nextId: string) {
|
||||||
|
const next = findPreset(nextId)
|
||||||
|
if (next.id === presetId) return
|
||||||
|
|
||||||
|
setPresetId(next.id)
|
||||||
|
setBaseUrl(next.baseUrl)
|
||||||
|
setModel(next.defaultModel)
|
||||||
|
setSttModel(next.defaultSttModel)
|
||||||
|
// 프로바이더가 바뀌면 이전 키는 무의미할 뿐 아니라, 그대로 두면
|
||||||
|
// 다른 회사 엔드포인트로 전송될 수 있으므로 비운다.
|
||||||
|
setApiKey('')
|
||||||
|
setTestState({ status: 'idle' })
|
||||||
|
}
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
const trimmed = draftKey.trim()
|
setStoredProviderConfig({
|
||||||
if (!trimmed) return
|
presetId,
|
||||||
setStoredApiKey(trimmed)
|
apiKey: apiKey.trim(),
|
||||||
setStoredKey(trimmed)
|
baseUrl: baseUrl.trim(),
|
||||||
setDraftKey('')
|
model: model.trim(),
|
||||||
|
sttModel: sttModel.trim(),
|
||||||
|
})
|
||||||
|
setSavedKey(apiKey.trim() || null)
|
||||||
setSavedToast(true)
|
setSavedToast(true)
|
||||||
setTimeout(() => setSavedToast(false), 2000)
|
setTimeout(() => setSavedToast(false), 2000)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClear() {
|
function handleClear() {
|
||||||
if (!confirm('브라우저에 저장된 키를 삭제할까요? 환경변수가 설정되어 있다면 그것을 사용합니다.')) {
|
if (
|
||||||
|
!confirm(
|
||||||
|
'브라우저에 저장된 프로바이더 설정과 키를 삭제할까요? 환경변수가 설정되어 있다면 그것을 사용합니다.',
|
||||||
|
)
|
||||||
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
clearStoredProviderConfig()
|
||||||
clearStoredApiKey()
|
clearStoredApiKey()
|
||||||
setStoredKey(null)
|
const fallback = findPreset(DEFAULT_PRESET_ID)
|
||||||
|
setPresetId(fallback.id)
|
||||||
|
setApiKey('')
|
||||||
|
setBaseUrl(fallback.baseUrl)
|
||||||
|
setModel(fallback.defaultModel)
|
||||||
|
setSttModel(fallback.defaultSttModel)
|
||||||
|
setSavedKey(null)
|
||||||
setTestState({ status: 'idle' })
|
setTestState({ status: 'idle' })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTest() {
|
async function handleTest() {
|
||||||
const keyToTest = draftKey.trim() || storedKey
|
|
||||||
if (!keyToTest) {
|
|
||||||
setTestState({ status: 'failed', message: '테스트할 키가 없습니다.' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setTestState({ status: 'testing' })
|
setTestState({ status: 'testing' })
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/settings/test', {
|
const res = await fetch('/api/settings/test', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ apiKey: keyToTest }),
|
body: JSON.stringify({
|
||||||
|
provider: preset.provider,
|
||||||
|
apiKey: apiKey.trim(),
|
||||||
|
baseUrl: baseUrl.trim(),
|
||||||
|
model: model.trim(),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
setTestState({
|
setTestState({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
message: data.message,
|
message: data.message,
|
||||||
|
model: data.model,
|
||||||
reply: data.reply,
|
reply: data.reply,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -82,13 +142,18 @@ export default function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeSource = storedKey
|
const canTest =
|
||||||
|
testState.status !== 'testing' &&
|
||||||
|
(preset.apiKeyOptional || apiKey.trim().length > 0) &&
|
||||||
|
(!isOpenAiCompatible || (baseUrl.trim().length > 0 && model.trim().length > 0))
|
||||||
|
|
||||||
|
const activeSource = savedKey
|
||||||
? '브라우저 (LocalStorage)'
|
? '브라우저 (LocalStorage)'
|
||||||
: envConfigured
|
: envConfigured
|
||||||
? '환경변수 (서버)'
|
? '환경변수 (서버)'
|
||||||
: '없음'
|
: '없음'
|
||||||
|
|
||||||
const activeBadgeStyle = storedKey
|
const activeBadgeStyle = savedKey
|
||||||
? 'bg-purple-100 text-purple-700'
|
? 'bg-purple-100 text-purple-700'
|
||||||
: envConfigured
|
: envConfigured
|
||||||
? 'bg-green-100 text-green-700'
|
? 'bg-green-100 text-green-700'
|
||||||
@@ -117,15 +182,15 @@ export default function SettingsPage() {
|
|||||||
⚙️ 설정
|
⚙️ 설정
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-neutral-500">
|
<p className="mt-2 text-sm text-neutral-500">
|
||||||
Gemini API 키를 브라우저에 저장합니다. 키는 서버에 저장되지 않으며,
|
AI 요약에 사용할 프로바이더와 키를 브라우저에 저장합니다. 키는 서버 DB에
|
||||||
요청 시점에만 전송되어 사용됩니다.
|
저장되지 않으며, 요청 시점에만 전송되어 사용됩니다.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-6">
|
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-6">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h2 className="text-base font-semibold text-neutral-800">
|
<h2 className="text-base font-semibold text-neutral-800">
|
||||||
현재 키 소스
|
현재 설정
|
||||||
</h2>
|
</h2>
|
||||||
<span
|
<span
|
||||||
className={`text-xs px-2.5 py-1 rounded-full font-medium ${activeBadgeStyle}`}
|
className={`text-xs px-2.5 py-1 rounded-full font-medium ${activeBadgeStyle}`}
|
||||||
@@ -135,16 +200,28 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1 text-sm text-neutral-600">
|
<div className="space-y-1 text-sm text-neutral-600">
|
||||||
|
<p>
|
||||||
|
<span className="inline-block w-32 text-neutral-500">프로바이더:</span>{' '}
|
||||||
|
<span className="font-medium text-neutral-800">{preset.label}</span>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="inline-block w-32 text-neutral-500">모델:</span>{' '}
|
||||||
|
{model.trim() ? (
|
||||||
|
<span className="font-mono text-xs">{model.trim()}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-400">기본값</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span className="inline-block w-32 text-neutral-500">브라우저 키:</span>{' '}
|
<span className="inline-block w-32 text-neutral-500">브라우저 키:</span>{' '}
|
||||||
{storedKey ? (
|
{savedKey ? (
|
||||||
<span className="font-mono">{maskApiKey(storedKey)}</span>
|
<span className="font-mono">{maskApiKey(savedKey)}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-neutral-400">미설정</span>
|
<span className="text-neutral-400">미설정</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span className="inline-block w-32 text-neutral-500">환경변수 키:</span>{' '}
|
<span className="inline-block w-32 text-neutral-500">환경변수:</span>{' '}
|
||||||
{envConfigured === null ? (
|
{envConfigured === null ? (
|
||||||
<span className="text-neutral-400">확인 중...</span>
|
<span className="text-neutral-400">확인 중...</span>
|
||||||
) : envConfigured ? (
|
) : envConfigured ? (
|
||||||
@@ -156,73 +233,144 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-3 text-xs text-neutral-500">
|
<p className="mt-3 text-xs text-neutral-500">
|
||||||
💡 우선순위: 브라우저 키 → 환경변수 → 단순 변환 폴백
|
💡 우선순위: 브라우저 설정 → 환경변수 → 단순 변환 폴백
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-6">
|
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-6">
|
||||||
<h2 className="mb-4 text-base font-semibold text-neutral-800">
|
<h2 className="mb-4 text-base font-semibold text-neutral-800">
|
||||||
Gemini API 키
|
프로바이더
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<label className="block mb-2 text-sm text-neutral-600">
|
<div className="flex flex-wrap gap-2">
|
||||||
새 키 입력
|
{PROVIDER_PRESETS.map((item) => (
|
||||||
</label>
|
<button
|
||||||
<div className="flex gap-2">
|
key={item.id}
|
||||||
<input
|
onClick={() => handlePresetChange(item.id)}
|
||||||
type={showKey ? 'text' : 'password'}
|
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-all ${
|
||||||
value={draftKey}
|
presetId === item.id
|
||||||
onChange={(e) => setDraftKey(e.target.value)}
|
? 'border-purple-400 bg-purple-50 text-purple-700'
|
||||||
placeholder="AIzaSy..."
|
: 'border-neutral-200 bg-white text-neutral-600 hover:border-neutral-300 hover:bg-neutral-50'
|
||||||
className="flex-1 rounded-xl border border-neutral-300 px-4 py-3 font-mono text-sm focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all"
|
}`}
|
||||||
/>
|
>
|
||||||
<button
|
{item.label}
|
||||||
onClick={() => setShowKey((v) => !v)}
|
</button>
|
||||||
className="rounded-xl border border-neutral-300 px-3 text-sm hover:bg-neutral-50 transition-colors"
|
))}
|
||||||
title={showKey ? '숨기기' : '보이기'}
|
|
||||||
>
|
|
||||||
{showKey ? '🙈' : '👁️'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-2 text-xs text-neutral-500">
|
<p className="mt-3 text-xs text-neutral-500">{preset.description}</p>
|
||||||
<a
|
|
||||||
href="https://aistudio.google.com/apikey"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline hover:text-purple-600"
|
|
||||||
>
|
|
||||||
Google AI Studio
|
|
||||||
</a>
|
|
||||||
에서 무료로 발급할 수 있습니다.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
{isOpenAiCompatible && (
|
||||||
|
<div className="mt-5">
|
||||||
|
<label className="mb-2 block text-sm text-neutral-600">
|
||||||
|
API 주소 (base URL)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
|
placeholder="https://api.example.com/v1"
|
||||||
|
className="w-full rounded-xl border border-neutral-300 px-4 py-3 font-mono text-sm focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
OpenAI 호환 엔드포인트의 <code>/chat/completions</code> 앞부분까지
|
||||||
|
입력하세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<label className="mb-2 block text-sm text-neutral-600">모델</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
placeholder={preset.defaultModel || 'model-id'}
|
||||||
|
className="w-full rounded-xl border border-neutral-300 px-4 py-3 font-mono text-sm focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all"
|
||||||
|
/>
|
||||||
|
{preset.modelHint && (
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">{preset.modelHint}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<label className="mb-2 block text-sm text-neutral-600">
|
||||||
|
음성 전사(STT) 모델
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={sttModel}
|
||||||
|
onChange={(e) => setSttModel(e.target.value)}
|
||||||
|
placeholder={preset.defaultSttModel || 'whisper-1'}
|
||||||
|
className="w-full rounded-xl border border-neutral-300 px-4 py-3 font-mono text-sm focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-neutral-500">
|
||||||
|
녹음 오디오를 텍스트로 옮길 때 쓰는 모델입니다. 요약 모델과 별개입니다.
|
||||||
|
</p>
|
||||||
|
{!preset.canTranscribeWebm && (
|
||||||
|
<p className="mt-2 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||||
|
⚠️ 이 프로바이더는 브라우저 녹음 형식(webm)을 받지 않습니다. 실시간
|
||||||
|
녹음을 전사하려면 OpenAI 호환 프로바이더(OrcaRouter · OpenAI ·
|
||||||
|
로컬 whisper)를 선택하세요. 업로드한 mp3·wav·flac 파일은 전사됩니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
<label className="mb-2 block text-sm text-neutral-600">
|
||||||
|
{preset.apiKeyLabel}
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type={showKey ? 'text' : 'password'}
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
placeholder={preset.apiKeyPlaceholder}
|
||||||
|
className="flex-1 rounded-xl border border-neutral-300 px-4 py-3 font-mono text-sm focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowKey((v) => !v)}
|
||||||
|
className="rounded-xl border border-neutral-300 px-3 text-sm hover:bg-neutral-50 transition-colors"
|
||||||
|
title={showKey ? '숨기기' : '보이기'}
|
||||||
|
>
|
||||||
|
{showKey ? '🙈' : '👁️'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{preset.docsUrl && (
|
||||||
|
<p className="mt-2 text-xs text-neutral-500">
|
||||||
|
<a
|
||||||
|
href={preset.docsUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-purple-600"
|
||||||
|
>
|
||||||
|
{preset.docsLabel ?? preset.docsUrl}
|
||||||
|
</a>
|
||||||
|
에서 발급할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!draftKey.trim()}
|
|
||||||
className="rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
className="rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
💾 저장
|
💾 저장
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleTest}
|
onClick={handleTest}
|
||||||
disabled={
|
disabled={!canTest}
|
||||||
testState.status === 'testing' ||
|
|
||||||
(!draftKey.trim() && !storedKey)
|
|
||||||
}
|
|
||||||
className="rounded-lg border border-purple-300 bg-purple-50 px-4 py-2 text-sm font-medium text-purple-700 hover:bg-purple-100 disabled:opacity-50 transition-colors"
|
className="rounded-lg border border-purple-300 bg-purple-50 px-4 py-2 text-sm font-medium text-purple-700 hover:bg-purple-100 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{testState.status === 'testing' ? '테스트 중...' : '🧪 테스트 호출'}
|
{testState.status === 'testing' ? '테스트 중...' : '🧪 테스트 호출'}
|
||||||
</button>
|
</button>
|
||||||
{storedKey && (
|
<button
|
||||||
<button
|
onClick={handleClear}
|
||||||
onClick={handleClear}
|
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100 transition-colors"
|
||||||
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100 transition-colors"
|
>
|
||||||
>
|
🗑️ 삭제
|
||||||
🗑️ 삭제
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{savedToast && (
|
{savedToast && (
|
||||||
@@ -234,11 +382,10 @@ export default function SettingsPage() {
|
|||||||
{testState.status === 'success' && (
|
{testState.status === 'success' && (
|
||||||
<div className="mt-4 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
|
<div className="mt-4 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
|
||||||
<strong>✓ {testState.message}</strong>
|
<strong>✓ {testState.message}</strong>
|
||||||
{testState.reply && (
|
<p className="mt-1 font-mono text-xs text-green-600">
|
||||||
<p className="mt-1 font-mono text-xs text-green-600">
|
{testState.model ? `${testState.model} → ` : ''}
|
||||||
Gemini 응답: {testState.reply}
|
{testState.reply}
|
||||||
</p>
|
</p>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -265,6 +412,9 @@ export default function SettingsPage() {
|
|||||||
<li className="text-amber-700">
|
<li className="text-amber-700">
|
||||||
⚠️ <strong>회의 내용에 민감 정보가 포함된 경우</strong>, 실시간 녹음 기능은 음성을 Google 서버로 전송합니다 (Chrome Web Speech API 동작). 사내 컴플라이언스 정책 확인 후 사용해주세요.
|
⚠️ <strong>회의 내용에 민감 정보가 포함된 경우</strong>, 실시간 녹음 기능은 음성을 Google 서버로 전송합니다 (Chrome Web Speech API 동작). 사내 컴플라이언스 정책 확인 후 사용해주세요.
|
||||||
</li>
|
</li>
|
||||||
|
<li className="text-amber-700">
|
||||||
|
⚠️ <strong>중계 서비스를 고르면 회의 전문이 그 회사 서버를 거칩니다.</strong> OpenAI·Gemini 직접 호출은 해당 회사만, OrcaRouter 같은 라우터는 라우터 운영사 + 실제 모델 제공사 양쪽을 거칩니다. 외부 전송이 곤란하면 <strong>로컬 모델</strong>을 선택하세요.
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ interface MinutesViewerProps {
|
|||||||
depth?: SummaryDepth
|
depth?: SummaryDepth
|
||||||
customPrompt?: string
|
customPrompt?: string
|
||||||
summaryMode?: 'simple' | 'gemini'
|
summaryMode?: 'simple' | 'gemini'
|
||||||
|
/** 이 회의록의 원본 오디오. 서버에 보관된 녹음이 있을 때만 붙는다. */
|
||||||
|
audio?: {
|
||||||
|
audioFileName: string
|
||||||
|
audioMimeType: string
|
||||||
|
audioDuration: number
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type SaveState =
|
type SaveState =
|
||||||
@@ -38,6 +44,7 @@ export function MinutesViewer({
|
|||||||
template,
|
template,
|
||||||
depth,
|
depth,
|
||||||
customPrompt,
|
customPrompt,
|
||||||
|
audio,
|
||||||
summaryMode,
|
summaryMode,
|
||||||
}: MinutesViewerProps) {
|
}: MinutesViewerProps) {
|
||||||
const [renderedView, setRenderedView] = useState<'rendered' | 'raw'>(
|
const [renderedView, setRenderedView] = useState<'rendered' | 'raw'>(
|
||||||
@@ -110,6 +117,7 @@ export function MinutesViewer({
|
|||||||
template,
|
template,
|
||||||
depth,
|
depth,
|
||||||
customPrompt,
|
customPrompt,
|
||||||
|
...audio,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
@@ -126,7 +134,7 @@ export function MinutesViewer({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const modeLabel = mode === 'gemini' ? 'Gemini AI 요약' : '단순 변환'
|
const modeLabel = mode === 'gemini' ? 'AI 요약' : '단순 변환'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -3,11 +3,20 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
|
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
|
||||||
import { useLiveSummary } from '@/hooks/useLiveSummary'
|
import { useLiveSummary } from '@/hooks/useLiveSummary'
|
||||||
|
import { useAudioRecorder, type CompletedRecording } from '@/hooks/useAudioRecorder'
|
||||||
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
|
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
|
||||||
|
import { useTranscription } from '@/hooks/useTranscription'
|
||||||
|
import { formatBytes, formatDuration } from '@/lib/recording'
|
||||||
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
||||||
|
|
||||||
interface LiveRecorderProps {
|
interface LiveRecorderProps {
|
||||||
onTranscriptReady: (transcript: string) => void
|
onTranscriptReady: (transcript: string) => void
|
||||||
|
onRecordingReady?: (recording: CompletedRecording) => void
|
||||||
|
/** STT 재전사 결과로 전사문을 갈아끼운다. */
|
||||||
|
onTranscriptReplaced?: (transcript: string) => void
|
||||||
|
title: string
|
||||||
|
/** 참석자 등 고유명사 힌트. STT 정확도를 올린다. */
|
||||||
|
vocabularyHint?: string
|
||||||
liveSummaryEnabled: boolean
|
liveSummaryEnabled: boolean
|
||||||
template: TemplateId
|
template: TemplateId
|
||||||
depth: SummaryDepth
|
depth: SummaryDepth
|
||||||
@@ -16,6 +25,10 @@ interface LiveRecorderProps {
|
|||||||
|
|
||||||
export function LiveRecorder({
|
export function LiveRecorder({
|
||||||
onTranscriptReady,
|
onTranscriptReady,
|
||||||
|
onRecordingReady,
|
||||||
|
onTranscriptReplaced,
|
||||||
|
title,
|
||||||
|
vocabularyHint,
|
||||||
liveSummaryEnabled,
|
liveSummaryEnabled,
|
||||||
template,
|
template,
|
||||||
depth,
|
depth,
|
||||||
@@ -27,11 +40,35 @@ export function LiveRecorder({
|
|||||||
chunks,
|
chunks,
|
||||||
interimText,
|
interimText,
|
||||||
error: recognitionError,
|
error: recognitionError,
|
||||||
|
missedCount,
|
||||||
startListening,
|
startListening,
|
||||||
stopListening,
|
stopListening,
|
||||||
resetChunks,
|
resetChunks,
|
||||||
} = useSpeechRecognition()
|
} = useSpeechRecognition()
|
||||||
|
|
||||||
|
const {
|
||||||
|
isRecording,
|
||||||
|
isSupported: isRecordingSupported,
|
||||||
|
error: recordingError,
|
||||||
|
uploadWarning,
|
||||||
|
elapsedMs,
|
||||||
|
localBytes,
|
||||||
|
uploadedBytes,
|
||||||
|
recording,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
downloadRecording,
|
||||||
|
reset: resetRecording,
|
||||||
|
} = useAudioRecorder()
|
||||||
|
|
||||||
|
const {
|
||||||
|
isTranscribing,
|
||||||
|
error: transcriptionError,
|
||||||
|
result: transcription,
|
||||||
|
transcribeRecording,
|
||||||
|
reset: resetTranscription,
|
||||||
|
} = useTranscription()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
summary,
|
summary,
|
||||||
isSummarizing,
|
isSummarizing,
|
||||||
@@ -59,6 +96,11 @@ export function LiveRecorder({
|
|||||||
summaryEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
summaryEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||||
}, [summary])
|
}, [summary])
|
||||||
|
|
||||||
|
// 녹음이 끝나 파일이 확정되면 상위로 올려 회의록과 함께 저장되게 한다.
|
||||||
|
useEffect(() => {
|
||||||
|
if (recording) onRecordingReady?.(recording)
|
||||||
|
}, [recording, onRecordingReady])
|
||||||
|
|
||||||
if (isSupported === null) {
|
if (isSupported === null) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-neutral-200 bg-neutral-50 p-6 text-center text-sm text-neutral-400">
|
<div className="rounded-2xl border border-neutral-200 bg-neutral-50 p-6 text-center text-sm text-neutral-400">
|
||||||
@@ -78,12 +120,27 @@ export function LiveRecorder({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStop() {
|
async function handleStart() {
|
||||||
stopListening()
|
resetRecording()
|
||||||
const transcript = formatTranscriptChunks(chunks)
|
resetTranscription()
|
||||||
|
// 오디오 녹음을 먼저 건다. 전사가 실패하더라도 원본은 남아야 한다.
|
||||||
|
// 녹음 시작 시각을 인식기에 넘겨, 전사 타임스탬프가 오디오 파일의
|
||||||
|
// 재생 위치와 같은 시간축을 쓰게 한다.
|
||||||
|
const audioStartedAt = await startRecording()
|
||||||
|
startListening(audioStartedAt ?? undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStop() {
|
||||||
|
// 훅이 미확정 발화까지 굳혀서 돌려준다. 렌더 시점의 `chunks`를 쓰면
|
||||||
|
// 회의 마지막 문장이 빠진다.
|
||||||
|
const finalChunks = stopListening()
|
||||||
|
|
||||||
|
const transcript = formatTranscriptChunks(finalChunks)
|
||||||
if (transcript.length > 0) {
|
if (transcript.length > 0) {
|
||||||
onTranscriptReady(transcript)
|
onTranscriptReady(transcript)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await stopRecording()
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastUpdatedLabel = lastUpdatedAt
|
const lastUpdatedLabel = lastUpdatedAt
|
||||||
@@ -136,7 +193,7 @@ export function LiveRecorder({
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={startListening}
|
onClick={handleStart}
|
||||||
className="flex items-center gap-2 rounded-full bg-blue-600 px-6 py-3 text-white font-medium shadow-lg shadow-blue-600/25 hover:bg-blue-700 transition-colors"
|
className="flex items-center gap-2 rounded-full bg-blue-600 px-6 py-3 text-white font-medium shadow-lg shadow-blue-600/25 hover:bg-blue-700 transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-lg">🎤</span>
|
<span className="text-lg">🎤</span>
|
||||||
@@ -144,9 +201,37 @@ export function LiveRecorder({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{recording?.recordingId && !isListening && (
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const outcome = await transcribeRecording(recording.recordingId!, {
|
||||||
|
vocabularyHint,
|
||||||
|
})
|
||||||
|
if (outcome?.transcript) onTranscriptReplaced?.(outcome.transcript)
|
||||||
|
}}
|
||||||
|
disabled={isTranscribing}
|
||||||
|
className="flex items-center gap-2 rounded-full bg-neutral-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{isTranscribing ? '전사 중… (몇 분 걸릴 수 있습니다)' : '🔤 원본 오디오로 다시 전사'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recording && !isListening && (
|
||||||
|
<button
|
||||||
|
onClick={() => downloadRecording(title)}
|
||||||
|
className="flex items-center gap-2 rounded-full border border-emerald-300 bg-emerald-50 px-4 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-100 transition-colors"
|
||||||
|
>
|
||||||
|
⬇️ 오디오 내려받기 ({formatBytes(recording.blob.size)})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{chunks.length > 0 && !isListening && (
|
{chunks.length > 0 && !isListening && (
|
||||||
<button
|
<button
|
||||||
onClick={resetChunks}
|
onClick={() => {
|
||||||
|
resetChunks()
|
||||||
|
resetRecording()
|
||||||
|
resetTranscription()
|
||||||
|
}}
|
||||||
className="rounded-full border border-neutral-300 px-4 py-2 text-sm text-neutral-600 hover:bg-neutral-50 transition-colors"
|
className="rounded-full border border-neutral-300 px-4 py-2 text-sm text-neutral-600 hover:bg-neutral-50 transition-colors"
|
||||||
>
|
>
|
||||||
초기화
|
초기화
|
||||||
@@ -159,14 +244,87 @@ export function LiveRecorder({
|
|||||||
녹음 중 · {wordCount}단어 · {chunks.length}개 구간
|
녹음 중 · {wordCount}단어 · {chunks.length}개 구간
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{missedCount > 0 && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1.5 rounded-full bg-amber-50 px-4 py-1.5 text-xs text-amber-700"
|
||||||
|
title="Chrome이 소리는 감지했지만 무슨 말인지 인식하지 못한 구간입니다."
|
||||||
|
>
|
||||||
|
⚠️ 인식 실패 {missedCount}건
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRecording && (
|
||||||
|
<div className="flex items-center gap-2 rounded-full bg-neutral-100 px-4 py-1.5 text-xs text-neutral-600">
|
||||||
|
<span className="text-sm">🎧</span>
|
||||||
|
오디오 {formatDuration(elapsedMs)} · 저장 {formatBytes(uploadedBytes)}
|
||||||
|
{localBytes > 0 && uploadedBytes < localBytes && (
|
||||||
|
<span className="text-amber-600">
|
||||||
|
(전송 대기 {formatBytes(localBytes - uploadedBytes)})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isRecordingSupported === false && (
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||||
|
이 브라우저는 오디오 녹음을 지원하지 않습니다. 전사만 진행되며,
|
||||||
|
<strong> 놓친 발화를 나중에 복구할 수 없습니다.</strong> Chrome을 사용해주세요.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recordingError && (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
<strong>오디오 녹음 오류:</strong> {recordingError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadWarning && (
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
|
||||||
|
<strong>⚠️ 서버 저장 문제:</strong> {uploadWarning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{recognitionError && (
|
{recognitionError && (
|
||||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
<strong>음성 인식 오류:</strong> {recognitionError}
|
<strong>음성 인식 오류:</strong> {recognitionError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{transcriptionError && (
|
||||||
|
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||||
|
<strong>전사 오류:</strong> {transcriptionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{transcription && (
|
||||||
|
<div className="rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">
|
||||||
|
<strong>🔤 재전사 완료</strong> — {transcription.model} 모델,{' '}
|
||||||
|
{transcription.segments.length > 0
|
||||||
|
? `${transcription.segments.length}개 구간 (실제 오디오 타임스탬프)`
|
||||||
|
: '타임스탬프 없음'}
|
||||||
|
. 아래 텍스트가 교체되었습니다.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recording && !isListening && (
|
||||||
|
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800">
|
||||||
|
<strong>
|
||||||
|
🎧 오디오 {formatDuration(recording.durationMs)} ·{' '}
|
||||||
|
{formatBytes(recording.blob.size)} 확보
|
||||||
|
</strong>
|
||||||
|
{' — '}
|
||||||
|
{recording.recordingId
|
||||||
|
? `서버에 ${formatBytes(recording.uploadedBytes)} 저장됨.`
|
||||||
|
: '서버 저장 실패 — 브라우저 사본만 있습니다.'}{' '}
|
||||||
|
전사가 놓친 발화는 이 오디오로 다시 살릴 수 있습니다.
|
||||||
|
{!recording.recordingId && (
|
||||||
|
<strong> 지금 내려받아 보관해주세요.</strong>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isListening && !hasTranscript && (
|
{isListening && !hasTranscript && (
|
||||||
<div className="rounded-2xl border border-dashed border-blue-300 bg-blue-50/50 p-6 text-center">
|
<div className="rounded-2xl border border-dashed border-blue-300 bg-blue-50/50 p-6 text-center">
|
||||||
<p className="text-sm text-blue-700">
|
<p className="text-sm text-blue-700">
|
||||||
@@ -193,7 +351,7 @@ export function LiveRecorder({
|
|||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<h3 className="text-xs font-semibold text-neutral-600 uppercase tracking-wider">
|
<h3 className="text-xs font-semibold text-neutral-600 uppercase tracking-wider">
|
||||||
실시간 텍스트
|
실시간 텍스트 <span className="normal-case font-normal text-neutral-400">(미리보기)</span>
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-neutral-500">
|
<span className="text-xs text-neutral-500">
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
AUDIO_BITS_PER_SECOND,
|
||||||
|
CHUNK_INTERVAL_MS,
|
||||||
|
RECORDING_AUDIO_CONSTRAINTS,
|
||||||
|
pickRecorderMimeType,
|
||||||
|
recordingDownloadName,
|
||||||
|
} from '@/lib/recording'
|
||||||
|
|
||||||
|
export interface CompletedRecording {
|
||||||
|
/** 서버 세션 id. 세션 생성에 실패했으면 null이고 로컬 사본만 있다. */
|
||||||
|
recordingId: string | null
|
||||||
|
mimeType: string
|
||||||
|
blob: Blob
|
||||||
|
durationMs: number
|
||||||
|
startedAt: Date
|
||||||
|
/** 서버에 실제로 저장된 바이트. 0이거나 blob보다 작으면 일부가 못 올라갔다. */
|
||||||
|
uploadedBytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioRecorderHook {
|
||||||
|
isRecording: boolean
|
||||||
|
isSupported: boolean | null
|
||||||
|
/** 녹음을 시작조차 못하게 만든 오류. */
|
||||||
|
error: string | null
|
||||||
|
/** 녹음은 되고 있으나 서버 사본에 문제가 있을 때의 경고. */
|
||||||
|
uploadWarning: string | null
|
||||||
|
elapsedMs: number
|
||||||
|
localBytes: number
|
||||||
|
uploadedBytes: number
|
||||||
|
recording: CompletedRecording | null
|
||||||
|
/** 캡처가 시작된 시각(epoch ms). 실패하면 null. */
|
||||||
|
startRecording: () => Promise<number | null>
|
||||||
|
stopRecording: () => Promise<void>
|
||||||
|
downloadRecording: (title: string) => void
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_CHUNK_RETRIES = 3
|
||||||
|
|
||||||
|
function describeCaptureError(err: unknown): string {
|
||||||
|
const name = err instanceof Error ? err.name : ''
|
||||||
|
|
||||||
|
switch (name) {
|
||||||
|
case 'NotAllowedError':
|
||||||
|
case 'SecurityError':
|
||||||
|
return '마이크 권한이 거부되어 녹음할 수 없습니다. 주소창 왼쪽 자물쇠 아이콘에서 마이크를 허용해주세요.'
|
||||||
|
case 'NotFoundError':
|
||||||
|
return '마이크를 찾을 수 없습니다. 장치가 연결되어 있는지 확인해주세요.'
|
||||||
|
case 'NotReadableError':
|
||||||
|
return '다른 프로그램이 마이크를 사용 중입니다. 해당 프로그램을 종료하고 다시 시도해주세요.'
|
||||||
|
default:
|
||||||
|
return err instanceof Error
|
||||||
|
? `마이크를 열 수 없습니다: ${err.message}`
|
||||||
|
: '마이크를 열 수 없습니다.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회의 오디오를 파일로 남긴다.
|
||||||
|
*
|
||||||
|
* 전사와 독립적으로 동작한다. Web Speech가 발화를 놓치든 네트워크가 끊기든
|
||||||
|
* 오디오만은 남아야 나중에 서버 STT로 다시 살릴 수 있다. 그래서 서버 업로드가
|
||||||
|
* 실패해도 녹음을 중단하지 않고, 브라우저 안의 사본을 끝까지 들고 간다.
|
||||||
|
*/
|
||||||
|
export function useAudioRecorder(): AudioRecorderHook {
|
||||||
|
const [isSupported, setIsSupported] = useState<boolean | null>(null)
|
||||||
|
const [isRecording, setIsRecording] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [uploadWarning, setUploadWarning] = useState<string | null>(null)
|
||||||
|
const [elapsedMs, setElapsedMs] = useState(0)
|
||||||
|
const [localBytes, setLocalBytes] = useState(0)
|
||||||
|
const [uploadedBytes, setUploadedBytes] = useState(0)
|
||||||
|
const [recording, setRecording] = useState<CompletedRecording | null>(null)
|
||||||
|
|
||||||
|
const recorderRef = useRef<MediaRecorder | null>(null)
|
||||||
|
const streamRef = useRef<MediaStream | null>(null)
|
||||||
|
const partsRef = useRef<Blob[]>([])
|
||||||
|
const sessionIdRef = useRef<string | null>(null)
|
||||||
|
const mimeTypeRef = useRef<string | null>(null)
|
||||||
|
const startedAtRef = useRef<Date | null>(null)
|
||||||
|
const uploadChainRef = useRef<Promise<void>>(Promise.resolve())
|
||||||
|
const uploadBrokenRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsSupported(
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
typeof window.MediaRecorder !== 'undefined' &&
|
||||||
|
typeof navigator !== 'undefined' &&
|
||||||
|
!!navigator.mediaDevices?.getUserMedia,
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 녹음 중에는 1초마다 경과 시간을 갱신한다. 이 시각이 나중에 전사 청크를
|
||||||
|
// 오디오 타임라인에 맞추는 기준이 된다.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRecording) return
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (startedAtRef.current) {
|
||||||
|
setElapsedMs(Date.now() - startedAtRef.current.getTime())
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => clearInterval(timer)
|
||||||
|
}, [isRecording])
|
||||||
|
|
||||||
|
// 녹음 중 탭을 닫으면 아직 못 올린 조각이 사라진다. 확인을 한 번 받는다.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isRecording) return
|
||||||
|
|
||||||
|
function warn(event: BeforeUnloadEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', warn)
|
||||||
|
return () => window.removeEventListener('beforeunload', warn)
|
||||||
|
}, [isRecording])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조각을 순서대로 올린다.
|
||||||
|
*
|
||||||
|
* 순서가 곧 파일의 순서이므로 병렬로 보내지 않는다. 한 조각이 끝내 실패하면
|
||||||
|
* 그 뒤를 이어 붙여봐야 중간이 비어 재생도 전사도 안 되는 파일이 되므로,
|
||||||
|
* 그 시점에 업로드를 멈추고 로컬 사본을 쓰라고 알린다.
|
||||||
|
*/
|
||||||
|
const queueUpload = useCallback((chunk: Blob) => {
|
||||||
|
const id = sessionIdRef.current
|
||||||
|
if (!id || uploadBrokenRef.current) return
|
||||||
|
|
||||||
|
uploadChainRef.current = uploadChainRef.current.then(async () => {
|
||||||
|
if (uploadBrokenRef.current) return
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= MAX_CHUNK_RETRIES; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/recordings/${id}/chunk`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/octet-stream' },
|
||||||
|
body: chunk,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setUploadedBytes(data.bytes)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4xx는 재시도해도 같은 답이 온다.
|
||||||
|
if (res.status >= 400 && res.status < 500) break
|
||||||
|
} catch {
|
||||||
|
// 네트워크 오류 — 아래에서 재시도한다.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < MAX_CHUNK_RETRIES) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500 * attempt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadBrokenRef.current = true
|
||||||
|
setUploadWarning(
|
||||||
|
'서버 저장이 중단되었습니다. 녹음은 계속되고 있으니 종료 후 반드시 오디오를 내려받아 주세요.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startRecording = useCallback(async (): Promise<number | null> => {
|
||||||
|
if (recorderRef.current) return null
|
||||||
|
|
||||||
|
setError(null)
|
||||||
|
setUploadWarning(null)
|
||||||
|
setRecording(null)
|
||||||
|
setElapsedMs(0)
|
||||||
|
setLocalBytes(0)
|
||||||
|
setUploadedBytes(0)
|
||||||
|
partsRef.current = []
|
||||||
|
sessionIdRef.current = null
|
||||||
|
uploadBrokenRef.current = false
|
||||||
|
uploadChainRef.current = Promise.resolve()
|
||||||
|
|
||||||
|
if (isSupported !== true) {
|
||||||
|
setError('이 브라우저는 오디오 녹음을 지원하지 않습니다. Chrome을 사용해주세요.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const mimeType = pickRecorderMimeType((type) =>
|
||||||
|
MediaRecorder.isTypeSupported(type),
|
||||||
|
)
|
||||||
|
if (!mimeType) {
|
||||||
|
setError('브라우저가 지원하는 녹음 형식을 찾지 못했습니다.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
let stream: MediaStream
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: RECORDING_AUDIO_CONSTRAINTS,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
setError(describeCaptureError(err))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서버 세션은 있으면 좋고 없어도 녹음은 간다. 여기서 포기하면
|
||||||
|
// 오디오를 남기려던 목적 자체를 잃는다.
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/recordings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mimeType }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const meta = await res.json()
|
||||||
|
sessionIdRef.current = meta.id
|
||||||
|
} else {
|
||||||
|
uploadBrokenRef.current = true
|
||||||
|
setUploadWarning(
|
||||||
|
'서버에 녹음 세션을 만들지 못했습니다. 브라우저에만 저장되니 종료 후 반드시 내려받아 주세요.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
uploadBrokenRef.current = true
|
||||||
|
setUploadWarning(
|
||||||
|
'서버에 연결하지 못했습니다. 브라우저에만 저장되니 종료 후 반드시 내려받아 주세요.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const recorder = new MediaRecorder(stream, {
|
||||||
|
mimeType,
|
||||||
|
audioBitsPerSecond: AUDIO_BITS_PER_SECOND,
|
||||||
|
})
|
||||||
|
|
||||||
|
recorder.ondataavailable = (event) => {
|
||||||
|
if (event.data.size === 0) return
|
||||||
|
partsRef.current.push(event.data)
|
||||||
|
setLocalBytes((prev) => prev + event.data.size)
|
||||||
|
queueUpload(event.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
recorder.onerror = () => {
|
||||||
|
setError('녹음 중 오류가 발생했습니다. 지금까지의 오디오는 보존되어 있습니다.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = new Date()
|
||||||
|
streamRef.current = stream
|
||||||
|
recorderRef.current = recorder
|
||||||
|
mimeTypeRef.current = mimeType
|
||||||
|
startedAtRef.current = startedAt
|
||||||
|
|
||||||
|
recorder.start(CHUNK_INTERVAL_MS)
|
||||||
|
setIsRecording(true)
|
||||||
|
|
||||||
|
return startedAt.getTime()
|
||||||
|
}, [isSupported, queueUpload])
|
||||||
|
|
||||||
|
const stopRecording = useCallback(async () => {
|
||||||
|
const recorder = recorderRef.current
|
||||||
|
if (!recorder) return
|
||||||
|
recorderRef.current = null
|
||||||
|
|
||||||
|
// stop()은 남은 버퍼를 dataavailable로 한 번 더 내보낸 뒤 stop을 쏜다.
|
||||||
|
// 그 마지막 조각까지 받아야 회의 끝부분이 잘리지 않는다.
|
||||||
|
if (recorder.state !== 'inactive') {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
recorder.addEventListener('stop', () => resolve(), { once: true })
|
||||||
|
recorder.stop()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
streamRef.current?.getTracks().forEach((track) => track.stop())
|
||||||
|
streamRef.current = null
|
||||||
|
|
||||||
|
await uploadChainRef.current
|
||||||
|
|
||||||
|
const startedAt = startedAtRef.current ?? new Date()
|
||||||
|
const durationMs = Date.now() - startedAt.getTime()
|
||||||
|
const mimeType = mimeTypeRef.current ?? 'audio/webm'
|
||||||
|
const id = sessionIdRef.current
|
||||||
|
|
||||||
|
const blob = new Blob(partsRef.current, { type: mimeType })
|
||||||
|
|
||||||
|
// 한 바이트도 못 받았으면 보관됐다고 말하면 안 된다. 이 기능의 요점은
|
||||||
|
// 오디오가 남는 것인데, 남지 않았는데 남았다고 알리면 사용자는 회의가
|
||||||
|
// 끝난 뒤에야 아무것도 없다는 걸 알게 된다.
|
||||||
|
if (blob.size === 0) {
|
||||||
|
setError(
|
||||||
|
'오디오가 한 조각도 캡처되지 않았습니다. 마이크 입력 장치를 확인하고 다시 녹음해주세요.',
|
||||||
|
)
|
||||||
|
setElapsedMs(durationMs)
|
||||||
|
setIsRecording(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverBytes = 0
|
||||||
|
if (id && !uploadBrokenRef.current) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/recordings/${id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ durationMs }),
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
serverBytes = (await res.json()).bytes ?? 0
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setUploadWarning(
|
||||||
|
'녹음 종료 처리에 실패했습니다. 저장된 조각은 남아 있지만, 로컬 사본도 내려받아 두시길 권합니다.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 서버 사본이 로컬보다 짧으면 조각이 새어나간 것이다. 조용히 넘기지 않는다.
|
||||||
|
if (id && !uploadBrokenRef.current && serverBytes < blob.size) {
|
||||||
|
setUploadWarning(
|
||||||
|
`서버 사본이 로컬보다 짧습니다 (${serverBytes} / ${blob.size} bytes). 오디오를 내려받아 보관해주세요.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setElapsedMs(durationMs)
|
||||||
|
setUploadedBytes(serverBytes)
|
||||||
|
setRecording({
|
||||||
|
recordingId: uploadBrokenRef.current ? null : id,
|
||||||
|
mimeType,
|
||||||
|
blob,
|
||||||
|
durationMs,
|
||||||
|
startedAt,
|
||||||
|
uploadedBytes: serverBytes,
|
||||||
|
})
|
||||||
|
setIsRecording(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const downloadRecording = useCallback((title: string) => {
|
||||||
|
const parts = partsRef.current
|
||||||
|
if (parts.length === 0) return
|
||||||
|
|
||||||
|
const mimeType = mimeTypeRef.current ?? 'audio/webm'
|
||||||
|
const blob = new Blob(parts, { type: mimeType })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = recordingDownloadName(
|
||||||
|
title,
|
||||||
|
startedAtRef.current ?? new Date(),
|
||||||
|
mimeType,
|
||||||
|
)
|
||||||
|
document.body.appendChild(anchor)
|
||||||
|
anchor.click()
|
||||||
|
anchor.remove()
|
||||||
|
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1_000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
partsRef.current = []
|
||||||
|
sessionIdRef.current = null
|
||||||
|
startedAtRef.current = null
|
||||||
|
uploadBrokenRef.current = false
|
||||||
|
setRecording(null)
|
||||||
|
setElapsedMs(0)
|
||||||
|
setLocalBytes(0)
|
||||||
|
setUploadedBytes(0)
|
||||||
|
setError(null)
|
||||||
|
setUploadWarning(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 언마운트 시 마이크를 놓아준다. 탭 표시등이 켜진 채 남지 않도록.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const recorder = recorderRef.current
|
||||||
|
if (recorder && recorder.state !== 'inactive') {
|
||||||
|
recorder.stop()
|
||||||
|
}
|
||||||
|
streamRef.current?.getTracks().forEach((track) => track.stop())
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRecording,
|
||||||
|
isSupported,
|
||||||
|
error,
|
||||||
|
uploadWarning,
|
||||||
|
elapsedMs,
|
||||||
|
localBytes,
|
||||||
|
uploadedBytes,
|
||||||
|
recording,
|
||||||
|
startRecording,
|
||||||
|
stopRecording,
|
||||||
|
downloadRecording,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,16 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
|||||||
import type { TranscriptChunk } from '@/lib/transcript-formatter'
|
import type { TranscriptChunk } from '@/lib/transcript-formatter'
|
||||||
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
|
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
|
||||||
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
||||||
import { getStoredApiKey } from '@/lib/api-key-storage'
|
import { getProviderRequestPayload } from '@/lib/api-key-storage'
|
||||||
|
import { planLiveSummaryRequest } from '@/lib/live-summary'
|
||||||
|
|
||||||
interface UseLiveSummaryOptions {
|
interface UseLiveSummaryOptions {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
pollIntervalMs?: number
|
pollIntervalMs?: number
|
||||||
minWords?: number
|
minWords?: number
|
||||||
incrementWords?: number
|
incrementWords?: number
|
||||||
|
/** 증분 요약을 이 횟수만큼 반복하면 전사 전체로 한 번 다시 요약한다. 0이면 끈다. */
|
||||||
|
fullRefreshEvery?: number
|
||||||
template?: TemplateId
|
template?: TemplateId
|
||||||
depth?: SummaryDepth
|
depth?: SummaryDepth
|
||||||
customPrompt?: string
|
customPrompt?: string
|
||||||
@@ -41,6 +44,7 @@ export function useLiveSummary(
|
|||||||
pollIntervalMs = 30_000,
|
pollIntervalMs = 30_000,
|
||||||
minWords = 25,
|
minWords = 25,
|
||||||
incrementWords = 40,
|
incrementWords = 40,
|
||||||
|
fullRefreshEvery = 20,
|
||||||
template = 'meeting',
|
template = 'meeting',
|
||||||
depth,
|
depth,
|
||||||
customPrompt,
|
customPrompt,
|
||||||
@@ -64,6 +68,11 @@ export function useLiveSummary(
|
|||||||
const cooldownUntilRef = useRef<number | null>(null)
|
const cooldownUntilRef = useRef<number | null>(null)
|
||||||
const consecutiveFailuresRef = useRef(0)
|
const consecutiveFailuresRef = useRef(0)
|
||||||
|
|
||||||
|
// 증분 요약 상태 — 성공했을 때만 전진시켜서 실패해도 발화를 잃지 않는다.
|
||||||
|
const summaryRef = useRef('')
|
||||||
|
const lastSummarizedIndexRef = useRef(0)
|
||||||
|
const incrementsSinceFullRef = useRef(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
chunksRef.current = chunks
|
chunksRef.current = chunks
|
||||||
}, [chunks])
|
}, [chunks])
|
||||||
@@ -98,12 +107,31 @@ export function useLiveSummary(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const transcript = formatTranscriptChunks(chunksRef.current)
|
const allChunks = chunksRef.current
|
||||||
|
const transcript = formatTranscriptChunks(allChunks)
|
||||||
const currentWordCount = countWords(transcript)
|
const currentWordCount = countWords(transcript)
|
||||||
|
|
||||||
if (currentWordCount < minWords) return
|
if (currentWordCount < minWords) return
|
||||||
if (currentWordCount - lastWordCountRef.current < incrementWords) return
|
if (currentWordCount - lastWordCountRef.current < incrementWords) return
|
||||||
|
|
||||||
|
const plan = planLiveSummaryRequest({
|
||||||
|
totalChunks: allChunks.length,
|
||||||
|
lastSummarizedIndex: lastSummarizedIndexRef.current,
|
||||||
|
incrementsSinceFull: incrementsSinceFullRef.current,
|
||||||
|
fullRefreshEvery,
|
||||||
|
hasPreviousSummary: summaryRef.current.trim().length > 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 요청을 보내는 시점의 길이를 고정해 둔다. 응답을 기다리는 동안
|
||||||
|
// 새 청크가 쌓여도 그 부분은 다음 회차로 넘어간다.
|
||||||
|
const chunkCountAtSend = allChunks.length
|
||||||
|
const payloadTranscript =
|
||||||
|
plan.mode === 'full'
|
||||||
|
? transcript
|
||||||
|
: formatTranscriptChunks(allChunks.slice(plan.startIndex))
|
||||||
|
|
||||||
|
if (payloadTranscript.trim().length === 0) return
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
abortRef.current = controller
|
abortRef.current = controller
|
||||||
inFlightRef.current = true
|
inFlightRef.current = true
|
||||||
@@ -115,11 +143,13 @@ export function useLiveSummary(
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
transcript,
|
transcript: payloadTranscript,
|
||||||
|
previousSummary:
|
||||||
|
plan.mode === 'incremental' ? summaryRef.current : undefined,
|
||||||
template: configRef.current.template,
|
template: configRef.current.template,
|
||||||
depth: configRef.current.depth,
|
depth: configRef.current.depth,
|
||||||
customPrompt: configRef.current.customPrompt,
|
customPrompt: configRef.current.customPrompt,
|
||||||
apiKey: getStoredApiKey(),
|
...getProviderRequestPayload(),
|
||||||
}),
|
}),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
@@ -142,8 +172,12 @@ export function useLiveSummary(
|
|||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setSummary(data.markdown)
|
setSummary(data.markdown)
|
||||||
|
summaryRef.current = data.markdown
|
||||||
setLastUpdatedAt(Date.now())
|
setLastUpdatedAt(Date.now())
|
||||||
lastWordCountRef.current = currentWordCount
|
lastWordCountRef.current = currentWordCount
|
||||||
|
lastSummarizedIndexRef.current = chunkCountAtSend
|
||||||
|
incrementsSinceFullRef.current =
|
||||||
|
plan.mode === 'full' ? 0 : incrementsSinceFullRef.current + 1
|
||||||
consecutiveFailuresRef.current = 0
|
consecutiveFailuresRef.current = 0
|
||||||
cooldownUntilRef.current = null
|
cooldownUntilRef.current = null
|
||||||
setCooldownUntil(null)
|
setCooldownUntil(null)
|
||||||
@@ -164,7 +198,7 @@ export function useLiveSummary(
|
|||||||
return () => {
|
return () => {
|
||||||
clearInterval(interval)
|
clearInterval(interval)
|
||||||
}
|
}
|
||||||
}, [enabled, pollIntervalMs, minWords, incrementWords])
|
}, [enabled, pollIntervalMs, minWords, incrementWords, fullRefreshEvery])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@@ -9,8 +9,20 @@ interface SpeechRecognitionHook {
|
|||||||
chunks: TranscriptChunk[]
|
chunks: TranscriptChunk[]
|
||||||
interimText: string
|
interimText: string
|
||||||
error: string | null
|
error: string | null
|
||||||
startListening: () => void
|
/**
|
||||||
stopListening: () => void
|
* Chrome이 "소리는 들었는데 못 알아듣겠다"고 답한 횟수.
|
||||||
|
*
|
||||||
|
* `isFinal: true`에 `transcript: ""`인 결과다. 실측 46분 회의에서 19번 나왔다.
|
||||||
|
* 조용히 버리면 유실이 보이지 않으므로 세어서 노출한다.
|
||||||
|
*/
|
||||||
|
missedCount: number
|
||||||
|
/**
|
||||||
|
* @param timeOrigin 시간축의 기준점(ms). 오디오 녹음 시작 시각을 넘기면
|
||||||
|
* 전사 타임스탬프가 녹음 파일의 재생 위치와 맞는다. 생략하면 지금 시각.
|
||||||
|
*/
|
||||||
|
startListening: (timeOrigin?: number) => void
|
||||||
|
/** 미확정 발화까지 확정한 최종 청크를 돌려준다. */
|
||||||
|
stopListening: () => TranscriptChunk[]
|
||||||
resetChunks: () => void
|
resetChunks: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,16 +42,31 @@ function describeError(code: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 발화 시작을 못 잡았을 때 되돌아갈 기본 길이(초). */
|
||||||
|
const FALLBACK_UTTERANCE_SECONDS = 2
|
||||||
|
|
||||||
export function useSpeechRecognition(): SpeechRecognitionHook {
|
export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||||
const [isListening, setIsListening] = useState(false)
|
const [isListening, setIsListening] = useState(false)
|
||||||
const [chunks, setChunks] = useState<TranscriptChunk[]>([])
|
const [chunks, setChunks] = useState<TranscriptChunk[]>([])
|
||||||
const [interimText, setInterimText] = useState('')
|
const [interimText, setInterimText] = useState('')
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [isSupported, setIsSupported] = useState<boolean | null>(null)
|
const [isSupported, setIsSupported] = useState<boolean | null>(null)
|
||||||
|
const [missedCount, setMissedCount] = useState(0)
|
||||||
|
|
||||||
const recognitionRef = useRef<SpeechRecognition | null>(null)
|
const recognitionRef = useRef<SpeechRecognition | null>(null)
|
||||||
const startTimeRef = useRef<number>(0)
|
const startTimeRef = useRef<number>(0)
|
||||||
const networkRetryRef = useRef<number>(0)
|
const networkRetryRef = useRef<number>(0)
|
||||||
|
|
||||||
|
// 청크의 정본은 ref다. 인식 종료 직후 곧바로 최종 결과를 넘겨야 하는데
|
||||||
|
// setState는 비동기라 그 시점의 값을 읽을 수 없다.
|
||||||
|
const chunksRef = useRef<TranscriptChunk[]>([])
|
||||||
|
|
||||||
|
// 아직 확정되지 않은 발화. 세션이 끊기면 Chrome은 이걸 그냥 버리므로
|
||||||
|
// 우리가 들고 있다가 직접 확정시킨다.
|
||||||
|
const interimRef = useRef('')
|
||||||
|
// 현재 발화가 처음 들리기 시작한 시각(초). interim이 처음 뜰 때 기록한다.
|
||||||
|
const utteranceStartRef = useRef<number | null>(null)
|
||||||
|
|
||||||
const MAX_NETWORK_RETRIES = 8
|
const MAX_NETWORK_RETRIES = 8
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,7 +77,44 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
|||||||
)
|
)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const startListening = useCallback(() => {
|
const elapsed = useCallback(
|
||||||
|
() => (Date.now() - startTimeRef.current) / 1000,
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const appendChunk = useCallback((chunk: TranscriptChunk) => {
|
||||||
|
chunksRef.current = [...chunksRef.current, chunk]
|
||||||
|
setChunks(chunksRef.current)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 들고 있던 미확정 발화를 청크로 굳힌다.
|
||||||
|
*
|
||||||
|
* 세션이 끝날 때마다 호출한다. Chrome은 `continuous`여도 침묵마다 세션을
|
||||||
|
* 닫는데, 그때 확정 전이던 문장이 통째로 사라지는 것이 유실의 큰 축이었다.
|
||||||
|
*/
|
||||||
|
const flushInterim = useCallback(() => {
|
||||||
|
const pending = interimRef.current.trim()
|
||||||
|
interimRef.current = ''
|
||||||
|
setInterimText('')
|
||||||
|
|
||||||
|
if (pending.length === 0) {
|
||||||
|
utteranceStartRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = elapsed()
|
||||||
|
appendChunk({
|
||||||
|
text: pending,
|
||||||
|
startTime: utteranceStartRef.current ?? Math.max(0, end - FALLBACK_UTTERANCE_SECONDS),
|
||||||
|
endTime: end,
|
||||||
|
// Chrome이 확정해 준 결과가 아니라 우리가 살려낸 값이다.
|
||||||
|
isFinal: false,
|
||||||
|
})
|
||||||
|
utteranceStartRef.current = null
|
||||||
|
}, [appendChunk, elapsed])
|
||||||
|
|
||||||
|
const startListening = useCallback((timeOrigin?: number) => {
|
||||||
if (isSupported !== true) {
|
if (isSupported !== true) {
|
||||||
setError('이 브라우저는 음성 인식을 지원하지 않습니다. Chrome을 사용해주세요.')
|
setError('이 브라우저는 음성 인식을 지원하지 않습니다. Chrome을 사용해주세요.')
|
||||||
return
|
return
|
||||||
@@ -64,8 +128,10 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
|||||||
recognition.continuous = true
|
recognition.continuous = true
|
||||||
recognition.interimResults = true
|
recognition.interimResults = true
|
||||||
|
|
||||||
startTimeRef.current = Date.now()
|
startTimeRef.current = timeOrigin ?? Date.now()
|
||||||
networkRetryRef.current = 0
|
networkRetryRef.current = 0
|
||||||
|
interimRef.current = ''
|
||||||
|
utteranceStartRef.current = null
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||||
@@ -74,32 +140,47 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
|||||||
setError(null)
|
setError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = (Date.now() - startTimeRef.current) / 1000
|
const now = elapsed()
|
||||||
|
|
||||||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||||||
const result = event.results[i]
|
const result = event.results[i]
|
||||||
const text = result[0].transcript.trim()
|
const text = result[0].transcript.trim()
|
||||||
|
|
||||||
if (result.isFinal) {
|
if (!result.isFinal) {
|
||||||
setChunks((prev) => [
|
// 발화의 첫 조각이 들린 순간이 곧 시작 시각이다.
|
||||||
...prev,
|
if (utteranceStartRef.current === null) {
|
||||||
{
|
utteranceStartRef.current = now
|
||||||
text,
|
}
|
||||||
startTime: Math.max(0, now - 2),
|
interimRef.current = text
|
||||||
endTime: now,
|
|
||||||
isFinal: true,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
setInterimText('')
|
|
||||||
} else {
|
|
||||||
setInterimText(text)
|
setInterimText(text)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const start =
|
||||||
|
utteranceStartRef.current ??
|
||||||
|
Math.max(0, now - FALLBACK_UTTERANCE_SECONDS)
|
||||||
|
|
||||||
|
interimRef.current = ''
|
||||||
|
utteranceStartRef.current = null
|
||||||
|
setInterimText('')
|
||||||
|
|
||||||
|
if (text.length === 0) {
|
||||||
|
// 소리는 감지했지만 인식에 실패한 구간. 프롬프트를 오염시키지 않도록
|
||||||
|
// 청크로 넣지 않되, 유실이 있었다는 사실은 남긴다.
|
||||||
|
setMissedCount((prev) => prev + 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
appendChunk({ text, startTime: start, endTime: now, isFinal: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
recognition.onend = () => {
|
recognition.onend = () => {
|
||||||
if (recognitionRef.current !== recognition) return
|
if (recognitionRef.current !== recognition) return
|
||||||
|
|
||||||
|
// 재시작 전에 반드시 비운다. 새 세션은 이전 오디오를 다시 주지 않는다.
|
||||||
|
flushInterim()
|
||||||
|
|
||||||
const delay = networkRetryRef.current > 0 ? 1500 : 0
|
const delay = networkRetryRef.current > 0 ? 1500 : 0
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (recognitionRef.current !== recognition) return
|
if (recognitionRef.current !== recognition) return
|
||||||
@@ -151,22 +232,31 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
|||||||
)
|
)
|
||||||
recognitionRef.current = null
|
recognitionRef.current = null
|
||||||
}
|
}
|
||||||
}, [isSupported])
|
}, [appendChunk, elapsed, flushInterim, isSupported])
|
||||||
|
|
||||||
const stopListening = useCallback(() => {
|
const stopListening = useCallback((): TranscriptChunk[] => {
|
||||||
if (recognitionRef.current) {
|
const recognition = recognitionRef.current
|
||||||
const recognition = recognitionRef.current
|
if (recognition) {
|
||||||
recognitionRef.current = null
|
recognitionRef.current = null
|
||||||
recognition.stop()
|
recognition.stop()
|
||||||
setIsListening(false)
|
setIsListening(false)
|
||||||
setInterimText('')
|
|
||||||
}
|
}
|
||||||
}, [])
|
|
||||||
|
// 마지막 발화는 확정 전에 세션이 닫히는 경우가 대부분이다.
|
||||||
|
// 여기서 굳히지 않으면 회의 끝머리가 통째로 사라진다.
|
||||||
|
flushInterim()
|
||||||
|
|
||||||
|
return chunksRef.current
|
||||||
|
}, [flushInterim])
|
||||||
|
|
||||||
const resetChunks = useCallback(() => {
|
const resetChunks = useCallback(() => {
|
||||||
|
chunksRef.current = []
|
||||||
|
interimRef.current = ''
|
||||||
|
utteranceStartRef.current = null
|
||||||
setChunks([])
|
setChunks([])
|
||||||
setInterimText('')
|
setInterimText('')
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setMissedCount(0)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -175,6 +265,7 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
|||||||
chunks,
|
chunks,
|
||||||
interimText,
|
interimText,
|
||||||
error,
|
error,
|
||||||
|
missedCount,
|
||||||
startListening,
|
startListening,
|
||||||
stopListening,
|
stopListening,
|
||||||
resetChunks,
|
resetChunks,
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useState } from 'react'
|
||||||
|
import { getProviderRequestPayload } from '@/lib/api-key-storage'
|
||||||
|
import type { TranscriptionSegment } from '@/lib/providers/types'
|
||||||
|
|
||||||
|
export interface TranscriptionOutcome {
|
||||||
|
transcript: string
|
||||||
|
segments: TranscriptionSegment[]
|
||||||
|
model: string
|
||||||
|
hasTimestamps: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptionHook {
|
||||||
|
isTranscribing: boolean
|
||||||
|
error: string | null
|
||||||
|
result: TranscriptionOutcome | null
|
||||||
|
transcribeRecording: (
|
||||||
|
recordingId: string,
|
||||||
|
options?: { vocabularyHint?: string },
|
||||||
|
) => Promise<TranscriptionOutcome | null>
|
||||||
|
reset: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 보관된 녹음을 서버 STT로 전사한다.
|
||||||
|
*
|
||||||
|
* Web Speech 결과를 덮어쓰는 것이 목적이다. 실시간 텍스트는 회의 중 흐름을
|
||||||
|
* 보기 위한 초안이고, 확정본은 원본 오디오에서 다시 뽑는다.
|
||||||
|
*/
|
||||||
|
export function useTranscription(): TranscriptionHook {
|
||||||
|
const [isTranscribing, setIsTranscribing] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [result, setResult] = useState<TranscriptionOutcome | null>(null)
|
||||||
|
|
||||||
|
const transcribeRecording = useCallback(
|
||||||
|
async (recordingId: string, options: { vocabularyHint?: string } = {}) => {
|
||||||
|
setIsTranscribing(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/transcribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
recordingId,
|
||||||
|
language: 'ko',
|
||||||
|
vocabularyHint: options.vocabularyHint,
|
||||||
|
...getProviderRequestPayload(),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => ({}))
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
setError(data.error ?? '전사에 실패했습니다.')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome: TranscriptionOutcome = {
|
||||||
|
transcript: data.transcript ?? '',
|
||||||
|
segments: Array.isArray(data.segments) ? data.segments : [],
|
||||||
|
model: data.model ?? '',
|
||||||
|
hasTimestamps: Boolean(data.hasTimestamps),
|
||||||
|
}
|
||||||
|
setResult(outcome)
|
||||||
|
return outcome
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? `전사 요청 실패: ${err.message}` : '전사 요청 실패',
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
setIsTranscribing(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
setResult(null)
|
||||||
|
setError(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { isTranscribing, error, result, transcribeRecording, reset }
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { findPreset, type ProviderId } from './providers'
|
||||||
|
|
||||||
const STORAGE_KEY = 'meeting-minutes:gemini-api-key'
|
const STORAGE_KEY = 'meeting-minutes:gemini-api-key'
|
||||||
|
|
||||||
export function getStoredApiKey(): string | null {
|
export function getStoredApiKey(): string | null {
|
||||||
@@ -34,3 +36,92 @@ export function maskApiKey(key: string): string {
|
|||||||
if (key.length <= 8) return '••••'
|
if (key.length <= 8) return '••••'
|
||||||
return `${key.slice(0, 4)}••••${key.slice(-4)}`
|
return `${key.slice(0, 4)}••••${key.slice(-4)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------- *
|
||||||
|
* 프로바이더 설정 (Gemini / OpenAI 호환 엔드포인트)
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const PROVIDER_STORAGE_KEY = 'meeting-minutes:llm-provider'
|
||||||
|
|
||||||
|
export interface StoredProviderConfig {
|
||||||
|
presetId: string
|
||||||
|
apiKey: string
|
||||||
|
baseUrl: string
|
||||||
|
model: string
|
||||||
|
/** 음성 전사 모델. 비우면 프리셋 기본값을 쓴다. */
|
||||||
|
sttModel?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderRequestPayload {
|
||||||
|
provider: ProviderId
|
||||||
|
apiKey: string
|
||||||
|
baseUrl: string
|
||||||
|
model: string
|
||||||
|
sttModel: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredProviderConfig(): StoredProviderConfig | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(PROVIDER_STORAGE_KEY)
|
||||||
|
if (!raw) return null
|
||||||
|
const parsed = JSON.parse(raw) as Partial<StoredProviderConfig>
|
||||||
|
if (typeof parsed?.presetId !== 'string') return null
|
||||||
|
return {
|
||||||
|
presetId: parsed.presetId,
|
||||||
|
apiKey: typeof parsed.apiKey === 'string' ? parsed.apiKey : '',
|
||||||
|
baseUrl: typeof parsed.baseUrl === 'string' ? parsed.baseUrl : '',
|
||||||
|
model: typeof parsed.model === 'string' ? parsed.model : '',
|
||||||
|
sttModel: typeof parsed.sttModel === 'string' ? parsed.sttModel : '',
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredProviderConfig(config: StoredProviderConfig): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(PROVIDER_STORAGE_KEY, JSON.stringify(config))
|
||||||
|
} catch {
|
||||||
|
// ignore storage errors (private mode, quota)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredProviderConfig(): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(PROVIDER_STORAGE_KEY)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요약 요청 body에 실을 프로바이더 정보.
|
||||||
|
*
|
||||||
|
* 프로바이더 설정이 없으면 기존 Gemini 키만 쓰던 사용자를 그대로 이어받는다
|
||||||
|
* (별도 마이그레이션 없이 동작).
|
||||||
|
*/
|
||||||
|
export function getProviderRequestPayload(): ProviderRequestPayload {
|
||||||
|
const stored = getStoredProviderConfig()
|
||||||
|
|
||||||
|
if (!stored) {
|
||||||
|
return {
|
||||||
|
provider: 'gemini',
|
||||||
|
apiKey: getStoredApiKey() ?? '',
|
||||||
|
baseUrl: '',
|
||||||
|
model: '',
|
||||||
|
sttModel: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const preset = findPreset(stored.presetId)
|
||||||
|
return {
|
||||||
|
provider: preset.provider,
|
||||||
|
apiKey: stored.apiKey || (preset.provider === 'gemini' ? getStoredApiKey() ?? '' : ''),
|
||||||
|
baseUrl: stored.baseUrl,
|
||||||
|
model: stored.model,
|
||||||
|
sttModel: stored.sttModel ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import {
|
||||||
|
isProviderId,
|
||||||
|
type ProviderId,
|
||||||
|
type ProviderSettings,
|
||||||
|
type TranscriptionSettings,
|
||||||
|
} from './providers'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gemini API 키 해석 우선순위:
|
* Gemini API 키 해석 우선순위:
|
||||||
* 1) 요청 body로 전달된 키 (브라우저 LocalStorage에서 옴)
|
* 1) 요청 body로 전달된 키 (브라우저 LocalStorage에서 옴)
|
||||||
@@ -19,3 +26,105 @@ export function resolveGeminiApiKey(
|
|||||||
export function isEnvKeyConfigured(): boolean {
|
export function isEnvKeyConfigured(): boolean {
|
||||||
return (process.env.GEMINI_API_KEY ?? '').trim().length > 0
|
return (process.env.GEMINI_API_KEY ?? '').trim().length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** LLM_* 환경변수로 프로바이더가 지정되어 있는지 */
|
||||||
|
export function isEnvProviderConfigured(): boolean {
|
||||||
|
const provider = env('LLM_PROVIDER')
|
||||||
|
if (provider === 'openai-compatible') {
|
||||||
|
return env('LLM_BASE_URL').length > 0 && env('LLM_MODEL').length > 0
|
||||||
|
}
|
||||||
|
return isEnvKeyConfigured() || env('LLM_API_KEY').length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderRequestBody {
|
||||||
|
provider?: unknown
|
||||||
|
apiKey?: unknown
|
||||||
|
baseUrl?: unknown
|
||||||
|
model?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 body + 환경변수로부터 프로바이더 설정을 만든다.
|
||||||
|
* 필드별 우선순위는 기존 키 해석과 동일하게 "요청 → 환경변수" 순이다.
|
||||||
|
*
|
||||||
|
* 사용 가능한 설정이 없으면 null을 돌려주고, 호출부는 단순 변환으로 폴백한다.
|
||||||
|
*/
|
||||||
|
export function resolveProviderSettings(
|
||||||
|
body: ProviderRequestBody | null | undefined,
|
||||||
|
): ProviderSettings | null {
|
||||||
|
const provider = resolveProvider(body?.provider)
|
||||||
|
|
||||||
|
if (provider === 'gemini') {
|
||||||
|
const apiKey = resolveGeminiApiKey(str(body?.apiKey) || env('LLM_API_KEY'))
|
||||||
|
if (!apiKey) return null
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
apiKey,
|
||||||
|
model: str(body?.model) || env('LLM_MODEL') || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = str(body?.baseUrl) || env('LLM_BASE_URL')
|
||||||
|
const model = str(body?.model) || env('LLM_MODEL')
|
||||||
|
if (baseUrl.length === 0 || model.length === 0) return null
|
||||||
|
|
||||||
|
// 로컬 모델 서버는 키가 없어도 되므로 빈 문자열을 허용한다.
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
apiKey: str(body?.apiKey) || env('LLM_API_KEY'),
|
||||||
|
baseUrl,
|
||||||
|
model,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveProvider(requested: unknown): ProviderId {
|
||||||
|
if (isProviderId(requested)) return requested
|
||||||
|
|
||||||
|
const fromEnv = env('LLM_PROVIDER')
|
||||||
|
if (isProviderId(fromEnv)) return fromEnv
|
||||||
|
|
||||||
|
return 'gemini'
|
||||||
|
}
|
||||||
|
|
||||||
|
function str(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function env(name: string): string {
|
||||||
|
return (process.env[name] ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TranscriptionRequestBody extends ProviderRequestBody {
|
||||||
|
sttModel?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전사용 프로바이더 설정.
|
||||||
|
*
|
||||||
|
* 요약과 달리 대화 모델 이름이 없어도 된다. 전사는 `sttModel`(비우면 프로바이더
|
||||||
|
* 기본값)로 별도 엔드포인트를 호출하므로, 대화 모델을 지정하지 않은 사용자도
|
||||||
|
* 전사는 쓸 수 있어야 한다.
|
||||||
|
*/
|
||||||
|
export function resolveTranscriptionSettings(
|
||||||
|
body: TranscriptionRequestBody | null | undefined,
|
||||||
|
): TranscriptionSettings | null {
|
||||||
|
const provider = resolveProvider(body?.provider)
|
||||||
|
const sttModel = str(body?.sttModel) || env('LLM_STT_MODEL')
|
||||||
|
|
||||||
|
if (provider === 'gemini') {
|
||||||
|
const apiKey = resolveGeminiApiKey(str(body?.apiKey) || env('LLM_API_KEY'))
|
||||||
|
if (!apiKey) return null
|
||||||
|
return { provider, apiKey, sttModel: sttModel || undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUrl = str(body?.baseUrl) || env('LLM_BASE_URL')
|
||||||
|
if (baseUrl.length === 0) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
apiKey: str(body?.apiKey) || env('LLM_API_KEY'),
|
||||||
|
baseUrl,
|
||||||
|
model: str(body?.model) || env('LLM_MODEL'),
|
||||||
|
sttModel: sttModel || undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
+84
-46
@@ -1,3 +1,4 @@
|
|||||||
|
import { complete, toProviderSettings, type ProviderSettings } from './providers'
|
||||||
import {
|
import {
|
||||||
buildPrompt,
|
buildPrompt,
|
||||||
resolveDepth,
|
resolveDepth,
|
||||||
@@ -10,7 +11,17 @@ type LiveSummaryResult =
|
|||||||
| { success: false; error: string; rateLimited?: boolean }
|
| { success: false; error: string; rateLimited?: boolean }
|
||||||
|
|
||||||
interface LiveSummaryOptions {
|
interface LiveSummaryOptions {
|
||||||
apiKey: string
|
/** 프로바이더 설정. 생략하면 apiKey로 Gemini를 호출한다. */
|
||||||
|
provider?: ProviderSettings
|
||||||
|
apiKey?: string
|
||||||
|
/**
|
||||||
|
* 직전 롤링 요약.
|
||||||
|
*
|
||||||
|
* 값이 있으면 증분 모드로 동작하며, 이때 `transcript` 인자는 전사 전체가 아니라
|
||||||
|
* **직전 요약 이후 새로 추가된 발화**만 담아야 한다. 호출당 토큰이 회의 길이와
|
||||||
|
* 무관하게 일정해진다.
|
||||||
|
*/
|
||||||
|
previousSummary?: string
|
||||||
template?: TemplateId
|
template?: TemplateId
|
||||||
depth?: SummaryDepth
|
depth?: SummaryDepth
|
||||||
customPrompt?: string
|
customPrompt?: string
|
||||||
@@ -21,67 +32,94 @@ export async function generateLiveSummary(
|
|||||||
transcript: string,
|
transcript: string,
|
||||||
options: LiveSummaryOptions,
|
options: LiveSummaryOptions,
|
||||||
): Promise<LiveSummaryResult> {
|
): Promise<LiveSummaryResult> {
|
||||||
const { apiKey, fetchFn = fetch } = options
|
const { fetchFn } = options
|
||||||
|
const settings = toProviderSettings(options.provider, options.apiKey)
|
||||||
if (!apiKey || apiKey.trim().length === 0) {
|
|
||||||
return { success: false, error: 'Gemini API 키가 필요합니다.' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmed = transcript.trim()
|
const trimmed = transcript.trim()
|
||||||
if (trimmed.length === 0) {
|
if (trimmed.length === 0) {
|
||||||
return { success: false, error: '요약할 텍스트가 비어 있습니다.' }
|
return { success: false, error: '요약할 텍스트가 비어 있습니다.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const previous = options.previousSummary?.trim() ?? ''
|
||||||
|
const incremental = previous.length > 0
|
||||||
|
|
||||||
const templateId = options.template ?? 'meeting'
|
const templateId = options.template ?? 'meeting'
|
||||||
const depth = resolveDepth(templateId, options.depth)
|
const depth = resolveDepth(templateId, options.depth)
|
||||||
const prompt = buildPrompt({
|
const prompt = buildPrompt({
|
||||||
templateId,
|
templateId,
|
||||||
depth,
|
depth,
|
||||||
transcript: trimmed,
|
transcript: incremental
|
||||||
|
? `[지금까지의 요약]\n${previous}\n\n[새로 추가된 발화]\n${trimmed}`
|
||||||
|
: trimmed,
|
||||||
live: true,
|
live: true,
|
||||||
|
incremental,
|
||||||
customPrompt: options.customPrompt,
|
customPrompt: options.customPrompt,
|
||||||
})
|
})
|
||||||
|
|
||||||
const url =
|
const result = await complete(prompt, settings, { fetchFn })
|
||||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
|
|
||||||
|
|
||||||
try {
|
if (!result.success) {
|
||||||
const response = await fetchFn(url, {
|
return {
|
||||||
method: 'POST',
|
success: false,
|
||||||
headers: {
|
error: result.error,
|
||||||
'Content-Type': 'application/json',
|
...(result.rateLimited ? { rateLimited: true } : {}),
|
||||||
'x-goog-api-key': apiKey,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
contents: [{ parts: [{ text: prompt }] }],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 429) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: 'Gemini 요청 한도 초과. 잠시 후 자동 재시도됩니다.',
|
|
||||||
rateLimited: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `Gemini API 호출 실패: ${response.status}`,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
const markdown: string =
|
|
||||||
data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''
|
|
||||||
|
|
||||||
if (markdown.trim().length === 0) {
|
|
||||||
return { success: false, error: '요약 결과가 비어 있습니다.' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, markdown }
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
|
||||||
return { success: false, error: `실시간 요약 중 오류: ${message}` }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.text.trim().length === 0) {
|
||||||
|
return { success: false, error: '요약 결과가 비어 있습니다.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, markdown: result.text }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveSummaryPlan {
|
||||||
|
/** 'full'이면 전사 전체를 보내고 previousSummary를 쓰지 않는다. */
|
||||||
|
mode: 'full' | 'incremental'
|
||||||
|
/** 이번에 보낼 청크의 시작 인덱스. full이면 0. */
|
||||||
|
startIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveSummaryPlanArgs {
|
||||||
|
totalChunks: number
|
||||||
|
lastSummarizedIndex: number
|
||||||
|
incrementsSinceFull: number
|
||||||
|
/** 0이면 주기적 전체 재요약을 하지 않는다. */
|
||||||
|
fullRefreshEvery: number
|
||||||
|
hasPreviousSummary: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이번 롤링 요약 호출을 증분으로 보낼지 전체로 보낼지 결정한다.
|
||||||
|
*
|
||||||
|
* 증분 모드는 호출당 토큰을 회의 길이와 무관하게 유지하지만, 요약을 요약하는
|
||||||
|
* 구조라 반복될수록 오차가 쌓인다. 그래서 일정 횟수마다 전사 전체로 한 번씩
|
||||||
|
* 다시 요약해 오차를 끊는다.
|
||||||
|
*/
|
||||||
|
export function planLiveSummaryRequest(
|
||||||
|
args: LiveSummaryPlanArgs,
|
||||||
|
): LiveSummaryPlan {
|
||||||
|
const {
|
||||||
|
totalChunks,
|
||||||
|
lastSummarizedIndex,
|
||||||
|
incrementsSinceFull,
|
||||||
|
fullRefreshEvery,
|
||||||
|
hasPreviousSummary,
|
||||||
|
} = args
|
||||||
|
|
||||||
|
// 전사가 초기화되어 인덱스가 범위를 벗어난 경우
|
||||||
|
if (lastSummarizedIndex > totalChunks) {
|
||||||
|
return { mode: 'full', startIndex: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 첫 호출이거나 갱신할 요약이 아직 없는 경우
|
||||||
|
if (lastSummarizedIndex === 0 || !hasPreviousSummary) {
|
||||||
|
return { mode: 'full', startIndex: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullRefreshEvery > 0 && incrementsSinceFull >= fullRefreshEvery) {
|
||||||
|
return { mode: 'full', startIndex: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mode: 'incremental', startIndex: lastSummarizedIndex }
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { complete, toProviderSettings, type ProviderSettings } from './providers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 긴 회의 처리.
|
||||||
|
*
|
||||||
|
* 예전에는 10만 자를 넘으면 `/api/summarize`가 413으로 **거부**했다. 3시간짜리
|
||||||
|
* 회의를 마치고 회의록을 만들려는 순간 아무것도 못 받는다는 뜻이다. 유실을
|
||||||
|
* 막자고 만든 파이프라인의 끝에서 결과를 통째로 버리는 셈이었다.
|
||||||
|
*
|
||||||
|
* 이제는 구간별로 한 번 압축한 뒤(map), 압축본을 모아 평소와 똑같은 템플릿
|
||||||
|
* 경로로 회의록을 만든다(reduce). 최종 출력이 짧은 회의와 같은 프롬프트를
|
||||||
|
* 타므로 서식이 흔들리지 않는다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 이 길이를 넘으면 한 번에 못 넣는다고 보고 압축 단계를 거친다. */
|
||||||
|
export const SINGLE_PASS_CHAR_LIMIT = 60_000
|
||||||
|
|
||||||
|
/** 압축 단계에서 한 번에 넣을 구간 크기. */
|
||||||
|
export const CONDENSE_WINDOW_CHARS = 40_000
|
||||||
|
|
||||||
|
/** 이걸 넘으면 압축을 해도 감당이 안 된다고 보고 거부한다(약 14시간 분량). */
|
||||||
|
export const MAX_TRANSCRIPT_CHARS = 500_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전사문을 줄 경계에서 잘라 구간으로 나눈다.
|
||||||
|
*
|
||||||
|
* 한 줄이 통째로 상한을 넘으면(타임스탬프 없는 긴 문단) 그 줄만 강제로 자른다.
|
||||||
|
* 어떤 경우에도 원문의 어느 부분도 버리지 않는다.
|
||||||
|
*/
|
||||||
|
export function planTranscriptWindows(
|
||||||
|
transcript: string,
|
||||||
|
maxChars: number = CONDENSE_WINDOW_CHARS,
|
||||||
|
): string[] {
|
||||||
|
if (maxChars <= 0) throw new Error('maxChars는 1 이상이어야 한다')
|
||||||
|
|
||||||
|
const text = transcript.trim()
|
||||||
|
if (text.length === 0) return []
|
||||||
|
if (text.length <= maxChars) return [text]
|
||||||
|
|
||||||
|
const windows: string[] = []
|
||||||
|
let current: string[] = []
|
||||||
|
let currentLength = 0
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
if (current.length === 0) return
|
||||||
|
windows.push(current.join('\n'))
|
||||||
|
current = []
|
||||||
|
currentLength = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
// 줄 하나가 상한보다 길면 쪼개는 수밖에 없다.
|
||||||
|
if (line.length > maxChars) {
|
||||||
|
flush()
|
||||||
|
for (let i = 0; i < line.length; i += maxChars) {
|
||||||
|
windows.push(line.slice(i, i + maxChars))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const projected = currentLength + line.length + (current.length > 0 ? 1 : 0)
|
||||||
|
if (projected > maxChars) flush()
|
||||||
|
|
||||||
|
current.push(line)
|
||||||
|
currentLength += line.length + (current.length > 1 ? 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
flush()
|
||||||
|
return windows
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCondensePrompt(
|
||||||
|
window: string,
|
||||||
|
index: number,
|
||||||
|
total: number,
|
||||||
|
): string {
|
||||||
|
return [
|
||||||
|
`다음은 회의 전사문의 ${index + 1}/${total} 구간입니다.`,
|
||||||
|
'',
|
||||||
|
'이 구간에서 오간 내용을 빠짐없이 정리하세요. 이 정리본만 보고 회의록을',
|
||||||
|
'작성하게 되므로, 뒤에서 쓰일 정보를 잃으면 안 됩니다.',
|
||||||
|
'',
|
||||||
|
'규칙:',
|
||||||
|
'- 논의된 주제, 결정된 사항, 할 일, 숫자·날짜·고유명사를 모두 남깁니다.',
|
||||||
|
'- 누가 말했는지 드러나면 함께 적습니다.',
|
||||||
|
'- 인사말·잡담·중복은 덜어냅니다.',
|
||||||
|
'- 회의록 서식으로 만들지 말고, 사실을 담은 개조식 메모로만 정리합니다.',
|
||||||
|
'- 앞뒤 구간을 추측해 채우지 않습니다. 이 구간에 있는 내용만 씁니다.',
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
window,
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CondenseOptions {
|
||||||
|
provider?: ProviderSettings
|
||||||
|
apiKey?: string
|
||||||
|
windowChars?: number
|
||||||
|
fetchFn?: typeof fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CondenseResult =
|
||||||
|
| { success: true; text: string; windows: number }
|
||||||
|
| { success: false; error: string; rateLimited?: boolean }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 긴 전사문을 구간별로 압축한다.
|
||||||
|
*
|
||||||
|
* 한 구간이라도 실패하면 전체를 실패로 돌린다. 일부만 빠진 압축본으로 회의록을
|
||||||
|
* 만들면 사용자는 무엇이 빠졌는지 모른 채 멀쩡해 보이는 문서를 받게 된다.
|
||||||
|
*/
|
||||||
|
export async function condenseTranscript(
|
||||||
|
transcript: string,
|
||||||
|
options: CondenseOptions = {},
|
||||||
|
): Promise<CondenseResult> {
|
||||||
|
const settings = toProviderSettings(options.provider, options.apiKey)
|
||||||
|
const windows = planTranscriptWindows(
|
||||||
|
transcript,
|
||||||
|
options.windowChars ?? CONDENSE_WINDOW_CHARS,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (windows.length === 0) {
|
||||||
|
return { success: false, error: '요약할 텍스트가 비어 있습니다.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const condensed: string[] = []
|
||||||
|
|
||||||
|
for (const [index, window] of windows.entries()) {
|
||||||
|
const result = await complete(
|
||||||
|
buildCondensePrompt(window, index, windows.length),
|
||||||
|
settings,
|
||||||
|
{ fetchFn: options.fetchFn },
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `${index + 1}/${windows.length} 구간 정리 실패: ${result.error}`,
|
||||||
|
...(result.rateLimited ? { rateLimited: true } : {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = result.text.trim()
|
||||||
|
if (text.length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `${index + 1}/${windows.length} 구간 정리 결과가 비어 있습니다.`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
condensed.push(`## 구간 ${index + 1}/${windows.length}\n\n${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
text: condensed.join('\n\n'),
|
||||||
|
windows: windows.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
import {
|
||||||
|
complete,
|
||||||
|
describeModel,
|
||||||
|
toProviderSettings,
|
||||||
|
type ProviderSettings,
|
||||||
|
} from './providers'
|
||||||
import {
|
import {
|
||||||
buildPrompt,
|
buildPrompt,
|
||||||
resolveDepth,
|
resolveDepth,
|
||||||
@@ -14,12 +20,14 @@ export interface MinutesInput {
|
|||||||
customPrompt?: string
|
customPrompt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type GeminiResult =
|
type AiMinutesResult =
|
||||||
| { success: true; markdown: string }
|
| { success: true; markdown: string }
|
||||||
| { success: false; error: string }
|
| { success: false; error: string; rateLimited?: boolean }
|
||||||
|
|
||||||
interface GeminiOptions {
|
interface AiMinutesOptions {
|
||||||
apiKey: string
|
/** 프로바이더 설정. 생략하면 apiKey로 Gemini를 호출한다. */
|
||||||
|
provider?: ProviderSettings
|
||||||
|
apiKey?: string
|
||||||
fetchFn?: typeof fetch
|
fetchFn?: typeof fetch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,15 +57,12 @@ ${content}
|
|||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateGeminiMinutes(
|
export async function generateAiMinutes(
|
||||||
input: MinutesInput,
|
input: MinutesInput,
|
||||||
options: GeminiOptions,
|
options: AiMinutesOptions,
|
||||||
): Promise<GeminiResult> {
|
): Promise<AiMinutesResult> {
|
||||||
const { apiKey, fetchFn = fetch } = options
|
const { fetchFn } = options
|
||||||
|
const settings = toProviderSettings(options.provider, options.apiKey)
|
||||||
if (!apiKey || apiKey.trim().length === 0) {
|
|
||||||
return { success: false, error: 'Gemini API 키가 필요합니다.' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const templateId = input.template ?? 'meeting'
|
const templateId = input.template ?? 'meeting'
|
||||||
const depth = resolveDepth(templateId, input.depth)
|
const depth = resolveDepth(templateId, input.depth)
|
||||||
@@ -69,34 +74,23 @@ export async function generateGeminiMinutes(
|
|||||||
customPrompt: input.customPrompt,
|
customPrompt: input.customPrompt,
|
||||||
})
|
})
|
||||||
|
|
||||||
const url =
|
const result = await complete(prompt, settings, { fetchFn })
|
||||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
|
|
||||||
|
|
||||||
try {
|
if (!result.success) {
|
||||||
const response = await fetchFn(url, {
|
return {
|
||||||
method: 'POST',
|
success: false,
|
||||||
headers: {
|
error: result.error,
|
||||||
'Content-Type': 'application/json',
|
...(result.rateLimited ? { rateLimited: true } : {}),
|
||||||
'x-goog-api-key': apiKey,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
contents: [{ parts: [{ text: prompt }] }],
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `Gemini API 호출 실패: ${response.status} ${response.statusText}`,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const generatedText = result.text.trim()
|
||||||
const generatedText =
|
if (generatedText.length === 0) {
|
||||||
data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''
|
return { success: false, error: '요약 결과가 비어 있습니다.' }
|
||||||
|
}
|
||||||
|
|
||||||
const dateStr = formatDate(input.date)
|
const dateStr = formatDate(input.date)
|
||||||
const markdown = `# ${input.title}
|
const markdown = `# ${input.title}
|
||||||
|
|
||||||
**날짜**: ${dateStr}
|
**날짜**: ${dateStr}
|
||||||
|
|
||||||
@@ -106,15 +100,13 @@ ${generatedText}
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*이 회의록은 Gemini AI를 활용하여 자동 생성되었습니다.*
|
*이 회의록은 AI(${describeModel(settings)})를 활용하여 자동 생성되었습니다.*
|
||||||
`
|
`
|
||||||
|
|
||||||
return { success: true, markdown }
|
return { success: true, markdown }
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `Gemini API 호출 중 오류 발생: ${message}`,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated `generateAiMinutes`를 사용하세요. Gemini 전용 호출부 하위 호환용입니다.
|
||||||
|
*/
|
||||||
|
export const generateGeminiMinutes = generateAiMinutes
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import type {
|
||||||
|
CompletionOptions,
|
||||||
|
CompletionResult,
|
||||||
|
ProviderSettings,
|
||||||
|
TranscriptionInput,
|
||||||
|
TranscriptionOptions,
|
||||||
|
TranscriptionResult,
|
||||||
|
TranscriptionSegment,
|
||||||
|
TranscriptionSettings,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export const DEFAULT_GEMINI_MODEL = 'gemini-3.5-flash-lite'
|
||||||
|
|
||||||
|
const API_ROOT = 'https://generativelanguage.googleapis.com/v1beta/models'
|
||||||
|
|
||||||
|
export function resolveGeminiModel(model?: string): string {
|
||||||
|
const trimmed = model?.trim() ?? ''
|
||||||
|
return trimmed.length > 0 ? trimmed : DEFAULT_GEMINI_MODEL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Generative Language API 직접 호출.
|
||||||
|
* 키는 URL이 아닌 `x-goog-api-key` 헤더로 보낸다 (로그/리퍼러 유출 방지).
|
||||||
|
*/
|
||||||
|
export async function completeWithGemini(
|
||||||
|
prompt: string,
|
||||||
|
settings: ProviderSettings,
|
||||||
|
options: CompletionOptions = {},
|
||||||
|
): Promise<CompletionResult> {
|
||||||
|
const { fetchFn = fetch } = options
|
||||||
|
const apiKey = settings.apiKey.trim()
|
||||||
|
|
||||||
|
if (apiKey.length === 0) {
|
||||||
|
return { success: false, error: 'Gemini API 키가 필요합니다.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = resolveGeminiModel(settings.model)
|
||||||
|
const url = `${API_ROOT}/${encodeURIComponent(model)}:generateContent`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchFn(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-goog-api-key': apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [{ parts: [{ text: prompt }] }],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 429) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Gemini API 요청 한도 초과. 잠시 후 자동 재시도됩니다.',
|
||||||
|
rateLimited: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: false, error: describeHttpError(response.status) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const text: string = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''
|
||||||
|
|
||||||
|
return { success: true, text }
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||||
|
return { success: false, error: `Gemini API 호출 중 오류: ${message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeHttpError(status: number): string {
|
||||||
|
if (status === 400) {
|
||||||
|
return 'Gemini API 오류: 잘못된 요청 또는 키 형식입니다 (400).'
|
||||||
|
}
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
return `Gemini API 인증 실패 (${status}). 키를 확인해주세요.`
|
||||||
|
}
|
||||||
|
return `Gemini API 호출 실패: ${status}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemini 오디오 입력 기본 모델.
|
||||||
|
* flash-lite는 오디오를 받지 않으므로 요약용 기본값과 다르다.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_GEMINI_STT_MODEL = 'gemini-3.6-flash'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gemini가 문서로 밝힌 오디오 형식.
|
||||||
|
*
|
||||||
|
* **webm은 여기에 없다.** 우리 브라우저 녹음이 webm/opus이므로 Gemini로는
|
||||||
|
* 실시간 녹음을 전사할 수 없고, 업로드한 mp3/wav/flac/ogg/m4a만 된다.
|
||||||
|
* 녹음 전사는 Whisper 호환 엔드포인트(OrcaRouter·OpenAI·로컬)를 써야 한다.
|
||||||
|
*/
|
||||||
|
const GEMINI_AUDIO_MIME_TYPES = [
|
||||||
|
'audio/wav',
|
||||||
|
'audio/x-wav',
|
||||||
|
'audio/mp3',
|
||||||
|
'audio/mpeg',
|
||||||
|
'audio/aiff',
|
||||||
|
'audio/aac',
|
||||||
|
'audio/ogg',
|
||||||
|
'audio/flac',
|
||||||
|
'audio/mp4',
|
||||||
|
'audio/x-m4a',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function isGeminiSupportedAudioMimeType(mimeType: string): boolean {
|
||||||
|
const base = mimeType.split(';')[0].trim().toLowerCase()
|
||||||
|
return GEMINI_AUDIO_MIME_TYPES.includes(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* inline_data로 보낼 수 있는 최대 오디오 크기.
|
||||||
|
*
|
||||||
|
* generateContent 요청 전체가 20MB를 넘으면 안 되는데 base64가 약 4/3배로
|
||||||
|
* 부풀리므로, 원본 기준 14MB에서 끊는다. 더 큰 파일은 Files API가 필요하다.
|
||||||
|
*/
|
||||||
|
export const GEMINI_INLINE_AUDIO_LIMIT = 14 * 1024 * 1024
|
||||||
|
|
||||||
|
function buildTranscriptionPrompt(input: TranscriptionInput): string {
|
||||||
|
const lines = [
|
||||||
|
'이 오디오는 회의 녹음입니다. 들리는 발화를 그대로 받아쓰세요.',
|
||||||
|
'',
|
||||||
|
'규칙:',
|
||||||
|
'- 요약하거나 문장을 다듬지 말고 말한 그대로 옮깁니다.',
|
||||||
|
'- 들리지 않는 구간은 지어내지 말고 건너뜁니다.',
|
||||||
|
'- 각 발화 앞에 `[MM:SS]` 형식으로 시작 시각을 붙입니다.',
|
||||||
|
'- 설명이나 머리말 없이 전사문만 출력합니다.',
|
||||||
|
]
|
||||||
|
|
||||||
|
if (input.vocabularyHint) {
|
||||||
|
lines.push(
|
||||||
|
'',
|
||||||
|
`이 회의에 나올 수 있는 고유명사·용어: ${input.vocabularyHint}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `[MM:SS] 텍스트` 또는 `[HH:MM:SS] 텍스트` 줄을 구간으로 바꾼다. */
|
||||||
|
export function parseTimestampedTranscript(text: string): TranscriptionSegment[] {
|
||||||
|
const segments: TranscriptionSegment[] = []
|
||||||
|
const pattern = /^\[(?:(\d{1,2}):)?(\d{1,2}):(\d{2})\]\s*(.+)$/
|
||||||
|
|
||||||
|
for (const line of text.split('\n')) {
|
||||||
|
const match = pattern.exec(line.trim())
|
||||||
|
if (!match) continue
|
||||||
|
|
||||||
|
const [, h, m, s, body] = match
|
||||||
|
const start = (h ? Number(h) * 3600 : 0) + Number(m) * 60 + Number(s)
|
||||||
|
const content = body.trim()
|
||||||
|
if (content.length === 0) continue
|
||||||
|
|
||||||
|
// 다음 구간이 시작될 때까지가 이 구간이다. 마지막은 뒤에서 채운다.
|
||||||
|
if (segments.length > 0) {
|
||||||
|
segments[segments.length - 1].end = start
|
||||||
|
}
|
||||||
|
segments.push({ start, end: start, text: content })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.length > 0) {
|
||||||
|
const last = segments[segments.length - 1]
|
||||||
|
if (last.end <= last.start) last.end = last.start + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gemini에 오디오를 inline으로 실어 전사한다. */
|
||||||
|
export async function transcribeWithGemini(
|
||||||
|
input: TranscriptionInput,
|
||||||
|
settings: TranscriptionSettings,
|
||||||
|
options: TranscriptionOptions = {},
|
||||||
|
): Promise<TranscriptionResult> {
|
||||||
|
const { fetchFn = fetch } = options
|
||||||
|
const apiKey = settings.apiKey.trim()
|
||||||
|
|
||||||
|
if (apiKey.length === 0) {
|
||||||
|
return { success: false, error: 'Gemini API 키가 필요합니다.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseMime = input.mimeType.split(';')[0].trim().toLowerCase()
|
||||||
|
|
||||||
|
if (!isGeminiSupportedAudioMimeType(input.mimeType)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
`Gemini는 ${baseMime} 형식을 받지 않습니다. ` +
|
||||||
|
'브라우저 녹음(webm)을 전사하려면 설정에서 OpenAI 호환 프로바이더' +
|
||||||
|
'(OrcaRouter · OpenAI · 로컬 whisper)를 선택해주세요.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.bytes.byteLength > GEMINI_INLINE_AUDIO_LIMIT) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
`오디오가 너무 큽니다 (${Math.round(input.bytes.byteLength / 1024 / 1024)}MB). ` +
|
||||||
|
`Gemini 직접 호출은 ${Math.round(GEMINI_INLINE_AUDIO_LIMIT / 1024 / 1024)}MB까지만 가능합니다. ` +
|
||||||
|
'OpenAI 호환 프로바이더를 쓰거나 오디오를 나눠주세요.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = settings.sttModel?.trim() || DEFAULT_GEMINI_STT_MODEL
|
||||||
|
const url = `${API_ROOT}/${encodeURIComponent(model)}:generateContent`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchFn(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'x-goog-api-key': apiKey,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
parts: [
|
||||||
|
{ text: buildTranscriptionPrompt(input) },
|
||||||
|
{
|
||||||
|
inline_data: {
|
||||||
|
mime_type: baseMime,
|
||||||
|
data: Buffer.from(input.bytes).toString('base64'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 429) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Gemini API 요청 한도 초과. 잠시 후 다시 시도해주세요.',
|
||||||
|
rateLimited: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: false, error: describeHttpError(response.status) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const text: string = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''
|
||||||
|
|
||||||
|
if (text.trim().length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '전사 결과가 비어 있습니다. 오디오에 음성이 들어 있는지 확인해주세요.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
text: text.trim(),
|
||||||
|
segments: parseTimestampedTranscript(text),
|
||||||
|
model,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||||
|
return { success: false, error: `전사 호출 중 오류: ${message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
DEFAULT_GEMINI_STT_MODEL,
|
||||||
|
completeWithGemini,
|
||||||
|
resolveGeminiModel,
|
||||||
|
transcribeWithGemini,
|
||||||
|
} from './gemini'
|
||||||
|
import {
|
||||||
|
DEFAULT_OPENAI_STT_MODEL,
|
||||||
|
completeWithOpenAICompatible,
|
||||||
|
transcribeWithOpenAICompatible,
|
||||||
|
} from './openai-compatible'
|
||||||
|
import type {
|
||||||
|
CompletionOptions,
|
||||||
|
CompletionResult,
|
||||||
|
ProviderSettings,
|
||||||
|
TranscriptionInput,
|
||||||
|
TranscriptionOptions,
|
||||||
|
TranscriptionResult,
|
||||||
|
TranscriptionSettings,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
export * from './types'
|
||||||
|
export * from './presets'
|
||||||
|
export {
|
||||||
|
DEFAULT_GEMINI_MODEL,
|
||||||
|
DEFAULT_GEMINI_STT_MODEL,
|
||||||
|
GEMINI_INLINE_AUDIO_LIMIT,
|
||||||
|
isGeminiSupportedAudioMimeType,
|
||||||
|
parseTimestampedTranscript,
|
||||||
|
resolveGeminiModel,
|
||||||
|
} from './gemini'
|
||||||
|
export {
|
||||||
|
DEFAULT_OPENAI_STT_MODEL,
|
||||||
|
isWhisperSupportedMimeType,
|
||||||
|
normalizeBaseUrl,
|
||||||
|
} from './openai-compatible'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정된 프로바이더로 프롬프트 1회 호출.
|
||||||
|
* 실패는 예외 대신 `{ success: false }`로 돌려주므로 호출부에서 폴백하기 쉽다.
|
||||||
|
*/
|
||||||
|
export async function complete(
|
||||||
|
prompt: string,
|
||||||
|
settings: ProviderSettings,
|
||||||
|
options: CompletionOptions = {},
|
||||||
|
): Promise<CompletionResult> {
|
||||||
|
if (settings.provider === 'openai-compatible') {
|
||||||
|
return completeWithOpenAICompatible(prompt, settings, options)
|
||||||
|
}
|
||||||
|
return completeWithGemini(prompt, settings, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 회의록 하단 문구 등에 쓸 모델 표기. */
|
||||||
|
export function describeModel(settings: ProviderSettings): string {
|
||||||
|
if (settings.provider === 'openai-compatible') {
|
||||||
|
return settings.model?.trim() || '알 수 없는 모델'
|
||||||
|
}
|
||||||
|
return resolveGeminiModel(settings.model)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** provider 설정이 없으면 기존 Gemini 전용 호출부와 동일하게 동작시킨다. */
|
||||||
|
export function toProviderSettings(
|
||||||
|
settings: ProviderSettings | undefined,
|
||||||
|
apiKey: string | undefined,
|
||||||
|
): ProviderSettings {
|
||||||
|
return settings ?? { provider: 'gemini', apiKey: apiKey ?? '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정된 프로바이더로 오디오 1건 전사.
|
||||||
|
*
|
||||||
|
* 요약과 마찬가지로 실패를 예외로 던지지 않는다. 전사는 회의가 끝난 뒤
|
||||||
|
* 한 번뿐인 기회이므로, 호출부가 오류 문구를 그대로 사용자에게 보여주고
|
||||||
|
* 원본 오디오를 내려받도록 안내할 수 있어야 한다.
|
||||||
|
*/
|
||||||
|
export async function transcribe(
|
||||||
|
input: TranscriptionInput,
|
||||||
|
settings: TranscriptionSettings,
|
||||||
|
options: TranscriptionOptions = {},
|
||||||
|
): Promise<TranscriptionResult> {
|
||||||
|
if (settings.provider === 'openai-compatible') {
|
||||||
|
return transcribeWithOpenAICompatible(input, settings, options)
|
||||||
|
}
|
||||||
|
return transcribeWithGemini(input, settings, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전사에 실제로 쓰일 모델 이름. */
|
||||||
|
export function resolveSttModel(settings: TranscriptionSettings): string {
|
||||||
|
const explicit = settings.sttModel?.trim()
|
||||||
|
if (explicit) return explicit
|
||||||
|
|
||||||
|
return settings.provider === 'openai-compatible'
|
||||||
|
? DEFAULT_OPENAI_STT_MODEL
|
||||||
|
: DEFAULT_GEMINI_STT_MODEL
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import type {
|
||||||
|
CompletionOptions,
|
||||||
|
CompletionResult,
|
||||||
|
ProviderSettings,
|
||||||
|
TranscriptionInput,
|
||||||
|
TranscriptionOptions,
|
||||||
|
TranscriptionResult,
|
||||||
|
TranscriptionSegment,
|
||||||
|
TranscriptionSettings,
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* base URL 검증.
|
||||||
|
*
|
||||||
|
* 이 값은 사용자가 설정 화면에서 입력하고 서버(Route Handler)가 그대로 fetch 하므로,
|
||||||
|
* http/https 이외의 스킴은 거부한다. 앱을 localhost 밖으로 노출한다면
|
||||||
|
* SECURITY.md의 "외부 엔드포인트" 항목을 먼저 확인할 것.
|
||||||
|
*/
|
||||||
|
export function normalizeBaseUrl(baseUrl: string): string | null {
|
||||||
|
const trimmed = baseUrl.trim().replace(/\/+$/, '')
|
||||||
|
if (trimmed.length === 0) return null
|
||||||
|
|
||||||
|
let parsed: URL
|
||||||
|
try {
|
||||||
|
parsed = new URL(trimmed)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
|
||||||
|
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI Chat Completions 호환 엔드포인트 호출.
|
||||||
|
* OrcaRouter · OpenAI · Ollama · LM Studio · vLLM 등이 모두 이 형식을 따른다.
|
||||||
|
*/
|
||||||
|
export async function completeWithOpenAICompatible(
|
||||||
|
prompt: string,
|
||||||
|
settings: ProviderSettings,
|
||||||
|
options: CompletionOptions = {},
|
||||||
|
): Promise<CompletionResult> {
|
||||||
|
const { fetchFn = fetch } = options
|
||||||
|
|
||||||
|
const baseUrl = normalizeBaseUrl(settings.baseUrl ?? '')
|
||||||
|
if (!baseUrl) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'API 주소(base URL)가 올바르지 않습니다. http:// 또는 https:// 로 시작해야 합니다.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = settings.model?.trim() ?? ''
|
||||||
|
if (model.length === 0) {
|
||||||
|
return { success: false, error: '모델 이름이 필요합니다.' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로컬 모델 서버(Ollama/LM Studio)는 키 없이 동작하므로 키가 없어도 호출한다.
|
||||||
|
//
|
||||||
|
// NOTE: 일부 라우터는 "이 요청이 어느 앱에서 왔는지" 식별하는 헤더를 받는다
|
||||||
|
// (OpenRouter의 HTTP-Referer / X-Title 등). 특정 서비스와 제휴해 트래픽을
|
||||||
|
// 귀속시키려면 그 서비스가 공식 문서로 밝힌 헤더를 여기에 추가하면 된다.
|
||||||
|
// 문서화되지 않은 헤더를 추측해서 보내지는 않는다.
|
||||||
|
const apiKey = settings.apiKey.trim()
|
||||||
|
if (apiKey.length > 0) {
|
||||||
|
headers.Authorization = `Bearer ${apiKey}`
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchFn(`${baseUrl}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
messages: [{ role: 'user', content: prompt }],
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 429) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'API 요청 한도 초과. 잠시 후 자동 재시도됩니다.',
|
||||||
|
rateLimited: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: false, error: describeHttpError(response.status) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const text: string = data?.choices?.[0]?.message?.content ?? ''
|
||||||
|
|
||||||
|
return { success: true, text }
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||||
|
return { success: false, error: `API 호출 중 오류: ${message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeHttpError(status: number): string {
|
||||||
|
if (status === 401 || status === 403) {
|
||||||
|
return `인증에 실패했습니다 (${status}). API 키를 확인해주세요.`
|
||||||
|
}
|
||||||
|
if (status === 404) {
|
||||||
|
return `엔드포인트를 찾을 수 없습니다 (404). base URL과 모델 이름을 확인해주세요.`
|
||||||
|
}
|
||||||
|
if (status === 402) {
|
||||||
|
return '크레딧이 부족합니다 (402). 프로바이더 잔액을 확인해주세요.'
|
||||||
|
}
|
||||||
|
return `API 호출 실패: ${status}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whisper 계열 기본 모델. OrcaRouter·OpenAI·로컬 whisper.cpp 모두 이 이름을 쓴다. */
|
||||||
|
export const DEFAULT_OPENAI_STT_MODEL = 'whisper-1'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whisper가 받아주는 컨테이너.
|
||||||
|
* 우리 녹음(webm/opus)이 여기 포함되므로 별도 변환 없이 그대로 보낸다.
|
||||||
|
*/
|
||||||
|
const WHISPER_MIME_TYPES = [
|
||||||
|
'audio/webm',
|
||||||
|
'audio/mp4',
|
||||||
|
'audio/mpeg',
|
||||||
|
'audio/mpga',
|
||||||
|
'audio/m4a',
|
||||||
|
'audio/x-m4a',
|
||||||
|
'audio/wav',
|
||||||
|
'audio/x-wav',
|
||||||
|
'audio/ogg',
|
||||||
|
'audio/flac',
|
||||||
|
]
|
||||||
|
|
||||||
|
export function isWhisperSupportedMimeType(mimeType: string): boolean {
|
||||||
|
const base = mimeType.split(';')[0].trim().toLowerCase()
|
||||||
|
return WHISPER_MIME_TYPES.includes(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI `/audio/transcriptions` 호환 엔드포인트로 전사.
|
||||||
|
*
|
||||||
|
* `verbose_json`을 먼저 요청한다. 구간별 실제 타임스탬프가 같이 오기 때문이다 —
|
||||||
|
* Web Speech가 주지 못하던 진짜 오디오 타임라인이며, 화자 분리를 얹으려면
|
||||||
|
* 반드시 필요하다. 이 형식을 지원하지 않는 최신 모델(gpt-4o-transcribe 등)은
|
||||||
|
* 400을 돌려주므로 그때는 `json`으로 한 번 더 시도한다.
|
||||||
|
*/
|
||||||
|
export async function transcribeWithOpenAICompatible(
|
||||||
|
input: TranscriptionInput,
|
||||||
|
settings: TranscriptionSettings,
|
||||||
|
options: TranscriptionOptions = {},
|
||||||
|
): Promise<TranscriptionResult> {
|
||||||
|
const { fetchFn = fetch } = options
|
||||||
|
|
||||||
|
const baseUrl = normalizeBaseUrl(settings.baseUrl ?? '')
|
||||||
|
if (!baseUrl) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'API 주소(base URL)가 올바르지 않습니다. http:// 또는 https:// 로 시작해야 합니다.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isWhisperSupportedMimeType(input.mimeType)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `이 엔드포인트가 지원하지 않는 오디오 형식입니다 (${input.mimeType}).`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = settings.sttModel?.trim() || DEFAULT_OPENAI_STT_MODEL
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
const apiKey = settings.apiKey.trim()
|
||||||
|
if (apiKey.length > 0) {
|
||||||
|
headers.Authorization = `Bearer ${apiKey}`
|
||||||
|
}
|
||||||
|
// Content-Type은 지정하지 않는다. FormData가 boundary까지 붙여 설정한다.
|
||||||
|
|
||||||
|
async function send(responseFormat: 'verbose_json' | 'json') {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append(
|
||||||
|
'file',
|
||||||
|
new Blob([input.bytes as BlobPart], { type: input.mimeType }),
|
||||||
|
input.fileName,
|
||||||
|
)
|
||||||
|
form.append('model', model)
|
||||||
|
form.append('response_format', responseFormat)
|
||||||
|
if (input.language) form.append('language', input.language)
|
||||||
|
if (input.vocabularyHint) form.append('prompt', input.vocabularyHint)
|
||||||
|
|
||||||
|
return fetchFn(`${baseUrl}/audio/transcriptions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response = await send('verbose_json')
|
||||||
|
|
||||||
|
// verbose_json 미지원 모델 → 타임스탬프를 포기하고 텍스트만 받는다.
|
||||||
|
if (response.status === 400) {
|
||||||
|
response = await send('json')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 429) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '전사 API 요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.',
|
||||||
|
rateLimited: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { success: false, error: describeHttpError(response.status) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
const text: string = typeof data?.text === 'string' ? data.text : ''
|
||||||
|
|
||||||
|
if (text.trim().length === 0) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: '전사 결과가 비어 있습니다. 오디오에 음성이 들어 있는지 확인해주세요.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, text, segments: parseWhisperSegments(data), model }
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||||
|
return { success: false, error: `전사 호출 중 오류: ${message}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWhisperSegments(data: unknown): TranscriptionSegment[] {
|
||||||
|
const raw = (data as { segments?: unknown })?.segments
|
||||||
|
if (!Array.isArray(raw)) return []
|
||||||
|
|
||||||
|
const segments: TranscriptionSegment[] = []
|
||||||
|
|
||||||
|
for (const item of raw) {
|
||||||
|
const start = Number((item as { start?: unknown })?.start)
|
||||||
|
const end = Number((item as { end?: unknown })?.end)
|
||||||
|
const text = String((item as { text?: unknown })?.text ?? '').trim()
|
||||||
|
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end)) continue
|
||||||
|
if (text.length === 0) continue
|
||||||
|
|
||||||
|
segments.push({ start: Math.max(0, start), end: Math.max(start, end), text })
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_STT_MODEL } from './gemini'
|
||||||
|
import { DEFAULT_OPENAI_STT_MODEL } from './openai-compatible'
|
||||||
|
import type { ProviderId } from './types'
|
||||||
|
|
||||||
|
export interface ProviderPreset {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
provider: ProviderId
|
||||||
|
/** openai-compatible 프리셋의 기본 base URL. 사용자가 수정할 수 있다. */
|
||||||
|
baseUrl: string
|
||||||
|
defaultModel: string
|
||||||
|
/** 음성 전사(STT)에 쓸 기본 모델. 요약용 모델과 다르다. */
|
||||||
|
defaultSttModel: string
|
||||||
|
/** 이 프로바이더로 브라우저 녹음(webm)을 전사할 수 있는지. */
|
||||||
|
canTranscribeWebm: boolean
|
||||||
|
description: string
|
||||||
|
apiKeyLabel: string
|
||||||
|
apiKeyPlaceholder: string
|
||||||
|
/** 키 없이도 동작하는 엔드포인트(로컬 모델 서버)인지 여부 */
|
||||||
|
apiKeyOptional?: boolean
|
||||||
|
docsUrl?: string
|
||||||
|
docsLabel?: string
|
||||||
|
modelHint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||||
|
{
|
||||||
|
id: 'gemini',
|
||||||
|
label: 'Google Gemini',
|
||||||
|
provider: 'gemini',
|
||||||
|
baseUrl: '',
|
||||||
|
defaultModel: DEFAULT_GEMINI_MODEL,
|
||||||
|
defaultSttModel: DEFAULT_GEMINI_STT_MODEL,
|
||||||
|
// Gemini는 webm 오디오를 받지 않는다 — 업로드한 mp3/wav/flac만 전사 가능.
|
||||||
|
canTranscribeWebm: false,
|
||||||
|
description: 'Google에 직접 호출합니다. 무료 티어가 있어 가장 간단합니다.',
|
||||||
|
apiKeyLabel: 'Gemini API 키',
|
||||||
|
apiKeyPlaceholder: 'AIzaSy...',
|
||||||
|
docsUrl: 'https://aistudio.google.com/apikey',
|
||||||
|
docsLabel: 'Google AI Studio',
|
||||||
|
modelHint:
|
||||||
|
'예: gemini-3.5-flash-lite(저렴·빠름), gemini-3.6-flash, gemini-2.5-pro',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'openai',
|
||||||
|
label: 'OpenAI',
|
||||||
|
provider: 'openai-compatible',
|
||||||
|
baseUrl: 'https://api.openai.com/v1',
|
||||||
|
defaultModel: 'gpt-4o-mini',
|
||||||
|
defaultSttModel: DEFAULT_OPENAI_STT_MODEL,
|
||||||
|
canTranscribeWebm: true,
|
||||||
|
description: 'OpenAI Chat Completions + Whisper 전사를 사용합니다.',
|
||||||
|
apiKeyLabel: 'OpenAI API 키',
|
||||||
|
apiKeyPlaceholder: 'sk-...',
|
||||||
|
docsUrl: 'https://platform.openai.com/api-keys',
|
||||||
|
docsLabel: 'OpenAI 대시보드',
|
||||||
|
modelHint: '예: gpt-4o-mini, gpt-4o',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'orcarouter',
|
||||||
|
label: 'OrcaRouter',
|
||||||
|
provider: 'openai-compatible',
|
||||||
|
baseUrl: 'https://api.orcarouter.ai/v1',
|
||||||
|
defaultModel: 'google/gemini-3.5-flash-lite',
|
||||||
|
defaultSttModel: 'openai/whisper-1',
|
||||||
|
canTranscribeWebm: true,
|
||||||
|
description:
|
||||||
|
'하나의 키로 여러 제공사 모델을 사용합니다. 별도 가입과 크레딧 충전(또는 BYOK 등록)이 필요합니다.',
|
||||||
|
apiKeyLabel: 'OrcaRouter API 키',
|
||||||
|
apiKeyPlaceholder: 'sk-...',
|
||||||
|
docsUrl: 'https://www.orcarouter.ai/',
|
||||||
|
docsLabel: 'OrcaRouter',
|
||||||
|
modelHint:
|
||||||
|
'예: google/gemini-3.5-flash-lite, openai/gpt-4o-mini, orcarouter/auto',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'local',
|
||||||
|
label: '로컬 모델',
|
||||||
|
provider: 'openai-compatible',
|
||||||
|
baseUrl: 'http://localhost:11434/v1',
|
||||||
|
defaultModel: 'llama3.1',
|
||||||
|
defaultSttModel: 'whisper-1',
|
||||||
|
canTranscribeWebm: true,
|
||||||
|
description:
|
||||||
|
'Ollama · LM Studio · vLLM 등 내 PC에서 도는 모델. 회의 내용이 외부로 나가지 않습니다.',
|
||||||
|
apiKeyLabel: 'API 키 (보통 불필요)',
|
||||||
|
apiKeyPlaceholder: '비워두세요',
|
||||||
|
apiKeyOptional: true,
|
||||||
|
modelHint: 'Ollama 기본 포트는 11434, LM Studio는 1234입니다.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'custom',
|
||||||
|
label: '직접 입력',
|
||||||
|
provider: 'openai-compatible',
|
||||||
|
baseUrl: '',
|
||||||
|
defaultModel: '',
|
||||||
|
defaultSttModel: DEFAULT_OPENAI_STT_MODEL,
|
||||||
|
canTranscribeWebm: true,
|
||||||
|
description: 'OpenAI 호환 엔드포인트라면 무엇이든 연결할 수 있습니다.',
|
||||||
|
apiKeyLabel: 'API 키',
|
||||||
|
apiKeyPlaceholder: 'sk-...',
|
||||||
|
apiKeyOptional: true,
|
||||||
|
modelHint: '엔드포인트가 제공하는 모델 ID를 그대로 입력하세요.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DEFAULT_PRESET_ID = 'gemini'
|
||||||
|
|
||||||
|
export function findPreset(presetId: string | null | undefined): ProviderPreset {
|
||||||
|
return (
|
||||||
|
PROVIDER_PRESETS.find((preset) => preset.id === presetId) ??
|
||||||
|
PROVIDER_PRESETS[0]
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* LLM 프로바이더 공통 타입.
|
||||||
|
*
|
||||||
|
* - `gemini`: Google Generative Language API를 직접 호출 (기본값, 기존 동작)
|
||||||
|
* - `openai-compatible`: OpenAI Chat Completions 형식을 따르는 모든 엔드포인트
|
||||||
|
* (OrcaRouter, OpenAI, Ollama, LM Studio, vLLM 등)
|
||||||
|
*/
|
||||||
|
export type ProviderId = 'gemini' | 'openai-compatible'
|
||||||
|
|
||||||
|
export const PROVIDER_IDS: readonly ProviderId[] = ['gemini', 'openai-compatible']
|
||||||
|
|
||||||
|
export function isProviderId(value: unknown): value is ProviderId {
|
||||||
|
return typeof value === 'string' && (PROVIDER_IDS as readonly string[]).includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderSettings {
|
||||||
|
provider: ProviderId
|
||||||
|
/** 로컬 모델 서버처럼 인증이 필요 없는 엔드포인트에서는 빈 문자열일 수 있다. */
|
||||||
|
apiKey: string
|
||||||
|
/** openai-compatible 전용. 예: https://api.orcarouter.ai/v1 */
|
||||||
|
baseUrl?: string
|
||||||
|
/** 비워두면 프로바이더별 기본 모델을 사용한다. */
|
||||||
|
model?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CompletionResult =
|
||||||
|
| { success: true; text: string }
|
||||||
|
| { success: false; error: string; rateLimited?: boolean }
|
||||||
|
|
||||||
|
export interface CompletionOptions {
|
||||||
|
fetchFn?: typeof fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------- *
|
||||||
|
* 음성 전사 (STT)
|
||||||
|
* ------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전사할 오디오.
|
||||||
|
*
|
||||||
|
* 파일 전체를 메모리에 올린다. 회의 하나가 상한(수십 MB) 안에 들어오도록
|
||||||
|
* 녹음 비트레이트를 낮춰 두었으므로 스트리밍까지 갈 필요는 없다.
|
||||||
|
*/
|
||||||
|
export interface TranscriptionInput {
|
||||||
|
bytes: Uint8Array
|
||||||
|
mimeType: string
|
||||||
|
fileName: string
|
||||||
|
/** BCP-47 앞부분. 예: 'ko'. 지정하면 인식 정확도가 눈에 띄게 오른다. */
|
||||||
|
language?: string
|
||||||
|
/**
|
||||||
|
* 어휘 힌트. 참석자 이름·제품명·사내 용어를 넣으면 고유명사 오인식이 준다.
|
||||||
|
* Whisper는 이 문자열을 직전 문맥처럼 취급한다(약 224토큰까지).
|
||||||
|
*/
|
||||||
|
vocabularyHint?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전사 구간. Whisper `verbose_json`이 주는 실제 오디오 타임라인 기준. */
|
||||||
|
export interface TranscriptionSegment {
|
||||||
|
/** 초 단위, 오디오 시작 기준. */
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TranscriptionResult =
|
||||||
|
| {
|
||||||
|
success: true
|
||||||
|
text: string
|
||||||
|
/** 프로바이더가 타임스탬프를 주지 않으면 비어 있다. */
|
||||||
|
segments: TranscriptionSegment[]
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
| { success: false; error: string; rateLimited?: boolean }
|
||||||
|
|
||||||
|
export interface TranscriptionOptions {
|
||||||
|
fetchFn?: typeof fetch
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전사에 쓸 모델은 대화용 모델과 다르므로 따로 받는다. */
|
||||||
|
export interface TranscriptionSettings extends ProviderSettings {
|
||||||
|
sttModel?: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* 녹음 조각을 디스크에 이어 붙이는 서버 저장소.
|
||||||
|
*
|
||||||
|
* 메모리에 모아 뒀다가 종료 시 한 번에 쓰지 않는다. 그러면 탭이나 서버가
|
||||||
|
* 죽는 순간 회의 전체가 사라진다. 조각이 도착할 때마다 곧바로 append 하므로
|
||||||
|
* 어느 시점에 중단되든 그때까지의 오디오는 파일로 남아 있다.
|
||||||
|
*
|
||||||
|
* MediaRecorder가 timeslice 모드에서 내보내는 조각은 첫 조각에 컨테이너
|
||||||
|
* 헤더가 들어 있고 이후에는 클러스터만 이어진다. 받은 순서대로 이어 붙이면
|
||||||
|
* 그대로 재생·전사 가능한 파일이 된다. 단 헤더의 duration 필드는 비어 있어
|
||||||
|
* 플레이어에 따라 탐색(seek)이 부정확할 수 있다 — 재전사에는 영향이 없다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes } from 'crypto'
|
||||||
|
import { createReadStream } from 'fs'
|
||||||
|
import type { Readable } from 'stream'
|
||||||
|
import { appendFile, mkdir, readFile, stat, writeFile } from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import {
|
||||||
|
MAX_RECORDING_BYTES,
|
||||||
|
extensionForMimeType,
|
||||||
|
isValidRecordingId,
|
||||||
|
} from './recording'
|
||||||
|
|
||||||
|
/** 별도 볼륨에 두고 싶으면 `RECORDINGS_DIR`로 덮어쓴다. */
|
||||||
|
export const RECORDINGS_DIR =
|
||||||
|
process.env.RECORDINGS_DIR ?? path.join(process.cwd(), 'uploads', 'recordings')
|
||||||
|
|
||||||
|
export interface RecordingMeta {
|
||||||
|
id: string
|
||||||
|
mimeType: string
|
||||||
|
/** `uploads/recordings/` 기준 상대 파일명. */
|
||||||
|
fileName: string
|
||||||
|
startedAt: string
|
||||||
|
finalizedAt: string | null
|
||||||
|
durationMs: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordingStatus extends RecordingMeta {
|
||||||
|
bytes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* id별 직렬화 큐.
|
||||||
|
*
|
||||||
|
* 조각의 순서가 곧 파일의 순서다. 두 요청이 겹쳐 들어오면 컨테이너가
|
||||||
|
* 깨지므로 같은 녹음에 대한 쓰기는 한 줄로 세운다.
|
||||||
|
*/
|
||||||
|
const queues = new Map<string, Promise<unknown>>()
|
||||||
|
|
||||||
|
function enqueue<T>(id: string, task: () => Promise<T>): Promise<T> {
|
||||||
|
const previous = queues.get(id) ?? Promise.resolve()
|
||||||
|
const next = previous.then(task, task)
|
||||||
|
|
||||||
|
// 앞 작업이 실패해도 뒤 작업은 돌아야 하므로, 큐에는 절대 거부되지 않는
|
||||||
|
// 프로미스를 넣는다. 실패는 호출자가 받는 `next`로만 전달된다.
|
||||||
|
const settled = next.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 큐가 무한정 자라지 않도록, 마지막 작업이 끝나면 항목을 지운다.
|
||||||
|
queues.set(id, settled)
|
||||||
|
settled.then(() => {
|
||||||
|
if (queues.get(id) === settled) queues.delete(id)
|
||||||
|
})
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
function metaPath(id: string): string {
|
||||||
|
return path.join(RECORDINGS_DIR, `${id}.json`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function audioPath(meta: RecordingMeta): string {
|
||||||
|
return path.join(RECORDINGS_DIR, meta.fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fileSize(filePath: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
return (await stat(filePath)).size
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 새 녹음 세션을 만든다. 오디오 파일은 첫 조각이 도착할 때 생긴다. */
|
||||||
|
export async function createRecording(
|
||||||
|
mimeType: string,
|
||||||
|
): Promise<RecordingMeta> {
|
||||||
|
const id = randomBytes(16).toString('hex')
|
||||||
|
const meta: RecordingMeta = {
|
||||||
|
id,
|
||||||
|
mimeType,
|
||||||
|
fileName: `${id}.${extensionForMimeType(mimeType)}`,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
finalizedAt: null,
|
||||||
|
durationMs: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(RECORDINGS_DIR, { recursive: true })
|
||||||
|
await writeFile(metaPath(id), JSON.stringify(meta, null, 2), 'utf-8')
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecording(id: string): Promise<RecordingMeta | null> {
|
||||||
|
if (!isValidRecordingId(id)) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await readFile(metaPath(id), 'utf-8')
|
||||||
|
return JSON.parse(raw) as RecordingMeta
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRecordingStatus(
|
||||||
|
id: string,
|
||||||
|
): Promise<RecordingStatus | null> {
|
||||||
|
const meta = await getRecording(id)
|
||||||
|
if (!meta) return null
|
||||||
|
|
||||||
|
return { ...meta, bytes: await fileSize(audioPath(meta)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AppendResult =
|
||||||
|
| { ok: true; bytes: number }
|
||||||
|
| { ok: false; error: string; status: number }
|
||||||
|
|
||||||
|
/** 조각 하나를 파일 끝에 이어 붙이고 누적 크기를 돌려준다. */
|
||||||
|
export async function appendChunk(
|
||||||
|
id: string,
|
||||||
|
chunk: Buffer,
|
||||||
|
): Promise<AppendResult> {
|
||||||
|
const meta = await getRecording(id)
|
||||||
|
if (!meta) {
|
||||||
|
return { ok: false, error: '녹음 세션을 찾을 수 없습니다.', status: 404 }
|
||||||
|
}
|
||||||
|
|
||||||
|
return enqueue(id, async () => {
|
||||||
|
const filePath = audioPath(meta)
|
||||||
|
const current = await fileSize(filePath)
|
||||||
|
|
||||||
|
if (current + chunk.byteLength > MAX_RECORDING_BYTES) {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
error: `녹음이 상한(${Math.round(
|
||||||
|
MAX_RECORDING_BYTES / 1024 / 1024,
|
||||||
|
)}MB)을 넘었습니다.`,
|
||||||
|
status: 413,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await appendFile(filePath, chunk)
|
||||||
|
return { ok: true as const, bytes: current + chunk.byteLength }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 녹음을 닫는다. 이후 조각은 더 오지 않는다고 보고 길이를 확정한다. */
|
||||||
|
export async function finalizeRecording(
|
||||||
|
id: string,
|
||||||
|
durationMs: number | null,
|
||||||
|
): Promise<RecordingStatus | null> {
|
||||||
|
const meta = await getRecording(id)
|
||||||
|
if (!meta) return null
|
||||||
|
|
||||||
|
return enqueue(id, async () => {
|
||||||
|
const updated: RecordingMeta = {
|
||||||
|
...meta,
|
||||||
|
finalizedAt: new Date().toISOString(),
|
||||||
|
durationMs:
|
||||||
|
typeof durationMs === 'number' && Number.isFinite(durationMs)
|
||||||
|
? Math.max(0, Math.round(durationMs))
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(metaPath(id), JSON.stringify(updated, null, 2), 'utf-8')
|
||||||
|
return { ...updated, bytes: await fileSize(audioPath(updated)) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 다운로드용 읽기 스트림. 파일이 없으면 null. */
|
||||||
|
export async function openRecording(
|
||||||
|
id: string,
|
||||||
|
): Promise<{ meta: RecordingMeta; bytes: number; stream: Readable } | null> {
|
||||||
|
const meta = await getRecording(id)
|
||||||
|
if (!meta) return null
|
||||||
|
|
||||||
|
const filePath = audioPath(meta)
|
||||||
|
const bytes = await fileSize(filePath)
|
||||||
|
if (bytes === 0) return null
|
||||||
|
|
||||||
|
return { meta, bytes, stream: createReadStream(filePath) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* 회의 오디오 녹음 — 브라우저·서버가 공유하는 상수와 순수 함수.
|
||||||
|
*
|
||||||
|
* 녹음의 목적은 재생이 아니라 **재전사**다. Web Speech가 놓친 발화를 나중에
|
||||||
|
* 서버 STT로 되살리려면 원본 오디오가 남아 있어야 한다. 그래서 전사와
|
||||||
|
* 무관하게, 전사가 실패하더라도 오디오만은 반드시 파일로 남긴다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** MediaRecorder에 우선순위대로 시도할 컨테이너/코덱. */
|
||||||
|
export const PREFERRED_MIME_TYPES = [
|
||||||
|
'audio/webm;codecs=opus',
|
||||||
|
'audio/webm',
|
||||||
|
'audio/ogg;codecs=opus',
|
||||||
|
'audio/mp4',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대면 회의 기준 캡처 제약.
|
||||||
|
*
|
||||||
|
* 브라우저 기본값은 세 가지가 모두 켜져 있고 전화 통화용으로 튜닝돼 있다.
|
||||||
|
* 노이즈 억제는 멀리 앉은 화자의 목소리를 노이즈로 오판해 지워버릴 수 있고,
|
||||||
|
* 에코 제거는 스피커 출력이 없는 대면 회의에서는 얻을 게 없다. 둘 다 끈다.
|
||||||
|
* 반면 AGC는 조용한 화자의 레벨을 끌어올려 주므로 남긴다.
|
||||||
|
*
|
||||||
|
* 원격 회의(스피커 출력이 마이크로 되먹임되는 경우)에는 echoCancellation을
|
||||||
|
* 다시 켜야 한다. 그 경로는 dual-stream 캡처와 함께 다룬다.
|
||||||
|
*/
|
||||||
|
export const RECORDING_AUDIO_CONSTRAINTS: MediaTrackConstraints = {
|
||||||
|
channelCount: 1,
|
||||||
|
echoCancellation: false,
|
||||||
|
noiseSuppression: false,
|
||||||
|
autoGainControl: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서버로 조각을 올리는 간격.
|
||||||
|
*
|
||||||
|
* 짧을수록 탭이 죽었을 때 잃는 양이 적고, 길수록 요청 수가 준다. 15초면
|
||||||
|
* 최악의 경우에도 15초치만 잃는다.
|
||||||
|
*/
|
||||||
|
export const CHUNK_INTERVAL_MS = 15_000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 녹음 비트레이트.
|
||||||
|
*
|
||||||
|
* 이 오디오는 감상용이 아니라 STT 입력이다. Opus는 음성 대역에서 32kbps만
|
||||||
|
* 되어도 인식 정확도에 영향이 없고, 대신 파일이 작아야 전사 API에 통째로
|
||||||
|
* 넣을 수 있다. 128kbps로 두면 46분 회의가 44MB가 되어 Whisper 상한(25MB)도,
|
||||||
|
* Gemini inline 상한도 넘긴다.
|
||||||
|
*
|
||||||
|
* 32kbps 기준 대략: 46분 → 11MB, 1시간 → 14MB, 1시간 40분 → 24MB
|
||||||
|
*/
|
||||||
|
export const AUDIO_BITS_PER_SECOND = 32_000
|
||||||
|
|
||||||
|
/** 회의 하나의 상한. 128kbps 기준 약 8.6시간. */
|
||||||
|
export const MAX_RECORDING_BYTES = 500 * 1024 * 1024
|
||||||
|
|
||||||
|
/** 조각 하나의 상한. 15초 × 128kbps면 240KB 남짓이라 넉넉하다. */
|
||||||
|
export const MAX_CHUNK_BYTES = 8 * 1024 * 1024
|
||||||
|
|
||||||
|
/** 브라우저가 지원하는 첫 번째 후보를 고른다. 없으면 null. */
|
||||||
|
export function pickRecorderMimeType(
|
||||||
|
isTypeSupported: (type: string) => boolean,
|
||||||
|
): string | null {
|
||||||
|
for (const type of PREFERRED_MIME_TYPES) {
|
||||||
|
if (isTypeSupported(type)) return type
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `audio/webm;codecs=opus` → `webm` */
|
||||||
|
export function extensionForMimeType(mimeType: string): string {
|
||||||
|
const base = mimeType.split(';')[0].trim().toLowerCase()
|
||||||
|
|
||||||
|
switch (base) {
|
||||||
|
case 'audio/webm':
|
||||||
|
return 'webm'
|
||||||
|
case 'audio/ogg':
|
||||||
|
return 'ogg'
|
||||||
|
case 'audio/mp4':
|
||||||
|
case 'audio/x-m4a':
|
||||||
|
return 'm4a'
|
||||||
|
case 'audio/mpeg':
|
||||||
|
return 'mp3'
|
||||||
|
case 'audio/wav':
|
||||||
|
case 'audio/x-wav':
|
||||||
|
return 'wav'
|
||||||
|
case 'audio/flac':
|
||||||
|
return 'flac'
|
||||||
|
default:
|
||||||
|
return 'bin'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 녹음 세션 식별자 검증.
|
||||||
|
*
|
||||||
|
* 이 값이 그대로 파일 경로가 되므로 `..`이나 구분자가 섞이면 안 된다.
|
||||||
|
* 경로 조립 전에 반드시 통과시킨다.
|
||||||
|
*/
|
||||||
|
const RECORDING_ID_PATTERN = /^[0-9a-f]{32}$/
|
||||||
|
|
||||||
|
export function isValidRecordingId(id: unknown): id is string {
|
||||||
|
return typeof id === 'string' && RECORDING_ID_PATTERN.test(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 사용자가 내려받을 때 보게 될 파일 이름. */
|
||||||
|
export function recordingDownloadName(
|
||||||
|
title: string,
|
||||||
|
startedAt: Date,
|
||||||
|
mimeType: string,
|
||||||
|
): string {
|
||||||
|
const stamp = [
|
||||||
|
startedAt.getFullYear(),
|
||||||
|
String(startedAt.getMonth() + 1).padStart(2, '0'),
|
||||||
|
String(startedAt.getDate()).padStart(2, '0'),
|
||||||
|
'-',
|
||||||
|
String(startedAt.getHours()).padStart(2, '0'),
|
||||||
|
String(startedAt.getMinutes()).padStart(2, '0'),
|
||||||
|
].join('')
|
||||||
|
|
||||||
|
const safeTitle = title
|
||||||
|
.trim()
|
||||||
|
.replace(/[\\/:*?"<>|]/g, '')
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.slice(0, 60)
|
||||||
|
|
||||||
|
const stem = safeTitle.length > 0 ? `${stamp}_${safeTitle}` : stamp
|
||||||
|
return `${stem}.${extensionForMimeType(mimeType)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (!Number.isFinite(bytes) || bytes < 0) return '0B'
|
||||||
|
if (bytes < 1024) return `${bytes}B`
|
||||||
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)}KB`
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)}MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(ms: number): string {
|
||||||
|
const total = Math.max(0, Math.floor(ms / 1000))
|
||||||
|
const h = Math.floor(total / 3600)
|
||||||
|
const m = Math.floor((total % 3600) / 60)
|
||||||
|
const s = total % 60
|
||||||
|
const mm = String(m).padStart(2, '0')
|
||||||
|
const ss = String(s).padStart(2, '0')
|
||||||
|
|
||||||
|
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`
|
||||||
|
}
|
||||||
+69
-32
@@ -34,20 +34,24 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
|
|||||||
depthAdjustable: true,
|
depthAdjustable: true,
|
||||||
basePrompt: `당신은 회의록 작성 전문가입니다. 아래 발화 내용을 분석하여 마크다운 회의록을 작성하세요.
|
basePrompt: `당신은 회의록 작성 전문가입니다. 아래 발화 내용을 분석하여 마크다운 회의록을 작성하세요.
|
||||||
|
|
||||||
포함할 섹션:
|
|
||||||
## 요약
|
## 요약
|
||||||
(3~5줄 핵심 내용)
|
회의 전체를 3~5줄로. 무엇을 논의했고 무엇이 정해졌는지.
|
||||||
|
|
||||||
## 주요 논의 사항
|
이어지는 본문은 **섹션 제목을 직접 지어서** 구성하세요:
|
||||||
1. 순번 매기기
|
- 실제로 논의된 주제를 스스로 파악해 \`##\` 제목을 붙여 나눕니다.
|
||||||
|
- "주요 논의 사항", "논의 내용" 같은 일반적인 제목은 쓰지 마세요.
|
||||||
|
이 회의가 무엇을 다뤘는지 제목만 봐도 알 수 있어야 합니다.
|
||||||
|
(예: "단계별 개발 계획", "수익화 방안", "기술적 고려사항")
|
||||||
|
- 주제 수는 내용에 따라 정하되 보통 3~6개입니다.
|
||||||
|
|
||||||
## 액션 아이템
|
## 액션 아이템
|
||||||
- [ ] 담당자와 기한이 명확한 항목만
|
반드시 \`- [ ]\` 체크박스 형식으로 작성하세요.
|
||||||
|
담당자가 파악되면 \`(담당자)\`를 앞에, 기한이 언급됐으면 뒤에 덧붙입니다.
|
||||||
|
예: \`- [ ] (김지훈) 경쟁 사이트 리스트업 — 5/17까지\`
|
||||||
|
담당자가 불분명해도 해야 할 일이 명확하면 포함하세요.
|
||||||
|
|
||||||
## 결정 사항
|
## 결정 사항
|
||||||
- 합의 또는 확정된 것만
|
- 합의되었거나 확정된 것만. 없으면 이 섹션을 생략하세요.`,
|
||||||
|
|
||||||
한국어로 작성하세요.`,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
lecture: {
|
lecture: {
|
||||||
@@ -60,20 +64,18 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
|
|||||||
depthAdjustable: true,
|
depthAdjustable: true,
|
||||||
basePrompt: `당신은 학습용 노트를 작성하는 전문가입니다. 강의/세미나 녹취를 구조화된 학습 자료로 정리하세요.
|
basePrompt: `당신은 학습용 노트를 작성하는 전문가입니다. 강의/세미나 녹취를 구조화된 학습 자료로 정리하세요.
|
||||||
|
|
||||||
포함할 섹션:
|
|
||||||
## 주요 주제
|
## 주요 주제
|
||||||
(한 줄 요약)
|
(한 줄 요약)
|
||||||
|
|
||||||
## 핵심 개념
|
## 핵심 개념
|
||||||
각 개념마다 ### 소제목 + 설명 + 구체 예시
|
각 개념마다 ### 소제목 + 설명 + 구체 예시
|
||||||
|
소제목은 강의에서 실제로 다룬 개념 이름을 그대로 쓰세요.
|
||||||
|
|
||||||
## 기억할 인용·예시
|
## 기억할 인용·예시
|
||||||
> 중요한 문장 인용 형식으로
|
> 중요한 문장 인용 형식으로
|
||||||
|
|
||||||
## 후속 질문
|
## 후속 질문
|
||||||
- 스스로 탐구해볼 만한 질문
|
- 스스로 탐구해볼 만한 질문`,
|
||||||
|
|
||||||
한국어로 작성하세요.`,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
one_on_one: {
|
one_on_one: {
|
||||||
@@ -86,12 +88,11 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
|
|||||||
depthAdjustable: true,
|
depthAdjustable: true,
|
||||||
basePrompt: `당신은 1:1 미팅 정리 전문가입니다. 개인적이고 신뢰 기반의 대화임을 존중하며 정리하세요.
|
basePrompt: `당신은 1:1 미팅 정리 전문가입니다. 개인적이고 신뢰 기반의 대화임을 존중하며 정리하세요.
|
||||||
|
|
||||||
포함할 섹션:
|
|
||||||
## 이번 세션 요지
|
## 이번 세션 요지
|
||||||
(2~3줄)
|
(2~3줄)
|
||||||
|
|
||||||
## 논의한 주제
|
이어서 대화에서 실제로 다룬 주제마다 **\`##\` 제목을 직접 지어** 나누세요.
|
||||||
- 주요 대화 흐름
|
"논의한 주제" 같은 일반적인 제목 대신, 무엇에 대한 이야기였는지 드러내는 제목을 쓰세요.
|
||||||
|
|
||||||
## 고민·블로커
|
## 고민·블로커
|
||||||
- 공유된 어려움
|
- 공유된 어려움
|
||||||
@@ -100,9 +101,7 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
|
|||||||
- 건설적 피드백 위주
|
- 건설적 피드백 위주
|
||||||
|
|
||||||
## 다음 미팅까지 할 일
|
## 다음 미팅까지 할 일
|
||||||
- [ ] 합의된 후속 조치
|
- [ ] 합의된 후속 조치. 담당자가 있으면 \`(담당자)\` 표기`,
|
||||||
|
|
||||||
한국어로 작성하세요.`,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
brainstorm: {
|
brainstorm: {
|
||||||
@@ -115,12 +114,12 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
|
|||||||
depthAdjustable: true,
|
depthAdjustable: true,
|
||||||
basePrompt: `당신은 브레인스토밍 세션을 정리하는 전문가입니다. 나온 아이디어를 분류하고 실행 가능성 관점에서 정리하세요.
|
basePrompt: `당신은 브레인스토밍 세션을 정리하는 전문가입니다. 나온 아이디어를 분류하고 실행 가능성 관점에서 정리하세요.
|
||||||
|
|
||||||
포함할 섹션:
|
|
||||||
## 세션 개요
|
## 세션 개요
|
||||||
(1~2줄 — 주제/목표)
|
(1~2줄 — 주제/목표)
|
||||||
|
|
||||||
## 아이디어 (카테고리별)
|
## 아이디어 (카테고리별)
|
||||||
### [카테고리 이름]
|
### [카테고리 이름]
|
||||||
|
카테고리는 미리 정해진 목록이 아니라, 나온 아이디어를 보고 직접 묶어서 이름 붙이세요.
|
||||||
- 아이디어 요약
|
- 아이디어 요약
|
||||||
|
|
||||||
## 즉시 시도 가능
|
## 즉시 시도 가능
|
||||||
@@ -130,9 +129,7 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
|
|||||||
- 사유 간단히
|
- 사유 간단히
|
||||||
|
|
||||||
## 다음 스텝
|
## 다음 스텝
|
||||||
- 구체적인 후속 행동
|
- [ ] 구체적인 후속 행동. 담당자가 있으면 \`(담당자)\` 표기`,
|
||||||
|
|
||||||
한국어로 작성하세요.`,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
interview: {
|
interview: {
|
||||||
@@ -145,7 +142,6 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
|
|||||||
depthAdjustable: true,
|
depthAdjustable: true,
|
||||||
basePrompt: `당신은 인터뷰 녹취를 Q&A 형식으로 정리하는 전문가입니다.
|
basePrompt: `당신은 인터뷰 녹취를 Q&A 형식으로 정리하는 전문가입니다.
|
||||||
|
|
||||||
포함할 섹션:
|
|
||||||
## 개요
|
## 개요
|
||||||
(인터뷰 대상/주제 1~2줄)
|
(인터뷰 대상/주제 1~2줄)
|
||||||
|
|
||||||
@@ -159,9 +155,7 @@ A: [답변 요약]
|
|||||||
> 직접 인용
|
> 직접 인용
|
||||||
|
|
||||||
## 종합 인상
|
## 종합 인상
|
||||||
- 전반적 테마 / 놓치지 말 것
|
- 전반적 테마 / 놓치지 말 것`,
|
||||||
|
|
||||||
한국어로 작성하세요.`,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
raw: {
|
raw: {
|
||||||
@@ -181,8 +175,7 @@ A: [답변 요약]
|
|||||||
엄격한 규칙:
|
엄격한 규칙:
|
||||||
- 내용을 삭제하거나 축약하지 마세요
|
- 내용을 삭제하거나 축약하지 마세요
|
||||||
- 구조적 제목(## 섹션)을 덧붙이지 마세요
|
- 구조적 제목(## 섹션)을 덧붙이지 마세요
|
||||||
- 타임스탬프가 있으면 그대로 유지하세요
|
- 타임스탬프가 있으면 그대로 유지하세요`,
|
||||||
- 한국어로 작성하세요.`,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,13 +188,41 @@ const DEPTH_MODIFIERS: Record<SummaryDepth, string> = {
|
|||||||
'작성 강도: **상세**. 원문의 주요 세부사항·수치·고유명사를 누락 없이 포함하세요. 구조만 바꾸고 내용은 최대한 보존합니다.',
|
'작성 강도: **상세**. 원문의 주요 세부사항·수치·고유명사를 누락 없이 포함하세요. 구조만 바꾸고 내용은 최대한 보존합니다.',
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIVE_MODIFIER = `회의가 아직 진행 중입니다. 지금까지의 내용을 기반으로 **중간 정리**를 작성하세요. 확정되지 않은 결정은 "(논의 중)"으로 표시하세요.`
|
const LIVE_MODIFIER = `회의가 아직 진행 중입니다. 지금까지의 내용을 기반으로 **중간 정리**를 작성하세요. 확정되지 않은 결정은 "(논의 중)"으로 표시하세요.
|
||||||
|
섹션 제목은 지금까지 나온 주제를 기준으로 붙이되, 이후 갱신될 수 있으므로 간결하게 유지하세요.`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 프리셋 템플릿 전체에 공통으로 덧붙는 규칙.
|
||||||
|
*
|
||||||
|
* custom 프롬프트에는 적용하지 않는다 — 사용자가 전적으로 제어하는 영역이기 때문.
|
||||||
|
*/
|
||||||
|
const COMMON_RULES = `공통 규칙:
|
||||||
|
- 발화에 없는 내용을 지어내지 마세요. 불확실하면 쓰지 마세요.
|
||||||
|
- 발화자 표기가 있다면 누가 무엇을 주장하고 약속했는지 반영하세요.
|
||||||
|
- 영어 기술 용어·제품명은 번역하거나 음차하지 말고 원문 표기를 유지하세요 (예: latency, deployment).
|
||||||
|
- 한국어로 작성하세요.`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 증분 갱신 모드.
|
||||||
|
*
|
||||||
|
* 전사 전체를 매번 다시 보내는 대신 [지금까지의 요약] + [새로 추가된 발화]만 보낸다.
|
||||||
|
* 호출당 토큰이 회의 길이와 무관하게 일정해져, 비용이 제곱이 아닌 선형으로 늘어난다.
|
||||||
|
*/
|
||||||
|
const INCREMENTAL_MODIFIER = `아래에는 [지금까지의 요약]과 [새로 추가된 발화]가 주어집니다.
|
||||||
|
기존 요약을 처음부터 다시 쓰지 말고 **갱신**하세요:
|
||||||
|
- 기존 요약의 내용과 구조를 유지한 채 새 발화를 반영합니다.
|
||||||
|
- 기존 항목이 새 발화로 확정되거나 번복되었다면 그 항목을 고치세요.
|
||||||
|
- 새 발화에 언급되지 않았다는 이유로 기존 내용을 삭제하지 마세요.
|
||||||
|
- 출력은 항상 갱신된 회의록 **전체**입니다. 변경분만 출력하지 마세요.`
|
||||||
|
|
||||||
export interface BuildPromptArgs {
|
export interface BuildPromptArgs {
|
||||||
templateId: TemplateId
|
templateId: TemplateId
|
||||||
depth: SummaryDepth
|
depth: SummaryDepth
|
||||||
|
/** 증분 모드에서는 [지금까지의 요약] + [새로 추가된 발화]를 담은 블록이 들어온다. */
|
||||||
transcript: string
|
transcript: string
|
||||||
live?: boolean
|
live?: boolean
|
||||||
|
/** 직전 요약을 갱신하는 모드. live와 함께 쓴다. */
|
||||||
|
incremental?: boolean
|
||||||
customPrompt?: string
|
customPrompt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,14 +243,21 @@ export function resolveDepth(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildPrompt(args: BuildPromptArgs): string {
|
export function buildPrompt(args: BuildPromptArgs): string {
|
||||||
const { templateId, depth, transcript, live = false, customPrompt } = args
|
const {
|
||||||
|
templateId,
|
||||||
|
depth,
|
||||||
|
transcript,
|
||||||
|
live = false,
|
||||||
|
incremental = false,
|
||||||
|
customPrompt,
|
||||||
|
} = args
|
||||||
|
|
||||||
let instruction: string
|
let instruction: string
|
||||||
|
|
||||||
if (templateId === 'custom') {
|
if (templateId === 'custom') {
|
||||||
const custom = customPrompt?.trim()
|
const custom = customPrompt?.trim()
|
||||||
if (!custom) {
|
if (!custom) {
|
||||||
instruction = TEMPLATES.meeting.basePrompt
|
instruction = `${TEMPLATES.meeting.basePrompt}\n\n${COMMON_RULES}`
|
||||||
} else {
|
} else {
|
||||||
instruction = custom
|
instruction = custom
|
||||||
}
|
}
|
||||||
@@ -243,12 +271,21 @@ export function buildPrompt(args: BuildPromptArgs): string {
|
|||||||
|
|
||||||
if (live && template.liveSupported) {
|
if (live && template.liveSupported) {
|
||||||
parts.push(LIVE_MODIFIER)
|
parts.push(LIVE_MODIFIER)
|
||||||
|
|
||||||
|
if (incremental) {
|
||||||
|
parts.push(INCREMENTAL_MODIFIER)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
parts.push(COMMON_RULES)
|
||||||
|
|
||||||
instruction = parts.join('\n\n')
|
instruction = parts.join('\n\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${instruction}\n\n---\n음성 인식 텍스트:\n${transcript}`
|
// 증분 모드의 transcript는 자체 라벨([지금까지의 요약] 등)을 이미 포함한다.
|
||||||
|
const body = incremental ? transcript : `음성 인식 텍스트:\n${transcript}`
|
||||||
|
|
||||||
|
return `${instruction}\n\n---\n${body}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function liveEnabledFor(templateId: TemplateId): boolean {
|
export function liveEnabledFor(templateId: TemplateId): boolean {
|
||||||
|
|||||||
@@ -50,3 +50,32 @@ export function mergeAdjacentChunks(
|
|||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서버 STT가 준 구간을 전사문으로 옮긴다.
|
||||||
|
*
|
||||||
|
* 여기 붙는 타임스탬프는 실제 오디오 타임라인이다. Web Speech 경로가 쓰던
|
||||||
|
* `결과 이벤트 시각 − 2초` 추정값과 달리 화자 분리·구간 재생에 그대로 쓸 수 있다.
|
||||||
|
*/
|
||||||
|
export function formatTranscriptionSegments(
|
||||||
|
segments: readonly { start: number; text: string }[],
|
||||||
|
): string {
|
||||||
|
return segments
|
||||||
|
.filter((segment) => segment.text.trim().length > 0)
|
||||||
|
.map((segment) => `${formatTimestamp(segment.start)} ${segment.text.trim()}`)
|
||||||
|
.join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** STT 구간을 기존 청크 구조로 변환한다 (화자 배정·병합 로직 재사용용). */
|
||||||
|
export function segmentsToChunks(
|
||||||
|
segments: readonly { start: number; end: number; text: string }[],
|
||||||
|
): TranscriptChunk[] {
|
||||||
|
return segments
|
||||||
|
.filter((segment) => segment.text.trim().length > 0)
|
||||||
|
.map((segment) => ({
|
||||||
|
text: segment.text.trim(),
|
||||||
|
startTime: Math.max(0, segment.start),
|
||||||
|
endTime: Math.max(segment.start, segment.end),
|
||||||
|
isFinal: true,
|
||||||
|
}))
|
||||||
|
}
|
||||||
Reference in new issue
Block a user