Author SHA1 Message Date
csbaeandClaude Opus 5 dacf3e617f fix: Web Speech 유실 지점 정리 + 긴 회의 분할 요약
P0에 남아 있던 세 가지. 실측 46분 회의 transcript에서 확인된 문제를
하나씩 코드로 막는다.

## 1. Chrome이 흘리는 것을 붙잡는다

**미확정 발화 보존** — Chrome은 `continuous`여도 침묵마다 세션을 닫는데,
그때 확정 전이던 문장을 그냥 버린다. 1시간 회의면 이 사이클이 수십 번 돌고
매번 마지막 한 문장씩 사라졌다. 세션이 끝날 때마다 직접 확정시킨다.

**마지막 발화 보존** — `handleStop`이 렌더 시점의 `chunks`를 그대로 넘기는
바람에 회의 끝머리가 항상 빠졌다. `stopListening()`이 미확정분까지 굳힌
최종 청크를 돌려주도록 바꿨다.

**인식 실패 가시화** — `isFinal: true`에 `transcript: ""`인 결과가 실측에서
19번 나왔다. "소리는 들었는데 못 알아듣겠다"는 신호다. 청크로 넣으면 요약
프롬프트가 오염되고, 조용히 버리면 유실이 보이지 않는다. 빼되 세어서
`⚠️ 인식 실패 N건` 배지로 노출한다.

**미리보기로 강등** — 실시간 텍스트 패널에 (미리보기)를 붙였다. 확정본은
서버 STT 재전사가 만든다.

## 2. 타임스탬프 실측화

`startTime: Math.max(0, now - 2)` 하드코딩을 걷어냈다. interim이 처음 뜬
시각이 곧 발화 시작이므로 그것을 쓴다. 또 `startRecording()`이 캡처 시작
시각을 돌려주고 `startListening(timeOrigin)`이 그것을 받아, 전사 타임스탬프가
오디오 파일의 재생 위치와 같은 시간축을 쓴다.

## 3. 긴 회의가 마지막에 통째로 실패하지 않는다

`/api/summarize`가 10만 자를 넘으면 413으로 거부했다. 3시간 회의를 마치고
회의록을 만들려는 순간 아무것도 못 받는다는 뜻이다. 유실을 막자고 만든
파이프라인 끝에서 결과를 버리는 셈이었다.

구간별로 압축한 뒤(map) 압축본을 모아 평소와 똑같은 템플릿 경로로 회의록을
만든다(reduce). 최종 출력이 짧은 회의와 같은 프롬프트를 타므로 서식이
흔들리지 않는다. 상한은 50만 자(약 14시간)로 올렸다.

한 구간이라도 실패하면 전체를 실패로 돌린다. 일부만 빠진 압축본으로 회의록을
만들면 사용자는 무엇이 빠졌는지 모른 채 멀쩡해 보이는 문서를 받게 된다.

## 검증

브라우저에서 가짜 SpeechRecognition을 주입해 확인:
- 빈 확정 2건 → 청크 0개 + "인식 실패 2건" 배지
- 미확정 마지막 발화가 중지 후 전사문에 남음
- 빈 `[MM:SS]` 줄이 하나도 없음

테스트 229 → 257 통과, 신규 타입 에러 0, 린트 baseline과 동일, 빌드 성공.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzAH7GPWSYTe2AoBV6CZDP
2026-09-09 18:09:05 +09:00
csbaeandClaude Opus 5 b115663ecf feat: 서버 STT 재전사 — 오디오 원본에서 확정 전사문을 뽑는다
#18이 오디오를 남기게 했다면, 이 PR은 그 오디오를 실제로 쓴다.
Web Speech 포착률 7~10%를 끌어올리는 유일한 방법은 인식 엔진 교체다.

## 두 경로가 하나로 만난다

    실시간 녹음 ─┐
                 ├→ recordingId → /api/transcribe → 확정 전사문
    파일 업로드 ─┘

`/api/upload`는 파일을 디스크에 떨궈만 두고 "실시간 녹음 탭에서 하세요"라고
안내하던 막다른 길이었다. 이제 녹음 저장소에 넣고 recordingId를 돌려주므로
브라우저 녹음과 완전히 같은 파이프라인을 탄다.

## 실제 타임스탬프

Whisper `verbose_json`이 구간별 실제 오디오 시각을 준다. Web Speech 경로가 쓰던
`결과 이벤트 시각 − 2초` 추정값과 달리 구간 재생·화자 분리에 그대로 쓸 수 있다.
이 값이 있어야 PR #17의 diarization 화자 배정이 비로소 의미를 갖는다.

verbose_json을 지원하지 않는 모델(gpt-4o-transcribe 등)은 400을 주므로 `json`
으로 한 번 더 시도해 텍스트만이라도 받는다.

## 프로바이더별 지원

| 프로바이더 | webm 녹음 | 업로드 파일 | 기본 모델 |
|---|:---:|:---:|---|
| OpenAI · OrcaRouter · 로컬 whisper | O | O | whisper-1 |
| Gemini | X | O | gemini-3.6-flash |

Gemini가 문서로 밝힌 오디오 형식에 webm이 없다. 조용히 실패시키지 않고
"OpenAI 호환 프로바이더를 쓰라"고 명시적으로 안내하며, 설정 화면에도 경고를 띄운다.

## 비트레이트 128k → 32k

이 오디오는 감상용이 아니라 STT 입력이다. 128kbps면 46분 회의가 44MB가 되어
Whisper 상한(25MB)도 Gemini inline 상한도 넘긴다. 32kbps mono Opus는 음성
인식 정확도에 영향이 없으면서 46분을 11MB로 줄인다.

## 참석자·용어 힌트

홈 화면에 입력란을 두고 전사 요청의 어휘 힌트로 보낸다. 실측 transcript에서
"계명대동산병원 → 저희 키스해 주셔서", "양식대로 → 양복점" 같은 고유명사
붕괴가 심했던 부분이다.

## 검증

가짜 Whisper 서버를 세워 종단간 확인:
- 업로드 → recordingId → 전사 → `[00:00]/[00:03]/[00:12]` 전사문
- multipart 필드 검증: model · response_format=verbose_json · language=ko ·
  prompt(어휘 힌트) · Bearer 인증 · 파일 바이트 정확히 일치
- webm 녹음도 Whisper로 정상 전사 (5000 bytes 그대로 전달)
- Gemini+webm 거부 / 미설정 400 / 경로조작 400 / 없는 녹음 404

테스트 206 → 229 통과, 신규 타입 에러 0, 린트 baseline과 동일, 빌드 성공.

### 검증하지 못한 것

실제 Whisper·Gemini API를 호출하지 못했다(키 없음). 모든 프로바이더 코드는
목 fetch와 가짜 서버로만 검증했다. 요청 형식은 문서를 따랐으나 실제 응답에
대한 확인이 필요하다.

포착률이 실제로 얼마나 오르는지도 아직 모른다. 다음 회의에서 같은 오디오로
Web Speech와 STT를 나란히 놓고 재야 한다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzAH7GPWSYTe2AoBV6CZDP
2026-09-09 15:19:49 +09:00
csbaeandClaude Opus 5 cf3ce3f40c feat: 회의 오디오 원본 보관 — 전사가 놓친 발화를 되살릴 수 있게
46분 대면 회의 실측에서 Web Speech 포착률이 7~10%에 그쳤다(489어절 /
분당 10.6어절, 30초 이상 공백 26곳 26분 29초). 빈 텍스트 청크 19개는
Chrome이 `isFinal: true`에 `transcript: ""`를 준 것으로, 소리는 감지했으나
인식에 실패했다는 신호다.

지금까지는 오디오를 어디에도 남기지 않아 놓친 발화를 복구할 수도, 얼마나
놓쳤는지 잴 수도 없었다. 전사와 독립적으로 원음을 파일로 남긴다.

## 유실 방지 설계

메모리에 모았다가 종료 시 한 번에 쓰지 않는다. 그러면 탭이나 서버가 죽는
순간 회의 전체가 사라진다. 15초 조각마다 디스크에 append 하므로 어느
시점에 중단되든 그때까지의 오디오는 남는다.

- 서버 업로드가 실패해도 녹음을 멈추지 않고 브라우저 사본으로 회수한다
- 조각이 3회 재시도 후에도 실패하면 뒤를 이어 붙이지 않고 멈춘다.
  중간이 빈 파일은 짧은 파일보다 나쁘다 — 재생도 전사도 안 된다
- 한 조각도 못 받았으면 "보관됨"이라고 하지 않고 오류를 띄운다
- 서버 사본이 로컬보다 짧으면 경고한다

## 캡처 제약 변경

`noiseSuppression: false, echoCancellation: false, autoGainControl: true`.
브라우저 기본값은 전화 통화용 튜닝이라 멀리 앉은 화자를 노이즈로 지운다.
AGC는 조용한 화자를 끌어올려 주므로 남긴다. 실측으로 검증할 가설이며
회귀 방지 테스트를 걸어 뒀다.

## 검증

- 서버 파이프라인: 184조각 755KB 업로드 → 다운로드 SHA256 바이트 일치
- 클라이언트 전 경로: 합성 스트림으로 48초 녹음 → 로컬·서버 크기 일치,
  서버 파일이 decodeAudioData로 48.12초 실제 오디오로 디코딩됨
- 경로 조작 400 / 없는 세션 404 / 빈 조각 400 / 미허용 mimeType 400
- 테스트 162 → 206 통과, 신규 타입 에러 0, 린트 baseline과 동일

실제 마이크 경로는 테스트하지 못했다. Web Speech와 getUserMedia가 같은
마이크를 두고 경합하는지 실사용 확인이 필요하다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzAH7GPWSYTe2AoBV6CZDP
2026-09-09 15:04:04 +09:00
9af00c3016 feat: 회의록 섹션을 내용 기반으로 생성하고 액션 아이템에 담당자 표기 (#13)
고정 제목(## 주요 논의 사항)을 강요하던 프롬프트를, 회의가 실제로 다룬
주제에서 섹션 제목을 도출하도록 바꿨다. 제목만 훑어도 내용이 파악된다.

- meeting·one_on_one: 본문 섹션 제목을 AI가 직접 짓도록 지시
- brainstorm: 카테고리도 미리 정해진 목록이 아님을 명시
- 액션 아이템에 (담당자)·기한 표기 요청 + 예시 제공
- COMMON_RULES 신설 — 환각 방지, 발화자 표기 활용,
  영어 기술 용어 원문 유지(한영 코드 스위칭 대응). 프리셋에만 적용하고
  custom 프롬프트는 사용자가 전적으로 제어하도록 유지
- LIVE_MODIFIER에 섹션 제목이 갱신될 수 있다는 안내 추가

버그 수정: 기존 meeting 프롬프트는 액션 아이템을 "담당자와 기한이 명확한
항목만"으로 제한해, 담당자를 정하지 않은 회의에서는 액션 아이템이 하나도
추출되지 않았다. 담당자가 불분명해도 포함하도록 변경.

`- [ ]` 체크박스 형식은 그대로 유지해 parseActionItems와 호환된다.
테스트 102 → 113.

Co-authored-by: csbae <csbae@RP-002.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:17:22 +09:00
ecd3192b40 perf: 롤링 요약을 증분 방식으로 전환해 토큰 비용을 선형화 (#15)
롤링 요약이 30초마다 누적 전사 전체를 다시 보내고 있었다. 호출 N회차가
그때까지의 전사 전부를 담으므로 총 토큰이 회의 길이의 제곱으로 늘어난다.
1시간 회의 기준 약 116만 입력 토큰, 2시간이면 2배가 아니라 4배가 된다.

[지금까지의 요약] + [새로 추가된 발화]만 보내도록 바꿔 호출당 토큰을
회의 길이와 무관하게 일정하게 만들었다.

- templates.ts: BuildPromptArgs.incremental 추가, INCREMENTAL_MODIFIER 신설.
  증분 모드에서는 전사 블록이 자체 라벨을 갖는다
- live-summary.ts: previousSummary 옵션. 값이 있으면 증분 모드로 동작
- live-summary.ts: planLiveSummaryRequest() — 전체/증분 결정을 순수 함수로
  분리해 단위 테스트 가능하게 함
- useLiveSummary: 마지막 요약 지점 인덱스를 추적해 델타만 전송.
  성공했을 때만 전진시켜 실패해도 발화를 잃지 않는다
- 요약을 요약하는 구조라 오차가 누적되므로 fullRefreshEvery(기본 20회)마다
  전사 전체로 한 번 다시 요약해 오차를 끊는다

기본 설정에서 1시간 회의 기준 약 7배, 전체 재요약을 끄면 약 12배 절감된다.
회의가 2~3분보다 짧으면 지시문 오버헤드 때문에 오히려 조금 늘어난다.

테스트 141 → 151.

Co-authored-by: csbae <csbae@RP-002.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:16:15 +09:00
352ac2ffd1 feat: OpenAI 호환 프로바이더 지원 (OrcaRouter · OpenAI · 로컬 모델) (#12)
* feat: OpenAI 호환 프로바이더 지원 (OrcaRouter · OpenAI · 로컬 모델)

Gemini 직접 호출만 가능하던 구조를 프로바이더 레이어로 분리.
base URL과 모델 ID만 지정하면 OpenAI Chat Completions 형식을 따르는
엔드포인트는 모두 연결된다 (OrcaRouter, OpenAI, Ollama, LM Studio, vLLM).

- src/lib/providers/ 신설 (gemini / openai-compatible 어댑터 + 프리셋)
- /settings에 프로바이더 선택 UI 추가, 기존 Gemini 키는 자동 승계
- LLM_PROVIDER / LLM_BASE_URL / LLM_MODEL / LLM_API_KEY 환경변수 지원
- base URL은 http/https만 허용 (서버가 대신 fetch하므로 SSRF 방어)
- SECURITY.md에 프로바이더별 전송 경로와 SSRF 주의사항 문서화
- 테스트 102 → 141

DB에 저장되는 summaryMode 값('gemini')은 기존 레코드 호환을 위해 유지하고
UI 라벨만 "AI 요약"으로 변경했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: 기본 Gemini 모델을 3.5 Flash Lite로 갱신

gemini-2.5-flash-lite → gemini-3.5-flash-lite. 같은 저비용·고속 티어의
최신 세대이며, 무료 등급 중심의 사용 프로필을 그대로 유지한다.

- providers/gemini.ts: DEFAULT_GEMINI_MODEL
- presets.ts: OrcaRouter 기본 모델과 모델 힌트 문구
- .env.example, README 참조 갱신

참고: 화자 분리를 지원하는 gemini-3.5-transcribe는 오디오 입력 전용
음성인식 모델이라 이 자리(텍스트 → 회의록 요약)에 넣을 수 없다.
오디오 캡처가 들어오는 시점에 STT 경로로 별도 추가해야 한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: csbae <csbae@RP-002.local>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:14:30 +09:00
48 changed files with 5686 additions and 317 deletions

No files matched your search

+39 -6
View File
@@ -25,13 +25,46 @@ POSTGRES_PASSWORD=
DATABASE_URL=
# -----------------------------------------------------------------------------
# 선택 — Gemini API 키
# 선택 — AI 프로바이더
# -----------------------------------------------------------------------------
# Gemini AI 요약 기능을 쓰려면 키가 필요합니다.
# 두 가지 방법 중 선택:
# (A) 웹 UI 방식 — 앱 기동 후 /settings에서 입력 (브라우저 LocalStorage 저장, 추천)
# (B) 환경변수 방식 — 아래 값 채우면 모든 사용자가 공통으로 사용
# AI 요약 기능을 쓰려면 프로바이더 설정이 필요합니다. 두 가지 방법 중 선택:
# (A) 웹 UI 방식 — 앱 기동 후 /settings에서 선택·입력 (브라우저 LocalStorage, 추천)
# (B) 환경변수 방식 — 아래 값을 채우면 모든 사용자가 공통으로 사용
#
# 설정이 없어도 "단순 변환" 모드는 정상 동작합니다.
# --- (기본) Google Gemini 직접 호출 ---
# 무료 발급: https://aistudio.google.com/apikey
# 키 없이도 "단순 변환" 모드는 정상 동작합니다.
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=
+150 -25
View File
@@ -28,7 +28,7 @@ docker compose up -d
- [⚡ 실시간 롤링 요약](#-실시간-롤링-요약)
- [💾 회의록 저장/조회/검색](#-회의록-저장조회검색)
- [✅ 액션 아이템 자동 추출](#-액션-아이템-자동-추출)
- [🔑 웹에서 API 키 설정](#-웹에서-api-키-설정)
- [🔑 AI 프로바이더 & API 키 설정](#-ai-프로바이더--api-키-설정)
- [📋 Google Docs 호환 복사](#-google-docs-호환-복사)
- [🚀 시작하기](#-시작하기)
- [Docker Compose (권장)](#docker-compose-권장)
@@ -51,10 +51,78 @@ docker compose up -d
**특징**
- 즉시 시작 — 별도 STT 서버나 키 없이 작동
- **오디오 원본 동시 녹음** — 전사와 무관하게 회의 원음을 파일로 보관
- **자동 재연결** — 네트워크 끊김 시 최대 8회 재시도, 누적 transcript 보존
- 확정 전 텍스트는 회색 이탤릭 + 깜빡이는 커서로 시각화
- 녹음 중 `🔴 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. 홈에서 **🎤 녹음 시작** 클릭
2. 마이크 권한 허용
@@ -63,20 +131,48 @@ docker compose up -d
---
### 📏 긴 회의 (map-reduce 요약)
전사문이 6만 자를 넘으면 한 번에 모델에 넣지 못합니다. 예전에는 10만 자에서
**413으로 거부**해, 3시간짜리 회의를 마치고 회의록을 만들려는 순간 아무것도 받지
못했습니다. 유실을 막자고 만든 파이프라인 끝에서 결과를 통째로 버리는 셈이었습니다.
지금은 구간별로 한 번 압축한 뒤(map), 압축본을 모아 **평소와 똑같은 템플릿 경로로**
회의록을 만듭니다(reduce). 최종 출력이 짧은 회의와 같은 프롬프트를 타므로 서식이
흔들리지 않습니다.
- 압축 창 4만 자 · 단일 통과 상한 6만 자 · 전체 상한 50만 자(약 14시간 분량)
- **한 구간이라도 실패하면 전체를 실패로 돌립니다.** 일부만 빠진 압축본으로 회의록을
만들면 사용자는 무엇이 빠졌는지 모른 채 멀쩡해 보이는 문서를 받게 됩니다
- 압축을 거치면 회의록 상단에 몇 개 구간으로 나눴는지 안내합니다
---
### 🤖 AI 회의록 (6 템플릿 × 3 강도)
회의 외에도 다양한 용도에 맞춰 출력 구조를 선택할 수 있습니다.
| 템플릿 | 생성되는 구조 | 기본 강도 | 라이브 요약 |
|---|---|---|---|
| 🗂️ 회의록 | 요약 / 논의 / 액션 / 결정 | 표준 | ✅ |
| 🗂️ 회의록 | 요약 / **내용 기반 주제 섹션** / 액션 / 결정 | 표준 | ✅ |
| 🎓 강의·세미나 노트 | 핵심 개념 / 예시 / 인용 / 후속 질문 | 상세 | ✅ |
| 🤝 1:1 미팅 | 주제 / 고민 / 피드백 / 다음 액션 | 표준 | ✅ |
| 🤝 1:1 미팅 | 요지 / **내용 기반 주제 섹션** / 고민 / 피드백 / 다음 액션 | 표준 | ✅ |
| 💡 브레인스토밍 | 카테고리별 아이디어 / 즉시 시도 / 보류 | 상세 | ✅ |
| 🎤 인터뷰 | Q&A 포맷 / 인상적 발언 / 종합 | 표준 | ❌ |
| 📝 원문 정리 | 요약 없이 문단화·오탈자 정리만 | 상세 고정 | ❌ |
| ⚙️ 커스텀 | 자유 프롬프트 입력 | - | ✅ |
**내용 기반 주제 섹션**
회의록·1:1 템플릿은 `## 주요 논의 사항` 같은 고정 제목을 쓰지 않습니다. 그 회의가 실제로 무엇을
다뤘는지에 따라 AI가 섹션 제목을 직접 짓습니다 — 예를 들어 `## 단계별 개발 계획`,
`## 수익화 방안`, `## 기술적 고려사항` 처럼. 제목만 훑어도 회의 내용이 파악됩니다.
**액션 아이템 담당자**
발화에서 담당자가 파악되면 `- [ ] (김지훈) 경쟁 사이트 리스트업 — 5/17까지` 형태로 이름과 기한이 붙습니다.
담당자가 정해지지 않은 항목도 버리지 않고 그대로 수집합니다.
**강도 3단계**
- **간결** — 각 섹션 3줄 이내
- **표준** — 맥락이 이해될 정도
@@ -101,7 +197,7 @@ docker compose up -d
- 429 쿼터 초과 시 60초 자동 쿨다운, UI에 카운트다운 노출
- 일반 실패 시 지수 백오프 (15s → 30s → 최대 120s)
**비용 가드 (Gemini 2.5 Flash Lite 무료 등급 기준)**
**비용 가드 (Gemini Flash Lite 무료 등급 기준)**
- 15 RPM / 1000 RPD / 250K TPM
- 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에 저장되지 않음)
@@ -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 표시)
summarize-live → 503 + 안내
@@ -175,12 +284,14 @@ docker compose up -d
**기능**
- 마스킹된 현재 키 표시 (`AIza••••XYZ12`)
- 보이기/숨기기 토글
- **🧪 테스트 호출** — 가벼운 Gemini 응답으로 즉시 키 검증
- 현재 키 소스 배지 (`브라우저` / `환경변수` / `없음`)
- **🧪 테스트 호출** — 가벼운 응답으로 즉시 설정 검증 (사용된 모델명 표시)
- 현재 소스 배지 (`브라우저` / `환경변수` / `없음`)
- 기존에 Gemini 키만 저장해둔 사용자는 **재입력 없이 그대로 동작** (자동 승계)
**보안**
- HTTPS 권장 (LocalStorage는 동일 출처 정책 의존)
- GET 응답에 절대 풀 키 노출 안 함
- base URL은 `http`/`https`만 허용 — 자세한 내용은 [SECURITY.md](SECURITY.md)
---
@@ -231,12 +342,13 @@ DATABASE_URL=postgresql://meetinguser:<위와_같은_비번>@localhost:5432/meet
> ⚠️ `POSTGRES_PASSWORD`를 비워두면 docker compose가 명시적 에러로 실패합니다. 보안을 위한 의도된 동작.
> 💡 **Gemini API 키는 두 가지 방법 중 선택**
> 💡 **AI 프로바이더 설정은 두 가지 방법 중 선택**
>
> - **(A) 웹 UI** — 앱 기동 후 `/settings`에서 입력 (브라우저 LocalStorage, 추천)
> - **(B) `.env` 환경변수** — `GEMINI_API_KEY=AIza...` 작성
> - **(A) 웹 UI** — 앱 기동 후 `/settings`에서 프로바이더 선택 + 키 입력 (브라우저 LocalStorage, 추천)
> - **(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회)
@@ -304,11 +416,11 @@ npm run test:coverage # 커버리지 리포트
|------|------|
| 프레임워크 | Next.js 16 (App Router) + React 19 + TypeScript |
| 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`) |
| Markdown | `marked` + `isomorphic-dompurify` |
| 스타일 | Tailwind CSS v4 |
| 테스트 | Vitest 4 (jsdom, 102 tests) |
| 테스트 | Vitest 4 (jsdom, 141 tests) |
| 배포 | Docker Compose + standalone Next.js 빌드 |
---
@@ -339,14 +451,20 @@ src/
│ ├── page.tsx # 메인 (녹음/요약)
│ └── layout.tsx
├── 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)
│ ├── markdown.ts # marked + DOMPurify + Docs용 복사
│ ├── action-items.ts # - [ ] 휴리스틱 파서
│ ├── api-keys.ts # 서버: 요청 키 → env fallback
│ ├── api-key-storage.ts # 클라: LocalStorage + 마스킹
│ ├── api-keys.ts # 서버: 요청 설정 → env fallback
│ ├── api-key-storage.ts # 클라: LocalStorage + 마스킹 + 프로바이더 설정
│ ├── templates.ts # 템플릿 레지스트리 + 프롬프트 빌더
│ ├── minutes-generator.ts # 최종 요약 생성
│ ├── live-summary.ts # 롤링 요약 생성
│ ├── minutes-generator.ts # 최종 요약 생성 (프로바이더 무관)
│ ├── live-summary.ts # 롤링 요약 생성 (프로바이더 무관)
│ ├── transcript-formatter.ts
│ ├── audio-validation.ts
│ ├── upload-handler.ts
@@ -409,7 +527,7 @@ prisma/
- 같은 머신을 다른 사람과 공유하지 않는 것을 가정
- 회의 transcript / 회의록은 평문으로 PostgreSQL에 저장됨 (디스크 암호화는 호스트 OS에 위임)
### Gemini API 키
### API 키
- LocalStorage 저장 (브라우저 동일 출처 정책으로 보호)
- 서버 DB에 저장되지 않음
- 요청 시점에만 body로 전송, 일회성 사용
@@ -417,10 +535,14 @@ prisma/
### 외부 데이터 송출
- **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에서 서버 사이드 전사 검토 |
| **화자 구분 없음** | 의도적 비지원 (Non-goal) | — |
| **Chrome 전용** | Web Speech API는 비표준 — Safari / Firefox는 제한적 | 서버 사이드 STT 전환 예정 |
| **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 | 한도 초과 시 자동 쿨다운, 단순 변환 폴백 |
| **단일 사용자** | 인증 없음, 데이터 격리 없음 | 1인 1인스턴스로 운용 |
| **마크다운 검색** | PostgreSQL `ILIKE` (수천 건 이상에서 느려질 수 있음) | 필요 시 `tsvector` 인덱스 추가 |
+70 -4
View File
@@ -48,9 +48,9 @@
| 데이터 | 저장 위치 | 외부 송출 |
|---|---|---|
| 회의 transcript / 회의록 | PostgreSQL (`meetings.markdownMinutes`, `rawTranscript`) | Gemini AI 요약 활성화 시 transcript가 Google로 전송 |
| 회의 transcript / 회의록 | PostgreSQL (`meetings.markdownMinutes`, `rawTranscript`) | AI 요약 활성화 시 transcript가 **선택한 프로바이더**로 전송 |
| 액션 아이템 | PostgreSQL (`action_items`) | 외부 송출 없음 |
| Gemini API 키 | 브라우저 LocalStorage 또는 `.env` | 요청 시 Google에만 전송 |
| API 키 | 브라우저 LocalStorage 또는 `.env` | 요청 시 선택한 프로바이더에만 전송 |
| 음성 데이터 | 메모리 (실시간), `/app/uploads` (파일 업로드) | **Chrome Web Speech API → Google 서버** ⚠️ |
### ⚠️ 주의: Web Speech API의 음성 외부 전송
@@ -63,6 +63,51 @@ Chrome의 `SpeechRecognition` API는 **음성 데이터를 Google 서버로 전
이 경우 **파일 업로드 탭**도 같은 한계가 있으므로(현재 업로드 후 전사는 미구현, 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/settings/status`: 키 존재 여부만 응답 (값 노출 X)
- `/api/settings/test`: 401/403/429 분류, detail 200자로 제한
- `getStoredApiKey` GET 요청에 절대 포함 안 함 (body 전송만)
- `/api/settings/test`: 401/403/429 분류, 업스트림 응답 본문을 그대로 돌려주지 않음
- 키는 GET 요청에 절대 포함 안 함 (body 전송만)
- `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 추가
- 회사 환경에서는 SSO 통합이 필요하지만 현재 비목표
### MEDIUM — 회의 원음이 디스크에 평문으로 남습니다
**현황**: 실시간 녹음을 시작하면 회의 오디오 원본이 `uploads/recordings/`에 저장됩니다
(Docker에서는 `uploads` 볼륨). 암호화하지 않은 평문 오디오이며, 회의에서 오간
말이 그대로 들어 있습니다. **전사 텍스트보다 민감도가 높습니다** — 텍스트는
Web Speech가 대부분 놓치지만 오디오에는 전부 남습니다.
이 저장은 의도된 설계입니다. Web Speech의 포착률이 낮아(실측 10% 안팎) 원음을
남기지 않으면 놓친 발화를 복구할 방법이 없습니다.
**완화**:
- 디스크 암호화(FileVault / BitLocker)가 켜진 머신에서 운용
- 불필요해진 녹음은 `uploads/recordings/`에서 직접 삭제
- `RECORDINGS_DIR` 환경변수로 저장 위치를 별도 암호화 볼륨으로 지정 가능
- 녹음 파일 자동 만료/삭제는 **미구현** — 수동 관리가 필요합니다
**세션 ID 추측**: 녹음 조회·추가 API는 128비트 난수 ID만으로 접근을 가릅니다.
인증이 없으므로, 같은 머신에서 앱에 접근 가능한 주체는 ID를 알면 오디오를
내려받을 수 있습니다. 단일 사용자 로컬 실행 전제입니다.
### MEDIUM — 파일 업로드 매직 바이트 미검증
**현황**: `audio-validation.ts`는 `file.type`(클라이언트 제공)과 파일명 확장자만 검사. 매직 바이트 검증 없음.
+73 -1
View File
@@ -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일)
### Issues
@@ -199,7 +272,6 @@
다음 기능은 개인 사용 목적에 부합하지 않아 **의도적으로 범위 밖**:
- **화자 구분 (Speaker Diarization)** — 유료 API 비용/복잡도 대비 개인용에 과함
- **실시간 공동 편집 (CRDT)** — Yjs/Liveblocks 도입 복잡도 대비 이득 없음
- **권한 관리 / 워크스페이스** — 단일 사용자 가정
- **모바일 네이티브 앱** — 웹 PWA로 충분
+105
View File
@@ -4,6 +4,10 @@ import {
setStoredApiKey,
clearStoredApiKey,
maskApiKey,
getStoredProviderConfig,
setStoredProviderConfig,
clearStoredProviderConfig,
getProviderRequestPayload,
} from '@/lib/api-key-storage'
describe('LocalStorage api key helpers', () => {
@@ -48,3 +52,104 @@ describe('maskApiKey', () => {
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')
})
})
+142 -1
View File
@@ -1,5 +1,10 @@
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', () => {
const originalEnv = process.env.GEMINI_API_KEY
@@ -70,3 +75,139 @@ describe('isEnvKeyConfigured', () => {
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)
})
})
+159 -1
View File
@@ -1,5 +1,8 @@
import { describe, it, expect, vi } from 'vitest'
import { generateLiveSummary } from '@/lib/live-summary'
import {
generateLiveSummary,
planLiveSummaryRequest,
} from '@/lib/live-summary'
describe('generateLiveSummary', () => {
it('Gemini API 응답을 받아 중간 요약 마크다운을 반환한다', async () => {
@@ -128,3 +131,158 @@ describe('generateLiveSummary', () => {
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,
)
})
})
+181
View File
@@ -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)
})
})
+47
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest'
import {
generateSimpleMinutes,
generateAiMinutes,
generateGeminiMinutes,
type MinutesInput,
} 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)
})
})
+222
View File
@@ -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)
}
})
})
+164
View File
@@ -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()
})
})
+149
View File
@@ -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)
})
})
+116
View File
@@ -164,3 +164,119 @@ describe('liveEnabledFor / depthAdjustableFor', () => {
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('작성 강도')
})
})
+335
View File
@@ -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()
})
})
+278
View File
@@ -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([])
})
})
+11
View File
@@ -79,6 +79,9 @@ export async function POST(request: NextRequest) {
customPrompt,
attendees,
tags,
audioFileName,
audioMimeType,
audioDuration,
} = body
if (!title || typeof title !== 'string' || title.trim().length === 0) {
@@ -112,6 +115,14 @@ export async function POST(request: NextRequest) {
tags: Array.isArray(tags)
? 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',
actionItems: {
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 },
)
}
}
+86
View File
@@ -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) },
})
}
+31
View File
@@ -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 },
)
}
}
+2 -1
View File
@@ -1,9 +1,10 @@
import { isEnvKeyConfigured } from '@/lib/api-keys'
import { isEnvKeyConfigured, isEnvProviderConfigured } from '@/lib/api-keys'
export const dynamic = 'force-dynamic'
export async function GET() {
return Response.json({
envConfigured: isEnvKeyConfigured(),
envProviderConfigured: isEnvProviderConfigured(),
})
}
+11 -38
View File
@@ -1,5 +1,6 @@
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'
@@ -8,60 +9,32 @@ const TEST_PROMPT = '"OK"라고만 한 단어로 답하세요.'
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}))
const apiKey = resolveGeminiApiKey(body.apiKey)
const settings = resolveProviderSettings(body)
if (!apiKey) {
if (!settings) {
return Response.json(
{
ok: false,
error: '키가 비어 있습니다.',
error: '설정이 비어 있습니다. 키(또는 base URL과 모델)를 확인해주세요.',
},
{ status: 400 },
)
}
const url =
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
const result = await complete(TEST_PROMPT, settings)
const response = await fetch(url, {
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}`
if (!result.success) {
return Response.json(
{
ok: false,
error: message,
detail: errorText.slice(0, 200),
},
{ ok: false, error: result.error },
{ status: 200 },
)
}
const data = await response.json()
const reply: string =
data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? ''
return Response.json({
ok: true,
message: '키가 정상적으로 동작합니다.',
reply: reply.slice(0, 50),
message: '설정이 정상적으로 동작합니다.',
model: describeModel(settings),
reply: result.text.trim().slice(0, 50),
})
} catch (err) {
const message = err instanceof Error ? err.message : '알 수 없는 오류'
+14 -6
View File
@@ -1,14 +1,15 @@
import { NextRequest } from 'next/server'
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'
const MAX_TRANSCRIPT_CHARS = 40_000
const MAX_PREVIOUS_SUMMARY_CHARS = 8_000
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { transcript, template, depth, customPrompt, apiKey } = body
const { transcript, template, depth, customPrompt, previousSummary } = body
if (!transcript || typeof transcript !== 'string') {
return Response.json(
@@ -17,12 +18,12 @@ export async function POST(request: NextRequest) {
)
}
const resolvedKey = resolveGeminiApiKey(apiKey)
if (!resolvedKey) {
const settings = resolveProviderSettings(body)
if (!settings) {
return Response.json(
{
error:
'Gemini API 키가 설정되지 않았습니다. /settings에서 키를 입력해주세요.',
'AI 프로바이더가 설정되지 않았습니다. /settings에서 키를 입력해주세요.',
},
{ status: 503 },
)
@@ -33,8 +34,15 @@ export async function POST(request: NextRequest) {
? transcript.slice(-MAX_TRANSCRIPT_CHARS)
: transcript
// 증분 모드: 직전 요약 + 새 발화만 보내므로 호출당 토큰이 일정하다.
const previous =
typeof previousSummary === 'string'
? previousSummary.slice(-MAX_PREVIOUS_SUMMARY_CHARS)
: undefined
const result = await generateLiveSummary(truncated, {
apiKey: resolvedKey,
provider: settings,
previousSummary: previous,
template: (template as TemplateId | undefined) ?? 'meeting',
depth: depth as SummaryDepth | undefined,
customPrompt:
+44 -21
View File
@@ -1,26 +1,20 @@
import { NextRequest } from 'next/server'
import {
generateGeminiMinutes,
generateAiMinutes,
generateSimpleMinutes,
} 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'
const MAX_TRANSCRIPT_CHARS = 100_000
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const {
title,
transcript,
mode,
date,
template,
depth,
customPrompt,
apiKey,
} = body
const { title, transcript, mode, date, template, depth, customPrompt } = body
if (!transcript || typeof transcript !== 'string') {
return Response.json(
@@ -48,30 +42,59 @@ export async function POST(request: NextRequest) {
typeof customPrompt === 'string' ? customPrompt : undefined,
}
if (mode === 'gemini') {
const resolvedKey = resolveGeminiApiKey(apiKey)
if (!resolvedKey) {
// 'gemini'는 DB에 저장된 기존 값과의 호환을 위해 유지되는 AI 모드 식별자다.
if (mode === 'gemini' || mode === 'ai') {
const settings = resolveProviderSettings(body)
if (!settings) {
const fallback = generateSimpleMinutes(input)
return Response.json({
markdown: fallback,
mode: 'simple',
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) {
const fallback = generateSimpleMinutes(input)
return Response.json({
markdown: fallback,
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)
+117
View File
@@ -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
View File
@@ -1,10 +1,20 @@
import { NextRequest } from 'next/server'
import { handleUpload } from '@/lib/upload-handler'
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import {
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) {
try {
const formData = await request.formData()
@@ -15,19 +25,21 @@ export async function POST(request: NextRequest) {
}
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()
const safeFileName = `${timestamp}_${result.data.fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`
const filePath = path.join(UPLOAD_DIR, safeFileName)
if (!appended.ok) {
return Response.json({ error: appended.error }, { status: appended.status })
}
await writeFile(filePath, buffer)
await finalizeRecording(meta.id, null)
return Response.json({
...result.data,
savedAs: safeFileName,
recordingId: meta.id,
savedAs: meta.fileName,
})
} catch (err) {
const message = err instanceof Error ? err.message : '알 수 없는 오류'
+95 -8
View File
@@ -1,11 +1,14 @@
'use client'
import { useMemo, useState } from 'react'
import { useCallback, useMemo, useState } from 'react'
import Link from 'next/link'
import { AudioUploader } from '@/components/upload/AudioUploader'
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 { getStoredApiKey } from '@/lib/api-key-storage'
import { getProviderRequestPayload } from '@/lib/api-key-storage'
import {
TEMPLATES,
DEFAULT_TEMPLATE_ID,
@@ -43,6 +46,9 @@ export default function HomePage() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | 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(
() => [
@@ -77,6 +83,7 @@ export default function HomePage() {
async function handleUpload(file: File) {
setUploadedFile(file)
setUploadedRecordingId(null)
setError(null)
const formData = new FormData()
@@ -93,6 +100,7 @@ export default function HomePage() {
}
if (!title) setTitle(data.title)
setUploadedRecordingId(data.recordingId ?? null)
} catch {
setError('파일 업로드에 실패했습니다.')
}
@@ -119,7 +127,7 @@ export default function HomePage() {
template,
depth,
customPrompt: template === 'custom' ? customPrompt : undefined,
apiKey: getStoredApiKey(),
...getProviderRequestPayload(),
}),
})
const data = await res.json()
@@ -146,6 +154,37 @@ export default function HomePage() {
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 =
summaryMode === 'gemini' && activeTemplateMeta.live && tab === 'record'
@@ -188,6 +227,22 @@ export default function HomePage() {
/>
</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">
<div className="flex items-center gap-4">
<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'
}`}
>
Gemini AI 요약
AI 요약
</button>
</div>
</div>
@@ -328,6 +383,10 @@ export default function HomePage() {
{tab === 'record' ? (
<LiveRecorder
onTranscriptReady={handleTranscriptReady}
onRecordingReady={handleRecordingReady}
onTranscriptReplaced={setTranscript}
title={title}
vocabularyHint={attendees}
liveSummaryEnabled={liveSummaryActive}
template={template}
depth={depth}
@@ -352,12 +411,39 @@ export default function HomePage() {
</section>
)}
{uploadedFile && !transcript && (
<section className="mb-8">
{uploadedFile && (
<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">
<strong>{uploadedFile.name}</strong> 업로드 완료.
실시간 녹음 탭에서 음성 인식을 시작하거나, 텍스트를 직접 입력해주세요.
<strong>{uploadedFile.name}</strong> 업로드 완료
{uploadedRecordingId
? ' — 아래 버튼으로 전사를 시작하세요.'
: ' — 서버 저장에 실패해 전사할 수 없습니다.'}
</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>
)}
@@ -391,6 +477,7 @@ export default function HomePage() {
depth={depth}
customPrompt={template === 'custom' ? customPrompt : undefined}
summaryMode={summaryMode}
audio={audioMeta}
/>
)}
</div>
+200 -50
View File
@@ -4,71 +4,131 @@ import Link from 'next/link'
import { useEffect, useState } from 'react'
import {
clearStoredApiKey,
clearStoredProviderConfig,
getStoredApiKey,
getStoredProviderConfig,
maskApiKey,
setStoredApiKey,
setStoredProviderConfig,
} from '@/lib/api-key-storage'
import {
DEFAULT_PRESET_ID,
PROVIDER_PRESETS,
findPreset,
} from '@/lib/providers'
type TestState =
| { status: 'idle' }
| { status: 'testing' }
| { status: 'success'; message: string; reply?: string }
| { status: 'success'; message: string; model?: string; reply?: string }
| { status: 'failed'; message: string }
export default function SettingsPage() {
const [storedKey, setStoredKey] = useState<string | null>(null)
const [draftKey, setDraftKey] = useState('')
const [presetId, setPresetId] = useState(DEFAULT_PRESET_ID)
const [apiKey, setApiKey] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [model, setModel] = useState('')
const [sttModel, setSttModel] = useState('')
const [showKey, setShowKey] = useState(false)
const [savedKey, setSavedKey] = useState<string | null>(null)
const [envConfigured, setEnvConfigured] = useState<boolean | null>(null)
const [testState, setTestState] = useState<TestState>({ status: 'idle' })
const [savedToast, setSavedToast] = useState(false)
const preset = findPreset(presetId)
const isOpenAiCompatible = preset.provider === 'openai-compatible'
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')
.then((r) => r.json())
.then((d) => setEnvConfigured(!!d.envConfigured))
.then((d) => setEnvConfigured(!!d.envConfigured || !!d.envProviderConfigured))
.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() {
const trimmed = draftKey.trim()
if (!trimmed) return
setStoredApiKey(trimmed)
setStoredKey(trimmed)
setDraftKey('')
setStoredProviderConfig({
presetId,
apiKey: apiKey.trim(),
baseUrl: baseUrl.trim(),
model: model.trim(),
sttModel: sttModel.trim(),
})
setSavedKey(apiKey.trim() || null)
setSavedToast(true)
setTimeout(() => setSavedToast(false), 2000)
}
function handleClear() {
if (!confirm('브라우저에 저장된 키를 삭제할까요? 환경변수가 설정되어 있다면 그것을 사용합니다.')) {
if (
!confirm(
'브라우저에 저장된 프로바이더 설정과 키를 삭제할까요? 환경변수가 설정되어 있다면 그것을 사용합니다.',
)
) {
return
}
clearStoredProviderConfig()
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' })
}
async function handleTest() {
const keyToTest = draftKey.trim() || storedKey
if (!keyToTest) {
setTestState({ status: 'failed', message: '테스트할 키가 없습니다.' })
return
}
setTestState({ status: 'testing' })
try {
const res = await fetch('/api/settings/test', {
method: 'POST',
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()
if (data.ok) {
setTestState({
status: 'success',
message: data.message,
model: data.model,
reply: data.reply,
})
} 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)'
: envConfigured
? '환경변수 (서버)'
: '없음'
const activeBadgeStyle = storedKey
const activeBadgeStyle = savedKey
? 'bg-purple-100 text-purple-700'
: envConfigured
? 'bg-green-100 text-green-700'
@@ -117,15 +182,15 @@ export default function SettingsPage() {
⚙️ 설정
</h1>
<p className="mt-2 text-sm text-neutral-500">
Gemini API 키를 브라우저에 저장합니다. 키는 서버에 저장되지 않으며,
요청 시점에만 전송되어 사용됩니다.
AI 요약에 사용할 프로바이더와 키를 브라우저에 저장합니다. 키는 서버 DB에
저장되지 않으며, 요청 시점에만 전송되어 사용됩니다.
</p>
</header>
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-neutral-800">
현재 키 소스
현재 설정
</h2>
<span
className={`text-xs px-2.5 py-1 rounded-full font-medium ${activeBadgeStyle}`}
@@ -135,16 +200,28 @@ export default function SettingsPage() {
</div>
<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>
<span className="inline-block w-32 text-neutral-500">브라우저 키:</span>{' '}
{storedKey ? (
<span className="font-mono">{maskApiKey(storedKey)}</span>
{savedKey ? (
<span className="font-mono">{maskApiKey(savedKey)}</span>
) : (
<span className="text-neutral-400">미설정</span>
)}
</p>
<p>
<span className="inline-block w-32 text-neutral-500">환경변수 키:</span>{' '}
<span className="inline-block w-32 text-neutral-500">환경변수:</span>{' '}
{envConfigured === null ? (
<span className="text-neutral-400">확인 중...</span>
) : envConfigured ? (
@@ -156,24 +233,99 @@ export default function SettingsPage() {
</div>
<p className="mt-3 text-xs text-neutral-500">
💡 우선순위: 브라우저 키 → 환경변수 → 단순 변환 폴백
💡 우선순위: 브라우저 설정 → 환경변수 → 단순 변환 폴백
</p>
</section>
<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">
Gemini API 키
프로바이더
</h2>
<label className="block mb-2 text-sm text-neutral-600">
새 키 입력
<div className="flex flex-wrap gap-2">
{PROVIDER_PRESETS.map((item) => (
<button
key={item.id}
onClick={() => handlePresetChange(item.id)}
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-all ${
presetId === item.id
? 'border-purple-400 bg-purple-50 text-purple-700'
: 'border-neutral-200 bg-white text-neutral-600 hover:border-neutral-300 hover:bg-neutral-50'
}`}
>
{item.label}
</button>
))}
</div>
<p className="mt-3 text-xs text-neutral-500">{preset.description}</p>
{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={draftKey}
onChange={(e) => setDraftKey(e.target.value)}
placeholder="AIzaSy..."
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
@@ -184,45 +336,41 @@ export default function SettingsPage() {
{showKey ? '🙈' : '👁️'}
</button>
</div>
{preset.docsUrl && (
<p className="mt-2 text-xs text-neutral-500">
<a
href="https://aistudio.google.com/apikey"
href={preset.docsUrl}
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-purple-600"
>
Google AI Studio
{preset.docsLabel ?? preset.docsUrl}
</a>
에서 무료로 발급할 수 있습니다.
에서 발급할 수 있습니다.
</p>
)}
</div>
<div className="mt-4 flex flex-wrap gap-2">
<div className="mt-5 flex flex-wrap gap-2">
<button
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"
>
💾 저장
</button>
<button
onClick={handleTest}
disabled={
testState.status === 'testing' ||
(!draftKey.trim() && !storedKey)
}
disabled={!canTest}
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' ? '테스트 중...' : '🧪 테스트 호출'}
</button>
{storedKey && (
<button
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"
>
🗑️ 삭제
</button>
)}
</div>
{savedToast && (
@@ -234,11 +382,10 @@ export default function SettingsPage() {
{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">
<strong>✓ {testState.message}</strong>
{testState.reply && (
<p className="mt-1 font-mono text-xs text-green-600">
Gemini 응답: {testState.reply}
{testState.model ? `${testState.model} → ` : ''}
{testState.reply}
</p>
)}
</div>
)}
@@ -265,6 +412,9 @@ export default function SettingsPage() {
<li className="text-amber-700">
⚠️ <strong>회의 내용에 민감 정보가 포함된 경우</strong>, 실시간 녹음 기능은 음성을 Google 서버로 전송합니다 (Chrome Web Speech API 동작). 사내 컴플라이언스 정책 확인 후 사용해주세요.
</li>
<li className="text-amber-700">
⚠️ <strong>중계 서비스를 고르면 회의 전문이 그 회사 서버를 거칩니다.</strong> OpenAI·Gemini 직접 호출은 해당 회사만, OrcaRouter 같은 라우터는 라우터 운영사 + 실제 모델 제공사 양쪽을 거칩니다. 외부 전송이 곤란하면 <strong>로컬 모델</strong>을 선택하세요.
</li>
</ul>
</section>
</div>
+9 -1
View File
@@ -22,6 +22,12 @@ interface MinutesViewerProps {
depth?: SummaryDepth
customPrompt?: string
summaryMode?: 'simple' | 'gemini'
/** 이 회의록의 원본 오디오. 서버에 보관된 녹음이 있을 때만 붙는다. */
audio?: {
audioFileName: string
audioMimeType: string
audioDuration: number
}
}
type SaveState =
@@ -38,6 +44,7 @@ export function MinutesViewer({
template,
depth,
customPrompt,
audio,
summaryMode,
}: MinutesViewerProps) {
const [renderedView, setRenderedView] = useState<'rendered' | 'raw'>(
@@ -110,6 +117,7 @@ export function MinutesViewer({
template,
depth,
customPrompt,
...audio,
}),
})
const data = await res.json()
@@ -126,7 +134,7 @@ export function MinutesViewer({
}
}
const modeLabel = mode === 'gemini' ? 'Gemini AI 요약' : '단순 변환'
const modeLabel = mode === 'gemini' ? 'AI 요약' : '단순 변환'
return (
<div className="space-y-4">
+164 -6
View File
@@ -3,11 +3,20 @@
import { useEffect, useRef } from 'react'
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
import { useLiveSummary } from '@/hooks/useLiveSummary'
import { useAudioRecorder, type CompletedRecording } from '@/hooks/useAudioRecorder'
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
import { useTranscription } from '@/hooks/useTranscription'
import { formatBytes, formatDuration } from '@/lib/recording'
import type { SummaryDepth, TemplateId } from '@/lib/templates'
interface LiveRecorderProps {
onTranscriptReady: (transcript: string) => void
onRecordingReady?: (recording: CompletedRecording) => void
/** STT 재전사 결과로 전사문을 갈아끼운다. */
onTranscriptReplaced?: (transcript: string) => void
title: string
/** 참석자 등 고유명사 힌트. STT 정확도를 올린다. */
vocabularyHint?: string
liveSummaryEnabled: boolean
template: TemplateId
depth: SummaryDepth
@@ -16,6 +25,10 @@ interface LiveRecorderProps {
export function LiveRecorder({
onTranscriptReady,
onRecordingReady,
onTranscriptReplaced,
title,
vocabularyHint,
liveSummaryEnabled,
template,
depth,
@@ -27,11 +40,35 @@ export function LiveRecorder({
chunks,
interimText,
error: recognitionError,
missedCount,
startListening,
stopListening,
resetChunks,
} = 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 {
summary,
isSummarizing,
@@ -59,6 +96,11 @@ export function LiveRecorder({
summaryEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [summary])
// 녹음이 끝나 파일이 확정되면 상위로 올려 회의록과 함께 저장되게 한다.
useEffect(() => {
if (recording) onRecordingReady?.(recording)
}, [recording, onRecordingReady])
if (isSupported === null) {
return (
<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() {
stopListening()
const transcript = formatTranscriptChunks(chunks)
async function handleStart() {
resetRecording()
resetTranscription()
// 오디오 녹음을 먼저 건다. 전사가 실패하더라도 원본은 남아야 한다.
// 녹음 시작 시각을 인식기에 넘겨, 전사 타임스탬프가 오디오 파일의
// 재생 위치와 같은 시간축을 쓰게 한다.
const audioStartedAt = await startRecording()
startListening(audioStartedAt ?? undefined)
}
async function handleStop() {
// 훅이 미확정 발화까지 굳혀서 돌려준다. 렌더 시점의 `chunks`를 쓰면
// 회의 마지막 문장이 빠진다.
const finalChunks = stopListening()
const transcript = formatTranscriptChunks(finalChunks)
if (transcript.length > 0) {
onTranscriptReady(transcript)
}
await stopRecording()
}
const lastUpdatedLabel = lastUpdatedAt
@@ -136,7 +193,7 @@ export function LiveRecorder({
</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"
>
<span className="text-lg">🎤</span>
@@ -144,9 +201,37 @@ export function LiveRecorder({
</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 && (
<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"
>
초기화
@@ -159,7 +244,47 @@ export function LiveRecorder({
녹음 중 · {wordCount}단어 · {chunks.length}개 구간
</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>
{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 && (
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
@@ -167,6 +292,39 @@ export function LiveRecorder({
</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 && (
<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">
@@ -193,7 +351,7 @@ export function LiveRecorder({
}`}
/>
<h3 className="text-xs font-semibold text-neutral-600 uppercase tracking-wider">
실시간 텍스트
실시간 텍스트 <span className="normal-case font-normal text-neutral-400">(미리보기)</span>
</h3>
</div>
<span className="text-xs text-neutral-500">
+394
View File
@@ -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,
}
}
+39 -5
View File
@@ -4,13 +4,16 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import type { TranscriptChunk } from '@/lib/transcript-formatter'
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
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 {
enabled: boolean
pollIntervalMs?: number
minWords?: number
incrementWords?: number
/** 증분 요약을 이 횟수만큼 반복하면 전사 전체로 한 번 다시 요약한다. 0이면 끈다. */
fullRefreshEvery?: number
template?: TemplateId
depth?: SummaryDepth
customPrompt?: string
@@ -41,6 +44,7 @@ export function useLiveSummary(
pollIntervalMs = 30_000,
minWords = 25,
incrementWords = 40,
fullRefreshEvery = 20,
template = 'meeting',
depth,
customPrompt,
@@ -64,6 +68,11 @@ export function useLiveSummary(
const cooldownUntilRef = useRef<number | null>(null)
const consecutiveFailuresRef = useRef(0)
// 증분 요약 상태 — 성공했을 때만 전진시켜서 실패해도 발화를 잃지 않는다.
const summaryRef = useRef('')
const lastSummarizedIndexRef = useRef(0)
const incrementsSinceFullRef = useRef(0)
useEffect(() => {
chunksRef.current = chunks
}, [chunks])
@@ -98,12 +107,31 @@ export function useLiveSummary(
return
}
const transcript = formatTranscriptChunks(chunksRef.current)
const allChunks = chunksRef.current
const transcript = formatTranscriptChunks(allChunks)
const currentWordCount = countWords(transcript)
if (currentWordCount < minWords) 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()
abortRef.current = controller
inFlightRef.current = true
@@ -115,11 +143,13 @@ export function useLiveSummary(
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transcript,
transcript: payloadTranscript,
previousSummary:
plan.mode === 'incremental' ? summaryRef.current : undefined,
template: configRef.current.template,
depth: configRef.current.depth,
customPrompt: configRef.current.customPrompt,
apiKey: getStoredApiKey(),
...getProviderRequestPayload(),
}),
signal: controller.signal,
})
@@ -142,8 +172,12 @@ export function useLiveSummary(
const data = await res.json()
setSummary(data.markdown)
summaryRef.current = data.markdown
setLastUpdatedAt(Date.now())
lastWordCountRef.current = currentWordCount
lastSummarizedIndexRef.current = chunkCountAtSend
incrementsSinceFullRef.current =
plan.mode === 'full' ? 0 : incrementsSinceFullRef.current + 1
consecutiveFailuresRef.current = 0
cooldownUntilRef.current = null
setCooldownUntil(null)
@@ -164,7 +198,7 @@ export function useLiveSummary(
return () => {
clearInterval(interval)
}
}, [enabled, pollIntervalMs, minWords, incrementWords])
}, [enabled, pollIntervalMs, minWords, incrementWords, fullRefreshEvery])
useEffect(() => {
return () => {
+114 -23
View File
@@ -9,8 +9,20 @@ interface SpeechRecognitionHook {
chunks: TranscriptChunk[]
interimText: string
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
}
@@ -30,16 +42,31 @@ function describeError(code: string): string {
}
}
/** 발화 시작을 못 잡았을 때 되돌아갈 기본 길이(초). */
const FALLBACK_UTTERANCE_SECONDS = 2
export function useSpeechRecognition(): SpeechRecognitionHook {
const [isListening, setIsListening] = useState(false)
const [chunks, setChunks] = useState<TranscriptChunk[]>([])
const [interimText, setInterimText] = useState('')
const [error, setError] = useState<string | null>(null)
const [isSupported, setIsSupported] = useState<boolean | null>(null)
const [missedCount, setMissedCount] = useState(0)
const recognitionRef = useRef<SpeechRecognition | null>(null)
const startTimeRef = 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
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) {
setError('이 브라우저는 음성 인식을 지원하지 않습니다. Chrome을 사용해주세요.')
return
@@ -64,8 +128,10 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
recognition.continuous = true
recognition.interimResults = true
startTimeRef.current = Date.now()
startTimeRef.current = timeOrigin ?? Date.now()
networkRetryRef.current = 0
interimRef.current = ''
utteranceStartRef.current = null
setError(null)
recognition.onresult = (event: SpeechRecognitionEvent) => {
@@ -74,32 +140,47 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
setError(null)
}
const now = (Date.now() - startTimeRef.current) / 1000
const now = elapsed()
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i]
const text = result[0].transcript.trim()
if (result.isFinal) {
setChunks((prev) => [
...prev,
{
text,
startTime: Math.max(0, now - 2),
endTime: now,
isFinal: true,
},
])
setInterimText('')
} else {
setInterimText(text)
if (!result.isFinal) {
// 발화의 첫 조각이 들린 순간이 곧 시작 시각이다.
if (utteranceStartRef.current === null) {
utteranceStartRef.current = now
}
interimRef.current = 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 = () => {
if (recognitionRef.current !== recognition) return
// 재시작 전에 반드시 비운다. 새 세션은 이전 오디오를 다시 주지 않는다.
flushInterim()
const delay = networkRetryRef.current > 0 ? 1500 : 0
setTimeout(() => {
if (recognitionRef.current !== recognition) return
@@ -151,22 +232,31 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
)
recognitionRef.current = null
}
}, [isSupported])
}, [appendChunk, elapsed, flushInterim, isSupported])
const stopListening = useCallback(() => {
if (recognitionRef.current) {
const stopListening = useCallback((): TranscriptChunk[] => {
const recognition = recognitionRef.current
if (recognition) {
recognitionRef.current = null
recognition.stop()
setIsListening(false)
setInterimText('')
}
}, [])
// 마지막 발화는 확정 전에 세션이 닫히는 경우가 대부분이다.
// 여기서 굳히지 않으면 회의 끝머리가 통째로 사라진다.
flushInterim()
return chunksRef.current
}, [flushInterim])
const resetChunks = useCallback(() => {
chunksRef.current = []
interimRef.current = ''
utteranceStartRef.current = null
setChunks([])
setInterimText('')
setError(null)
setMissedCount(0)
}, [])
return {
@@ -175,6 +265,7 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
chunks,
interimText,
error,
missedCount,
startListening,
stopListening,
resetChunks,
+86
View File
@@ -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 }
}
+91
View File
@@ -1,5 +1,7 @@
'use client'
import { findPreset, type ProviderId } from './providers'
const STORAGE_KEY = 'meeting-minutes:gemini-api-key'
export function getStoredApiKey(): string | null {
@@ -34,3 +36,92 @@ export function maskApiKey(key: string): string {
if (key.length <= 8) return '••••'
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 ?? '',
}
}
+109
View File
@@ -1,3 +1,10 @@
import {
isProviderId,
type ProviderId,
type ProviderSettings,
type TranscriptionSettings,
} from './providers'
/**
* Gemini API 키 해석 우선순위:
* 1) 요청 body로 전달된 키 (브라우저 LocalStorage에서 옴)
@@ -19,3 +26,105 @@ export function resolveGeminiApiKey(
export function isEnvKeyConfigured(): boolean {
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,
}
}
+77 -39
View File
@@ -1,3 +1,4 @@
import { complete, toProviderSettings, type ProviderSettings } from './providers'
import {
buildPrompt,
resolveDepth,
@@ -10,7 +11,17 @@ type LiveSummaryResult =
| { success: false; error: string; rateLimited?: boolean }
interface LiveSummaryOptions {
apiKey: string
/** 프로바이더 설정. 생략하면 apiKey로 Gemini를 호출한다. */
provider?: ProviderSettings
apiKey?: string
/**
* 직전 롤링 요약.
*
* 값이 있으면 증분 모드로 동작하며, 이때 `transcript` 인자는 전사 전체가 아니라
* **직전 요약 이후 새로 추가된 발화**만 담아야 한다. 호출당 토큰이 회의 길이와
* 무관하게 일정해진다.
*/
previousSummary?: string
template?: TemplateId
depth?: SummaryDepth
customPrompt?: string
@@ -21,67 +32,94 @@ export async function generateLiveSummary(
transcript: string,
options: LiveSummaryOptions,
): Promise<LiveSummaryResult> {
const { apiKey, fetchFn = fetch } = options
if (!apiKey || apiKey.trim().length === 0) {
return { success: false, error: 'Gemini API 키가 필요합니다.' }
}
const { fetchFn } = options
const settings = toProviderSettings(options.provider, options.apiKey)
const trimmed = transcript.trim()
if (trimmed.length === 0) {
return { success: false, error: '요약할 텍스트가 비어 있습니다.' }
}
const previous = options.previousSummary?.trim() ?? ''
const incremental = previous.length > 0
const templateId = options.template ?? 'meeting'
const depth = resolveDepth(templateId, options.depth)
const prompt = buildPrompt({
templateId,
depth,
transcript: trimmed,
transcript: incremental
? `[지금까지의 요약]\n${previous}\n\n[새로 추가된 발화]\n${trimmed}`
: trimmed,
live: true,
incremental,
customPrompt: options.customPrompt,
})
const url =
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
const result = await complete(prompt, settings, { fetchFn })
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) {
if (!result.success) {
return {
success: false,
error: 'Gemini 요청 한도 초과. 잠시 후 자동 재시도됩니다.',
rateLimited: true,
}
}
return {
success: false,
error: `Gemini API 호출 실패: ${response.status}`,
error: result.error,
...(result.rateLimited ? { rateLimited: true } : {}),
}
}
const data = await response.json()
const markdown: string =
data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''
if (markdown.trim().length === 0) {
if (result.text.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}` }
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 }
}
+159
View File
@@ -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,
}
}
+31 -39
View File
@@ -1,3 +1,9 @@
import {
complete,
describeModel,
toProviderSettings,
type ProviderSettings,
} from './providers'
import {
buildPrompt,
resolveDepth,
@@ -14,12 +20,14 @@ export interface MinutesInput {
customPrompt?: string
}
type GeminiResult =
type AiMinutesResult =
| { success: true; markdown: string }
| { success: false; error: string }
| { success: false; error: string; rateLimited?: boolean }
interface GeminiOptions {
apiKey: string
interface AiMinutesOptions {
/** 프로바이더 설정. 생략하면 apiKey로 Gemini를 호출한다. */
provider?: ProviderSettings
apiKey?: string
fetchFn?: typeof fetch
}
@@ -49,15 +57,12 @@ ${content}
`
}
export async function generateGeminiMinutes(
export async function generateAiMinutes(
input: MinutesInput,
options: GeminiOptions,
): Promise<GeminiResult> {
const { apiKey, fetchFn = fetch } = options
if (!apiKey || apiKey.trim().length === 0) {
return { success: false, error: 'Gemini API 키가 필요합니다.' }
}
options: AiMinutesOptions,
): Promise<AiMinutesResult> {
const { fetchFn } = options
const settings = toProviderSettings(options.provider, options.apiKey)
const templateId = input.template ?? 'meeting'
const depth = resolveDepth(templateId, input.depth)
@@ -69,31 +74,20 @@ export async function generateGeminiMinutes(
customPrompt: input.customPrompt,
})
const url =
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
const result = await complete(prompt, settings, { fetchFn })
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 (!result.success) {
return {
success: false,
error: `Gemini API 호출 실패: ${response.status} ${response.statusText}`,
error: result.error,
...(result.rateLimited ? { rateLimited: true } : {}),
}
}
const data = await response.json()
const generatedText =
data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''
const generatedText = result.text.trim()
if (generatedText.length === 0) {
return { success: false, error: '요약 결과가 비어 있습니다.' }
}
const dateStr = formatDate(input.date)
const markdown = `# ${input.title}
@@ -106,15 +100,13 @@ ${generatedText}
---
*이 회의록은 Gemini AI를 활용하여 자동 생성되었습니다.*
*이 회의록은 AI(${describeModel(settings)})를 활용하여 자동 생성되었습니다.*
`
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
+265
View File
@@ -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}` }
}
}
+95
View File
@@ -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
}
+256
View File
@@ -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
}
+114
View File
@@ -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]
)
}
+82
View File
@@ -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
}
+195
View File
@@ -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) }
}
+148
View File
@@ -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
View File
@@ -34,20 +34,24 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
depthAdjustable: true,
basePrompt: `당신은 회의록 작성 전문가입니다. 아래 발화 내용을 분석하여 마크다운 회의록을 작성하세요.
포함할 섹션:
## 요약
(3~5줄 핵심 내용)
회의 전체를 3~5줄로. 무엇을 논의했고 무엇이 정해졌는지.
## 주요 논의 사항
1. 순번 매기기
이어지는 본문은 **섹션 제목을 직접 지어서** 구성하세요:
- 실제로 논의된 주제를 스스로 파악해 \`##\` 제목을 붙여 나눕니다.
- "주요 논의 사항", "논의 내용" 같은 일반적인 제목은 쓰지 마세요.
이 회의가 무엇을 다뤘는지 제목만 봐도 알 수 있어야 합니다.
(예: "단계별 개발 계획", "수익화 방안", "기술적 고려사항")
- 주제 수는 내용에 따라 정하되 보통 3~6개입니다.
## 액션 아이템
- [ ] 담당자와 기한이 명확한 항목만
반드시 \`- [ ]\` 체크박스 형식으로 작성하세요.
담당자가 파악되면 \`(담당자)\`를 앞에, 기한이 언급됐으면 뒤에 덧붙입니다.
예: \`- [ ] (김지훈) 경쟁 사이트 리스트업 — 5/17까지\`
담당자가 불분명해도 해야 할 일이 명확하면 포함하세요.
## 결정 사항
- 합의 또는 확정된 것만
한국어로 작성하세요.`,
- 합의되었거나 확정된 것만. 없으면 이 섹션을 생략하세요.`,
},
lecture: {
@@ -60,20 +64,18 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
depthAdjustable: true,
basePrompt: `당신은 학습용 노트를 작성하는 전문가입니다. 강의/세미나 녹취를 구조화된 학습 자료로 정리하세요.
포함할 섹션:
## 주요 주제
(한 줄 요약)
## 핵심 개념
각 개념마다 ### 소제목 + 설명 + 구체 예시
소제목은 강의에서 실제로 다룬 개념 이름을 그대로 쓰세요.
## 기억할 인용·예시
> 중요한 문장 인용 형식으로
## 후속 질문
- 스스로 탐구해볼 만한 질문
한국어로 작성하세요.`,
- 스스로 탐구해볼 만한 질문`,
},
one_on_one: {
@@ -86,12 +88,11 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
depthAdjustable: true,
basePrompt: `당신은 1:1 미팅 정리 전문가입니다. 개인적이고 신뢰 기반의 대화임을 존중하며 정리하세요.
포함할 섹션:
## 이번 세션 요지
(2~3줄)
## 논의한 주제
- 주요 대화 흐름
이어서 대화에서 실제로 다룬 주제마다 **\`##\` 제목을 직접 지어** 나누세요.
"논의한 주제" 같은 일반적인 제목 대신, 무엇에 대한 이야기였는지 드러내는 제목을 쓰세요.
## 고민·블로커
- 공유된 어려움
@@ -100,9 +101,7 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
- 건설적 피드백 위주
## 다음 미팅까지 할 일
- [ ] 합의된 후속 조치
한국어로 작성하세요.`,
- [ ] 합의된 후속 조치. 담당자가 있으면 \`(담당자)\` 표기`,
},
brainstorm: {
@@ -115,12 +114,12 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
depthAdjustable: true,
basePrompt: `당신은 브레인스토밍 세션을 정리하는 전문가입니다. 나온 아이디어를 분류하고 실행 가능성 관점에서 정리하세요.
포함할 섹션:
## 세션 개요
(1~2줄 — 주제/목표)
## 아이디어 (카테고리별)
### [카테고리 이름]
카테고리는 미리 정해진 목록이 아니라, 나온 아이디어를 보고 직접 묶어서 이름 붙이세요.
- 아이디어 요약
## 즉시 시도 가능
@@ -130,9 +129,7 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
- 사유 간단히
## 다음 스텝
- 구체적인 후속 행동
한국어로 작성하세요.`,
- [ ] 구체적인 후속 행동. 담당자가 있으면 \`(담당자)\` 표기`,
},
interview: {
@@ -145,7 +142,6 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
depthAdjustable: true,
basePrompt: `당신은 인터뷰 녹취를 Q&A 형식으로 정리하는 전문가입니다.
포함할 섹션:
## 개요
(인터뷰 대상/주제 1~2줄)
@@ -159,9 +155,7 @@ A: [답변 요약]
> 직접 인용
## 종합 인상
- 전반적 테마 / 놓치지 말 것
한국어로 작성하세요.`,
- 전반적 테마 / 놓치지 말 것`,
},
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 {
templateId: TemplateId
depth: SummaryDepth
/** 증분 모드에서는 [지금까지의 요약] + [새로 추가된 발화]를 담은 블록이 들어온다. */
transcript: string
live?: boolean
/** 직전 요약을 갱신하는 모드. live와 함께 쓴다. */
incremental?: boolean
customPrompt?: string
}
@@ -222,14 +243,21 @@ export function resolveDepth(
}
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
if (templateId === 'custom') {
const custom = customPrompt?.trim()
if (!custom) {
instruction = TEMPLATES.meeting.basePrompt
instruction = `${TEMPLATES.meeting.basePrompt}\n\n${COMMON_RULES}`
} else {
instruction = custom
}
@@ -243,12 +271,21 @@ export function buildPrompt(args: BuildPromptArgs): string {
if (live && template.liveSupported) {
parts.push(LIVE_MODIFIER)
if (incremental) {
parts.push(INCREMENTAL_MODIFIER)
}
}
parts.push(COMMON_RULES)
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 {
+29
View File
@@ -50,3 +50,32 @@ export function mergeAdjacentChunks(
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,
}))
}