Author SHA1 Message Date
csbaeandClaude Opus 5 874cf11a7a fix: Docker 실행 경로에서 화자 분리가 동작하도록 수정
Docker로 띄우면 화자 분리가 전혀 되지 않았다. 세 가지 문제가 겹쳐 있었다.

1. 베이스 이미지가 Alpine(musl)인데 sherpa-onnx 사전 빌드 바이너리는 glibc에
   링크되어 있어 ld-linux-aarch64.so.1 을 찾지 못했다. node:22-slim 으로 교체.
2. Next 파일 트레이싱이 sherpa-onnx.node 만 복사하고 동반 공유 라이브러리
   (libonnxruntime, libsherpa-onnx-c-api 등 약 33MB)를 놓쳤다.
   outputFileTracingIncludes 로 플랫폼 패키지를 통째로 포함시킨다.
3. models/ 가 이미지에도 볼륨에도 없어 컨테이너 안에서는 항상 미설치 상태였다.
   호스트의 models/ 를 읽기 전용으로 마운트한다.

추가로 app 컨테이너에 LLM_* 환경변수를 전달한다. GEMINI_API_KEY만 넘기고
있어서 OpenAI 호환 프로바이더를 환경변수로 설정할 수 없었다.

컨테이너 안에서 실제 오디오로 화자 분리가 도는 것을 확인했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:31:45 +09:00
csbaeandClaude Opus 5 73d5790dcc chore: 로컬 개발용 compose 오버라이드를 gitignore
npm run dev를 호스트에서 돌리려면 DB 포트가 필요한데,
커밋된 docker-compose.yml은 의도적으로 포트를 노출하지 않는다.
docker-compose.override.yml로 127.0.0.1에만 바인딩해 쓰고,
실수로 커밋되지 않도록 무시한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:25:42 +09:00
csbaeandClaude Opus 5 4af0a03de5 feat: 대면 회의 화자 분리 (로컬 diarization)
마이크 한 트랙에 여러 사람이 섞여 들어오는 대면 회의에서 화자를 나눈다.
pyannote-segmentation-3.0 + 3D-Speaker CAM++ 를 ONNX Runtime으로 로컬
Next.js 프로세스에서 실행한다. GPU도 파이썬도 필요 없고 오디오가 외부로
나가지 않는다.

실측 (Apple Silicon, 2스레드, 4인 한국어 아닌 샘플 57초):
- 처리 1.5초 → RTF 0.03, 실시간의 33배
- 자동 추정은 화자 5명(과다 계수), 참석자 수를 주면 4명(정확)
  → 참석자 수 입력을 1급 기능으로 노출

구성:
- lib/diarization.ts — sherpa-onnx-node 래퍼, 모델 캐시, 참석자 수 힌트
- lib/audio-session.ts — 녹음 중 PCM 조각을 세션별로 누적 (메모리)
- api/diarize/chunk — 4초짜리 Int16 PCM 조각 업로드
- api/diarize — 누적 오디오 전체 분석, GET은 모델 설치 여부 확인
- public/pcm-worklet.js — 16kHz Int16 PCM 추출 AudioWorklet
- hooks/useDiarization.ts — 수집·분석 상태 관리
- transcript-formatter: assignSpeakers() — 시간 겹침으로 화자 배정.
  겹치는 구간이 없으면 라벨을 비운다 (틀린 라벨보다 없는 편이 낫다)
- LiveRecorder: 사용 안 함 / 원격 회의 / 대면 회의 3모드 + 참석자 수
- scripts/setup-diarization.mjs — 모델 34MB 내려받기 (npm run setup:diarization)

설계 변경: 롤링 재-diarization 대신 녹음 종료 시 1회 전체 분석.
구간을 잘라 반복하면 실행마다 화자 번호가 달라져 이어 붙일 수 없고,
누적분 재분석은 비용이 회의 길이에 비례해 커진다. 대신 실시간 화자 표시는 없다.

모델은 라이선스와 용량 때문에 저장소에 넣지 않고 gitignore 한다.

테스트 176 → 184.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 10:47:10 +09:00
csbaeandClaude Opus 5 a8cfab8a4a fix: 두 트랙을 확보했을 때만 화자를 라벨링
대면 회의처럼 시스템 오디오가 없는 상황에서 화자 분리를 켜면,
마이크에 섞여 들어온 상대 발언까지 "나"로 기록되는 문제가 있었다.
라벨이 틀리는 것은 라벨이 없는 것보다 나쁘다.

capture.mode가 'dual-stream'일 때만 speaker를 붙이고, 마이크 단독으로
폴백하면 라벨 없이 기존 형식으로 기록한다.

설정 UI에도 대면 회의에서는 동작하지 않는다는 점을 명시했다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:29:58 +09:00
csbaeandClaude Opus 5 d2cb5b0009 feat: dual-stream 캡처로 원격 회의 화자 분리
내 마이크와 시스템(탭) 오디오를 별도 트랙으로 잡고, 트랙마다 인식기를
붙인다. 트랙이 곧 화자이므로 추론도 모델 다운로드도 없이 화자가 갈린다.

Chrome이 SpeechRecognition.start(audioTrack)를 지원하는 덕분에 가능하다.
트랙을 명시적으로 넘기므로 기본 마이크를 두고 경합할 일도 없다.

- speakers.ts 신설 — 화자 식별자와 표시 이름 해석
- TranscriptChunk.speaker 추가, formatTranscriptChunks가 "화자: 발화"로 출력
- mergeSpeakerChunks — 트랙별 청크를 하나의 시간축으로 병합.
  여러 인식기가 같은 timeOrigin을 공유해야 순서가 맞는다
- mergeAdjacentChunks가 화자 경계를 넘어 합치지 않도록 수정
- useSpeechRecognition: audioTrack / speaker / timeOrigin / lang 옵션.
  인자 없이 호출하면 기존 동작 그대로다
- useDualStreamCapture 신설 — getUserMedia + getDisplayMedia.
  video는 즉시 끊고 오디오만 쓴다. 시스템 오디오 확보에 실패하면
  마이크 단독으로 폴백하고 화자 분리가 꺼졌음을 안내한다
- LiveRecorder: 화자 분리 토글, 화자 이름 입력, 전사 목록 화자 색상 구분

원격 다자 회의와 대면 회의는 원격 트랙에 diarization이 필요하며 후속 작업이다.

테스트 162 → 176.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 18:27:00 +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
49 changed files with 3462 additions and 293 deletions

No files matched your search

+22 -6
View File
@@ -25,13 +25,29 @@ POSTGRES_PASSWORD=
DATABASE_URL= DATABASE_URL=
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# 선택 — Gemini API 키 # 선택 — AI 프로바이더
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Gemini AI 요약 기능을 쓰려면 키가 필요합니다. # AI 요약 기능을 쓰려면 프로바이더 설정이 필요합니다. 두 가지 방법 중 선택:
# 두 가지 방법 중 선택: # (A) 웹 UI 방식 — 앱 기동 후 /settings에서 선택·입력 (브라우저 LocalStorage, 추천)
# (A) 웹 UI 방식 — 앱 기동 후 /settings에서 입력 (브라우저 LocalStorage 저장, 추천) # (B) 환경변수 방식 — 아래 값을 채우면 모든 사용자가 공통으로 사용
# (B) 환경변수 방식 — 아래 값 채우면 모든 사용자가 공통으로 사용
# #
# 설정이 없어도 "단순 변환" 모드는 정상 동작합니다.
# --- (기본) Google Gemini 직접 호출 ---
# 무료 발급: https://aistudio.google.com/apikey # 무료 발급: https://aistudio.google.com/apikey
# 키 없이도 "단순 변환" 모드는 정상 동작합니다.
GEMINI_API_KEY= GEMINI_API_KEY=
# --- (선택) OpenAI 호환 엔드포인트 ---
# OrcaRouter · OpenAI · Ollama · LM Studio · vLLM 등 OpenAI Chat Completions
# 형식을 따르는 엔드포인트라면 무엇이든 연결됩니다.
#
# LLM_PROVIDER=openai-compatible 로 두었을 때만 아래 값들이 사용됩니다.
# OpenAI : LLM_BASE_URL=https://api.openai.com/v1 LLM_MODEL=gpt-4o-mini
# OrcaRouter : LLM_BASE_URL=https://api.orcarouter.ai/v1 LLM_MODEL=google/gemini-3.5-flash-lite
# Ollama : LLM_BASE_URL=http://localhost:11434/v1 LLM_MODEL=llama3.1 (키 불필요)
#
# ⚠️ 중계 서비스를 쓰면 회의 전문이 그 회사 서버를 거칩니다. SECURITY.md 참고.
# LLM_PROVIDER=
# LLM_BASE_URL=
# LLM_MODEL=
# LLM_API_KEY=
+6
View File
@@ -49,3 +49,9 @@ next-env.d.ts
.env .env
!.env.example !.env.example
# 화자 분리 모델 (npm run setup:diarization 으로 내려받음)
models/
# 로컬 개발용 compose 오버라이드
docker-compose.override.yml
+8 -3
View File
@@ -1,4 +1,6 @@
FROM node:22-alpine AS base # sherpa-onnx 사전 빌드 바이너리가 glibc에 링크되어 있어 Alpine(musl)에서는
# ld-linux-aarch64.so.1 을 찾지 못한다. glibc 계열인 slim 을 쓴다.
FROM node:22-slim AS base
FROM base AS deps FROM base AS deps
WORKDIR /app WORKDIR /app
@@ -15,8 +17,8 @@ RUN npm run build
FROM base AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs RUN groupadd --system --gid 1001 nodejs \
RUN adduser --system --uid 1001 nextjs && useradd --system --uid 1001 --gid nodejs nextjs
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/static ./.next/static
@@ -24,6 +26,9 @@ COPY --from=builder /app/prisma ./prisma
RUN mkdir -p uploads && chown nextjs:nodejs uploads RUN mkdir -p uploads && chown nextjs:nodejs uploads
# sherpa-onnx의 .so는 표준 검색 경로에 없으므로 명시해준다.
ENV LD_LIBRARY_PATH=/app/node_modules/sherpa-onnx-linux-arm64:/app/node_modules/sherpa-onnx-linux-x64
USER nextjs USER nextjs
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
+98 -23
View File
@@ -24,11 +24,12 @@ docker compose up -d
- [✨ 주요 기능](#-주요-기능) - [✨ 주요 기능](#-주요-기능)
- [🎤 실시간 녹음 & 전사](#-실시간-녹음--전사) - [🎤 실시간 녹음 & 전사](#-실시간-녹음--전사)
- [🗣️ 화자 분리](#️-화자-분리)
- [🤖 AI 회의록 (6 템플릿 × 3 강도)](#-ai-회의록-6-템플릿--3-강도) - [🤖 AI 회의록 (6 템플릿 × 3 강도)](#-ai-회의록-6-템플릿--3-강도)
- [⚡ 실시간 롤링 요약](#-실시간-롤링-요약) - [⚡ 실시간 롤링 요약](#-실시간-롤링-요약)
- [💾 회의록 저장/조회/검색](#-회의록-저장조회검색) - [💾 회의록 저장/조회/검색](#-회의록-저장조회검색)
- [✅ 액션 아이템 자동 추출](#-액션-아이템-자동-추출) - [✅ 액션 아이템 자동 추출](#-액션-아이템-자동-추출)
- [🔑 웹에서 API 키 설정](#-웹에서-api-키-설정) - [🔑 AI 프로바이더 & API 키 설정](#-ai-프로바이더--api-키-설정)
- [📋 Google Docs 호환 복사](#-google-docs-호환-복사) - [📋 Google Docs 호환 복사](#-google-docs-호환-복사)
- [🚀 시작하기](#-시작하기) - [🚀 시작하기](#-시작하기)
- [Docker Compose (권장)](#docker-compose-권장) - [Docker Compose (권장)](#docker-compose-권장)
@@ -69,14 +70,25 @@ docker compose up -d
| 템플릿 | 생성되는 구조 | 기본 강도 | 라이브 요약 | | 템플릿 | 생성되는 구조 | 기본 강도 | 라이브 요약 |
|---|---|---|---| |---|---|---|---|
| 🗂️ 회의록 | 요약 / 논의 / 액션 / 결정 | 표준 | ✅ | | 🗂️ 회의록 | 요약 / **내용 기반 주제 섹션** / 액션 / 결정 | 표준 | ✅ |
| 🎓 강의·세미나 노트 | 핵심 개념 / 예시 / 인용 / 후속 질문 | 상세 | ✅ | | 🎓 강의·세미나 노트 | 핵심 개념 / 예시 / 인용 / 후속 질문 | 상세 | ✅ |
| 🤝 1:1 미팅 | 주제 / 고민 / 피드백 / 다음 액션 | 표준 | ✅ | | 🤝 1:1 미팅 | 요지 / **내용 기반 주제 섹션** / 고민 / 피드백 / 다음 액션 | 표준 | ✅ |
| 💡 브레인스토밍 | 카테고리별 아이디어 / 즉시 시도 / 보류 | 상세 | ✅ | | 💡 브레인스토밍 | 카테고리별 아이디어 / 즉시 시도 / 보류 | 상세 | ✅ |
| 🎤 인터뷰 | Q&A 포맷 / 인상적 발언 / 종합 | 표준 | ❌ | | 🎤 인터뷰 | Q&A 포맷 / 인상적 발언 / 종합 | 표준 | ❌ |
| 📝 원문 정리 | 요약 없이 문단화·오탈자 정리만 | 상세 고정 | ❌ | | 📝 원문 정리 | 요약 없이 문단화·오탈자 정리만 | 상세 고정 | ❌ |
| ⚙️ 커스텀 | 자유 프롬프트 입력 | - | ✅ | | ⚙️ 커스텀 | 자유 프롬프트 입력 | - | ✅ |
**내용 기반 주제 섹션**
회의록·1:1 템플릿은 `## 주요 논의 사항` 같은 고정 제목을 쓰지 않습니다. 그 회의가 실제로 무엇을
다뤘는지에 따라 AI가 섹션 제목을 직접 짓습니다 — 예를 들어 `## 단계별 개발 계획`,
`## 수익화 방안`, `## 기술적 고려사항` 처럼. 제목만 훑어도 회의 내용이 파악됩니다.
**액션 아이템 담당자**
발화에서 담당자가 파악되면 `- [ ] (김지훈) 경쟁 사이트 리스트업 — 5/17까지` 형태로 이름과 기한이 붙습니다.
담당자가 정해지지 않은 항목도 버리지 않고 그대로 수집합니다.
**강도 3단계** **강도 3단계**
- **간결** — 각 섹션 3줄 이내 - **간결** — 각 섹션 3줄 이내
- **표준** — 맥락이 이해될 정도 - **표준** — 맥락이 이해될 정도
@@ -90,6 +102,42 @@ docker compose up -d
--- ---
### 🗣️ 화자 분리
누가 무슨 말을 했는지 구분해 기록합니다. 회의 환경에 따라 두 가지 방식을 씁니다.
| 모드 | 방식 | 정확도 | 준비물 |
|---|---|---|---|
| **원격 회의** | 내 마이크와 시스템(탭) 오디오를 **별도 트랙**으로 받아 트랙별로 전사 | **100%** — 추론 없음 | 없음 |
| **대면 회의** | 마이크 한 트랙을 **내 PC에서** 분석해 화자를 나눔 | DER 12~15% | 모델 1회 설치 |
**원격 회의**는 트랙이 곧 화자라 틀릴 수가 없습니다. 시작할 때 화면 공유 대화상자에서
**"탭 오디오 공유"를 반드시 켜야** 합니다. 켜지 않으면 마이크 단독 녹음으로 넘어가고
화자 라벨이 붙지 않습니다 (틀린 라벨을 붙이지 않습니다).
**대면 회의**는 `pyannote-segmentation-3.0` + `3D-Speaker CAM++` ONNX 모델을 로컬
Next.js 프로세스에서 실행합니다. **오디오가 외부로 나가지 않습니다.** GPU도 파이썬
런타임도 필요 없고, 노트북 CPU에서 실시간의 수십 배 속도로 처리됩니다.
```bash
# 최초 1회 — 모델 약 34MB 내려받기
npm run setup:diarization
```
> Docker로 실행하실 때도 **호스트에서** 위 명령을 먼저 실행하세요. `models/` 디렉터리가
> 컨테이너에 읽기 전용으로 마운트됩니다. 받지 않으면 대면 모드만 비활성화되고 나머지는
> 정상 동작합니다.
> 💡 **참석자 수를 정확히 입력하세요.** 자동 추정은 화자 수를 잘못 세는 것이 주 실패
> 모드입니다. 실측에서 4인 오디오가 자동 모드로는 5명, 참석자 수를 알려주면 4명으로
> 정확히 나왔습니다.
대면 모드는 녹음이 끝난 뒤 **전체 오디오에 한 번** 분석을 돌립니다. 구간을 잘라
반복 실행하면 실행마다 화자 번호가 달라져 이어 붙일 수 없기 때문입니다. 따라서
녹음 중 실시간 화자 표시는 없고, 종료 시 원문 전체에 화자가 붙습니다.
---
### ⚡ 실시간 롤링 요약 ### ⚡ 실시간 롤링 요약
녹음 중 **30초마다** Gemini가 그동안의 발화를 분석해 중간 회의록을 자동 갱신합니다. 녹음 중 **30초마다** Gemini가 그동안의 발화를 분석해 중간 회의록을 자동 갱신합니다.
@@ -101,7 +149,7 @@ docker compose up -d
- 429 쿼터 초과 시 60초 자동 쿨다운, UI에 카운트다운 노출 - 429 쿼터 초과 시 60초 자동 쿨다운, UI에 카운트다운 노출
- 일반 실패 시 지수 백오프 (15s → 30s → 최대 120s) - 일반 실패 시 지수 백오프 (15s → 30s → 최대 120s)
**비용 가드 (Gemini 2.5 Flash Lite 무료 등급 기준)** **비용 가드 (Gemini Flash Lite 무료 등급 기준)**
- 15 RPM / 1000 RPD / 250K TPM - 15 RPM / 1000 RPD / 250K TPM
- 30초 폴링 + 증분 게이트 → 1시간 회의 ≤ 60회 호출, 발화량 적으면 훨씬 적음 - 30초 폴링 + 증분 게이트 → 1시간 회의 ≤ 60회 호출, 발화량 적으면 훨씬 적음
- 개인 사용 시 일일 한도 도달 거의 불가 - 개인 사용 시 일일 한도 도달 거의 불가
@@ -154,9 +202,22 @@ docker compose up -d
--- ---
### 🔑 웹에서 API 키 설정 ### 🔑 AI 프로바이더 & API 키 설정
`.env`를 만지지 않고 `/settings` 페이지에서 Gemini 키를 입력·저장·검증할 수 있습니다. `.env`를 만지지 않고 `/settings` 페이지에서 프로바이더·모델·키를 선택하고 검증할 수 있습니다.
**선택 가능한 프로바이더**
| 프리셋 | 엔드포인트 | 비고 |
|--------|-----------|------|
| **Google Gemini** (기본) | Google 직접 호출 | 무료 티어가 있어 가장 간단 |
| **OpenAI** | `https://api.openai.com/v1` | Chat Completions |
| **OrcaRouter** | `https://api.orcarouter.ai/v1` | 하나의 키로 여러 제공사 모델 |
| **로컬 모델** | `http://localhost:11434/v1` | Ollama · LM Studio · vLLM — 회의 내용이 외부로 나가지 않음 |
| **직접 입력** | 사용자 지정 | OpenAI 호환이면 무엇이든 |
Gemini 외 프리셋은 모두 동일한 **OpenAI Chat Completions** 어댑터를 사용합니다
(`src/lib/providers/openai-compatible.ts`). base URL과 모델 ID만 바꾸면 새 서비스가 붙습니다.
**저장 위치** **저장 위치**
- 브라우저 LocalStorage (서버 DB에 저장되지 않음) - 브라우저 LocalStorage (서버 DB에 저장되지 않음)
@@ -164,9 +225,9 @@ docker compose up -d
**우선순위** **우선순위**
``` ```
1. 요청 body의 apiKey (브라우저 LocalStorage) 1. 요청 body의 provider/apiKey/baseUrl/model (브라우저 LocalStorage)
↓ 없으면 ↓ 없으면
2. process.env.GEMINI_API_KEY (서버 환경변수 fallback) 2. 서버 환경변수 (GEMINI_API_KEY 또는 LLM_PROVIDER / LLM_BASE_URL / LLM_MODEL / LLM_API_KEY)
↓ 없으면 ↓ 없으면
3. summarize → 단순 변환 폴백 (warning 표시) 3. summarize → 단순 변환 폴백 (warning 표시)
summarize-live → 503 + 안내 summarize-live → 503 + 안내
@@ -175,12 +236,14 @@ docker compose up -d
**기능** **기능**
- 마스킹된 현재 키 표시 (`AIza••••XYZ12`) - 마스킹된 현재 키 표시 (`AIza••••XYZ12`)
- 보이기/숨기기 토글 - 보이기/숨기기 토글
- **🧪 테스트 호출** — 가벼운 Gemini 응답으로 즉시 키 검증 - **🧪 테스트 호출** — 가벼운 응답으로 즉시 설정 검증 (사용된 모델명 표시)
- 현재 키 소스 배지 (`브라우저` / `환경변수` / `없음`) - 현재 소스 배지 (`브라우저` / `환경변수` / `없음`)
- 기존에 Gemini 키만 저장해둔 사용자는 **재입력 없이 그대로 동작** (자동 승계)
**보안** **보안**
- HTTPS 권장 (LocalStorage는 동일 출처 정책 의존) - HTTPS 권장 (LocalStorage는 동일 출처 정책 의존)
- GET 응답에 절대 풀 키 노출 안 함 - GET 응답에 절대 풀 키 노출 안 함
- base URL은 `http`/`https`만 허용 — 자세한 내용은 [SECURITY.md](SECURITY.md)
--- ---
@@ -231,12 +294,13 @@ DATABASE_URL=postgresql://meetinguser:<위와_같은_비번>@localhost:5432/meet
> ⚠️ `POSTGRES_PASSWORD`를 비워두면 docker compose가 명시적 에러로 실패합니다. 보안을 위한 의도된 동작. > ⚠️ `POSTGRES_PASSWORD`를 비워두면 docker compose가 명시적 에러로 실패합니다. 보안을 위한 의도된 동작.
> 💡 **Gemini API 키는 두 가지 방법 중 선택** > 💡 **AI 프로바이더 설정은 두 가지 방법 중 선택**
> >
> - **(A) 웹 UI** — 앱 기동 후 `/settings`에서 입력 (브라우저 LocalStorage, 추천) > - **(A) 웹 UI** — 앱 기동 후 `/settings`에서 프로바이더 선택 + 키 입력 (브라우저 LocalStorage, 추천)
> - **(B) `.env` 환경변수** — `GEMINI_API_KEY=AIza...` 작성 > - **(B) `.env` 환경변수** — `GEMINI_API_KEY=AIza...` 또는 `LLM_PROVIDER` / `LLM_BASE_URL` / `LLM_MODEL` / `LLM_API_KEY`
> >
> [Google AI Studio](https://aistudio.google.com/apikey)에서 무료로 발급. 키 없이도 "단순 변환" 모드는 동작. > 기본값인 Gemini 키는 [Google AI Studio](https://aistudio.google.com/apikey)에서 무료로 발급.
> 설정이 없어도 "단순 변환" 모드는 동작.
#### 3. DB 마이그레이션 (최초 1회) #### 3. DB 마이그레이션 (최초 1회)
@@ -304,11 +368,12 @@ npm run test:coverage # 커버리지 리포트
|------|------| |------|------|
| 프레임워크 | Next.js 16 (App Router) + React 19 + TypeScript | | 프레임워크 | Next.js 16 (App Router) + React 19 + TypeScript |
| STT (실시간) | Web Speech API — Chrome 내장, 무료 | | STT (실시간) | Web Speech API — Chrome 내장, 무료 |
| AI 요약 | Gemini 2.5 Flash Lite (무료 등급 15 RPM / 1000 RPD) | | AI 요약 | Gemini 3.5 Flash Lite (기본) · OpenAI 호환 엔드포인트 선택 가능 |
| DB | PostgreSQL 16 + Prisma 7 (driver adapter `@prisma/adapter-pg`) | | DB | PostgreSQL 16 + Prisma 7 (driver adapter `@prisma/adapter-pg`) |
| Markdown | `marked` + `isomorphic-dompurify` | | Markdown | `marked` + `isomorphic-dompurify` |
| 스타일 | Tailwind CSS v4 | | 스타일 | Tailwind CSS v4 |
| 테스트 | Vitest 4 (jsdom, 102 tests) | | 화자 분리 | pyannote-segmentation-3.0 + CAM++ (ONNX Runtime, CPU) |
| 테스트 | Vitest 4 (jsdom, 184 tests) |
| 배포 | Docker Compose + standalone Next.js 빌드 | | 배포 | Docker Compose + standalone Next.js 빌드 |
--- ---
@@ -339,14 +404,20 @@ src/
│ ├── page.tsx # 메인 (녹음/요약) │ ├── page.tsx # 메인 (녹음/요약)
│ └── layout.tsx │ └── layout.tsx
├── lib/ ├── lib/
│ ├── providers/ # AI 프로바이더 어댑터
│ │ ├── index.ts # complete() 디스패치
│ │ ├── types.ts # ProviderSettings / CompletionResult
│ │ ├── presets.ts # 설정 UI용 프리셋 목록
│ │ ├── gemini.ts # Google Generative Language API
│ │ └── openai-compatible.ts # OpenAI Chat Completions 호환
│ ├── db.ts # Prisma 싱글톤 (pg adapter) │ ├── db.ts # Prisma 싱글톤 (pg adapter)
│ ├── markdown.ts # marked + DOMPurify + Docs용 복사 │ ├── markdown.ts # marked + DOMPurify + Docs용 복사
│ ├── action-items.ts # - [ ] 휴리스틱 파서 │ ├── action-items.ts # - [ ] 휴리스틱 파서
│ ├── api-keys.ts # 서버: 요청 키 → env fallback │ ├── api-keys.ts # 서버: 요청 설정 → env fallback
│ ├── api-key-storage.ts # 클라: LocalStorage + 마스킹 │ ├── api-key-storage.ts # 클라: LocalStorage + 마스킹 + 프로바이더 설정
│ ├── templates.ts # 템플릿 레지스트리 + 프롬프트 빌더 │ ├── templates.ts # 템플릿 레지스트리 + 프롬프트 빌더
│ ├── minutes-generator.ts # 최종 요약 생성 │ ├── minutes-generator.ts # 최종 요약 생성 (프로바이더 무관)
│ ├── live-summary.ts # 롤링 요약 생성 │ ├── live-summary.ts # 롤링 요약 생성 (프로바이더 무관)
│ ├── transcript-formatter.ts │ ├── transcript-formatter.ts
│ ├── audio-validation.ts │ ├── audio-validation.ts
│ ├── upload-handler.ts │ ├── upload-handler.ts
@@ -409,7 +480,7 @@ prisma/
- 같은 머신을 다른 사람과 공유하지 않는 것을 가정 - 같은 머신을 다른 사람과 공유하지 않는 것을 가정
- 회의 transcript / 회의록은 평문으로 PostgreSQL에 저장됨 (디스크 암호화는 호스트 OS에 위임) - 회의 transcript / 회의록은 평문으로 PostgreSQL에 저장됨 (디스크 암호화는 호스트 OS에 위임)
### Gemini API 키 ### API 키
- LocalStorage 저장 (브라우저 동일 출처 정책으로 보호) - LocalStorage 저장 (브라우저 동일 출처 정책으로 보호)
- 서버 DB에 저장되지 않음 - 서버 DB에 저장되지 않음
- 요청 시점에만 body로 전송, 일회성 사용 - 요청 시점에만 body로 전송, 일회성 사용
@@ -417,10 +488,14 @@ prisma/
### 외부 데이터 송출 ### 외부 데이터 송출
- **Web Speech API** — Chrome이 마이크 오디오를 Google 서버로 전송하여 전사 (Chrome 자체 동작, 우리 서버 경유 X) - **Web Speech API** — Chrome이 마이크 오디오를 Google 서버로 전송하여 전사 (Chrome 자체 동작, 우리 서버 경유 X)
- **Gemini API** — 사용자가 명시적으로 활성화한 경우에만 transcript를 Google 서버로 전송 - **AI 요약 API** — 사용자가 명시적으로 활성화한 경우에만 transcript를 선택한 프로바이더로 전송
- Gemini / OpenAI 직접 호출 → 해당 회사 서버 1곳
- OrcaRouter 등 중계 라우터 → **라우터 운영사 + 실제 모델 제공사** 양쪽
- 로컬 모델 (Ollama / LM Studio / vLLM) → **외부 전송 없음**
- **그 외** — 외부 호출 없음 (텔레메트리 / 분석 도구 미설치) - **그 외** — 외부 호출 없음 (텔레메트리 / 분석 도구 미설치)
> ⚠️ **회사 회의 등 민감 정보가 외부 클라우드(Google)로 송출되는 점에 유의.** 사내 컴플라이언스 정책 확인 후 사용 권장. > ⚠️ **회사 회의 등 민감 정보가 외부 클라우드로 송출되는 점에 유의.** 사내 컴플라이언스 정책 확인 후 사용 권장.
> 외부 전송이 곤란하다면 `/settings`에서 **로컬 모델**을 선택하세요.
### 권장 배포 방식 ### 권장 배포 방식
+45 -4
View File
@@ -48,10 +48,11 @@
| 데이터 | 저장 위치 | 외부 송출 | | 데이터 | 저장 위치 | 외부 송출 |
|---|---|---| |---|---|---|
| 회의 transcript / 회의록 | PostgreSQL (`meetings.markdownMinutes`, `rawTranscript`) | Gemini AI 요약 활성화 시 transcript가 Google로 전송 | | 회의 transcript / 회의록 | PostgreSQL (`meetings.markdownMinutes`, `rawTranscript`) | AI 요약 활성화 시 transcript가 **선택한 프로바이더**로 전송 |
| 액션 아이템 | PostgreSQL (`action_items`) | 외부 송출 없음 | | 액션 아이템 | PostgreSQL (`action_items`) | 외부 송출 없음 |
| Gemini API 키 | 브라우저 LocalStorage 또는 `.env` | 요청 시 Google에만 전송 | | API 키 | 브라우저 LocalStorage 또는 `.env` | 요청 시 선택한 프로바이더에만 전송 |
| 음성 데이터 | 메모리 (실시간), `/app/uploads` (파일 업로드) | **Chrome Web Speech API → Google 서버** ⚠️ | | 음성 데이터 | 메모리 (실시간), `/app/uploads` (파일 업로드) | **Chrome Web Speech API → Google 서버** ⚠️ |
| 화자 분리용 PCM | 서버 프로세스 메모리 (녹음 중에만) | **외부 송출 없음** — 로컬에서 분석하고 분석 후 즉시 폐기 |
### ⚠️ 주의: Web Speech API의 음성 외부 전송 ### ⚠️ 주의: Web Speech API의 음성 외부 전송
@@ -63,6 +64,45 @@ Chrome의 `SpeechRecognition` API는 **음성 데이터를 Google 서버로 전
이 경우 **파일 업로드 탭**도 같은 한계가 있으므로(현재 업로드 후 전사는 미구현, Phase 4에서 자체 STT 검토 예정), 이 도구의 사용을 보류하는 것을 권장합니다. 이 경우 **파일 업로드 탭**도 같은 한계가 있으므로(현재 업로드 후 전사는 미구현, Phase 4에서 자체 STT 검토 예정), 이 도구의 사용을 보류하는 것을 권장합니다.
### ⚠️ 주의: AI 프로바이더 선택에 따른 전송 경로
`/settings`에서 고른 프로바이더에 따라 **회의 전문이 지나가는 회사가 달라집니다.**
| 선택 | transcript를 보게 되는 주체 |
|---|---|
| Google Gemini (기본) | Google |
| OpenAI | OpenAI |
| OrcaRouter 등 중계 라우터 | **라우터 운영사 + 라우터가 고른 실제 모델 제공사** (2단계) |
| 로컬 모델 (Ollama / LM Studio / vLLM) | **없음** — 요청이 내 머신 밖으로 나가지 않음 |
- 중계 라우터는 요청을 대신 전달하는 구조상 **평문 프롬프트를 볼 수 있는 주체가 한 곳 늘어납니다.** 로깅·보관 정책은 각 서비스 약관을 직접 확인하세요.
- 사내 컴플라이언스가 외부 전송을 제한한다면 **로컬 모델** 프리셋을 사용하세요.
### 화자 분리 오디오는 로컬에만 머뭅니다
대면 회의 화자 분리는 `pyannote-segmentation-3.0` + `CAM++` ONNX 모델을 로컬
Next.js 프로세스에서 실행합니다. 외부 API를 호출하지 않으므로 **오디오가 네트워크로
나가지 않습니다.**
- 녹음 중 PCM 조각이 서버 프로세스 **메모리**에 쌓입니다 (디스크에 쓰지 않음)
- 분석이 끝나면 즉시 폐기하며, 6시간 지난 세션은 자동 정리됩니다
- 세션 최대 길이는 3시간으로 제한됩니다
- 단일 사용자 로컬 실행 전제입니다. 여러 사용자가 붙는 환경이라면 세션 ID를
추측해 다른 사람의 오디오에 접근할 수 있으므로, 노출 배포 시 인증이 선행되어야 합니다
전사 텍스트는 여전히 Web Speech API를 거치므로 Google로 갑니다. 화자 분리만
로컬이라는 점에 유의하세요.
### 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 +131,10 @@ Chrome의 `SpeechRecognition` API는 **음성 데이터를 Google 서버로 전
### API 키 보호 ### API 키 보호
- `/api/settings/status`: 키 존재 여부만 응답 (값 노출 X) - `/api/settings/status`: 키 존재 여부만 응답 (값 노출 X)
- `/api/settings/test`: 401/403/429 분류, detail 200자로 제한 - `/api/settings/test`: 401/403/429 분류, 업스트림 응답 본문을 그대로 돌려주지 않음
- `getStoredApiKey` GET 요청에 절대 포함 안 함 (body 전송만) - 키는 GET 요청에 절대 포함 안 함 (body 전송만)
- `maskApiKey()`: UI에 표시 시 `AIza••••XYZ12` 형태로 마스킹 - `maskApiKey()`: UI에 표시 시 `AIza••••XYZ12` 형태로 마스킹
- Gemini는 `x-goog-api-key` 헤더, OpenAI 호환은 `Authorization: Bearer` 헤더 — **키를 URL에 넣지 않음** (로그/리퍼러 유출 방지)
--- ---
+9
View File
@@ -43,8 +43,17 @@ services:
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USER:?required}:${POSTGRES_PASSWORD:?required}@db:5432/${POSTGRES_DB:?required} DATABASE_URL: postgresql://${POSTGRES_USER:?required}:${POSTGRES_PASSWORD:?required}@db:5432/${POSTGRES_DB:?required}
GEMINI_API_KEY: ${GEMINI_API_KEY:-} GEMINI_API_KEY: ${GEMINI_API_KEY:-}
# OpenAI 호환 프로바이더 (선택) — 없으면 브라우저에 저장된 설정을 쓴다
LLM_PROVIDER: ${LLM_PROVIDER:-}
LLM_BASE_URL: ${LLM_BASE_URL:-}
LLM_MODEL: ${LLM_MODEL:-}
LLM_API_KEY: ${LLM_API_KEY:-}
DIARIZATION_MODEL_DIR: /app/models/diarization
volumes: volumes:
- uploads:/app/uploads - uploads:/app/uploads
# 화자 분리 모델 — 호스트에서 npm run setup:diarization 으로 받은 것을 읽기 전용으로 마운트.
# 없으면 빈 디렉터리가 되고 대면 화자 분리만 비활성화된다 (앱은 정상 동작).
- ./models:/app/models:ro
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
+7
View File
@@ -12,6 +12,13 @@ const securityHeaders = [
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
// sherpa-onnx-node는 네이티브 애드온이라 번들링하면 안 된다.
serverExternalPackages: ["sherpa-onnx-node"],
// 트레이싱은 .node 파일만 잡고 동반 공유 라이브러리(.so/.dylib)를 놓친다.
// 플랫폼 패키지를 통째로 포함시켜야 standalone 빌드에서 로드된다.
outputFileTracingIncludes: {
"/api/diarize": ["./node_modules/sherpa-onnx-*/**"],
},
async headers() { async headers() {
return [ return [
{ {
+94 -1
View File
@@ -16,7 +16,8 @@
"pg": "^8.13.1", "pg": "^8.13.1",
"prisma": "^7.7.0", "prisma": "^7.7.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"sherpa-onnx-node": "^1.13.7"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -8422,6 +8423,98 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/sherpa-onnx-darwin-arm64": {
"version": "1.13.7",
"resolved": "https://registry.npmjs.org/sherpa-onnx-darwin-arm64/-/sherpa-onnx-darwin-arm64-1.13.7.tgz",
"integrity": "sha512-5NCE50hAvr3n2pdett0SgfPBJXaFZE0bqHwbHyiq+IKZ8Ids0l4M0VrG+ImGYIafCwie+oC3uAJ+pKj9xg/k+w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/sherpa-onnx-darwin-x64": {
"version": "1.13.7",
"resolved": "https://registry.npmjs.org/sherpa-onnx-darwin-x64/-/sherpa-onnx-darwin-x64-1.13.7.tgz",
"integrity": "sha512-N3o+T+wn9WaQmsKV5DD8bTHdo+WN2+sXwmZcGJZiDjtOMR2zFz7uVCZnYCmEAMgvChC+oHcF5RvEEKcRCAu6Pw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/sherpa-onnx-linux-arm64": {
"version": "1.13.7",
"resolved": "https://registry.npmjs.org/sherpa-onnx-linux-arm64/-/sherpa-onnx-linux-arm64-1.13.7.tgz",
"integrity": "sha512-TFCVpXyTh69buhOtTS8KIfkRXOVKY4Y1qjAktSItrKS4A0chnnrlXO5bKWoNAPeI6fMxTF/uvMYbYgcvjEMfNg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/sherpa-onnx-linux-x64": {
"version": "1.13.7",
"resolved": "https://registry.npmjs.org/sherpa-onnx-linux-x64/-/sherpa-onnx-linux-x64-1.13.7.tgz",
"integrity": "sha512-npmxn5WwmAmlthgBhmbZ33t3i2j4mJwQt46dMEb3j7d41y1/uJrjrVAfa/DkvV+vn49ZWfcQ2UEWDipaZBVhuw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/sherpa-onnx-node": {
"version": "1.13.7",
"resolved": "https://registry.npmjs.org/sherpa-onnx-node/-/sherpa-onnx-node-1.13.7.tgz",
"integrity": "sha512-0XGV7arGngBCnol0m8OLyqlnaUm19Q1KmetVj1DDBdymXa1upmAHZDwNdN47gjsEhqE5hXUEyc1vRQoXrNhNVg==",
"license": "Apache-2.0",
"optionalDependencies": {
"sherpa-onnx-darwin-arm64": "^1.13.7",
"sherpa-onnx-darwin-x64": "^1.13.7",
"sherpa-onnx-linux-arm64": "^1.13.7",
"sherpa-onnx-linux-x64": "^1.13.7",
"sherpa-onnx-win-ia32": "^1.13.7",
"sherpa-onnx-win-x64": "^1.13.7"
}
},
"node_modules/sherpa-onnx-win-ia32": {
"version": "1.13.7",
"resolved": "https://registry.npmjs.org/sherpa-onnx-win-ia32/-/sherpa-onnx-win-ia32-1.13.7.tgz",
"integrity": "sha512-sTwtpxPQ76XLn0giAbvknIDEDKD3XXi2mo2AVROEucf1pIK1DjQl+LjLkalTeFoQqbC4J3xGx/g+xgcHQD1dsw==",
"cpu": [
"ia32"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/sherpa-onnx-win-x64": {
"version": "1.13.7",
"resolved": "https://registry.npmjs.org/sherpa-onnx-win-x64/-/sherpa-onnx-win-x64-1.13.7.tgz",
"integrity": "sha512-wBV1o+/zgsMrOjfCFIgGrH6S28xq6CqRCLSavCOjTZ6cqr80yGc07DUHxqsHFPZvfoJU+2JF5L2l3gyWFWoWdQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/side-channel": { "node_modules/side-channel": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+4 -2
View File
@@ -9,7 +9,8 @@
"lint": "eslint", "lint": "eslint",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage",
"setup:diarization": "node scripts/setup-diarization.mjs"
}, },
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.7.0", "@prisma/adapter-pg": "^7.7.0",
@@ -20,7 +21,8 @@
"pg": "^8.13.1", "pg": "^8.13.1",
"prisma": "^7.7.0", "prisma": "^7.7.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"sherpa-onnx-node": "^1.13.7"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
+38
View File
@@ -0,0 +1,38 @@
/**
* 마이크 오디오를 Int16 PCM 조각으로 잘라 메인 스레드로 넘긴다.
*
* AudioContext를 16kHz로 열면 브라우저가 리샘플링해주므로 여기서는
* float32 → int16 변환과 버퍼링만 한다. 화자 분리 모델이 16kHz 모노를 받는다.
*/
const CHUNK_SAMPLES = 16000 * 4 // 4초
class PcmCollector extends AudioWorkletProcessor {
constructor() {
super()
this.buffer = new Int16Array(CHUNK_SAMPLES)
this.offset = 0
}
flush() {
if (this.offset === 0) return
const out = this.buffer.slice(0, this.offset)
this.port.postMessage(out, [out.buffer])
this.buffer = new Int16Array(CHUNK_SAMPLES)
this.offset = 0
}
process(inputs) {
const channel = inputs[0] && inputs[0][0]
if (!channel) return true
for (let i = 0; i < channel.length; i++) {
const clamped = Math.max(-1, Math.min(1, channel[i]))
this.buffer[this.offset++] = clamped * 32767
if (this.offset === CHUNK_SAMPLES) this.flush()
}
return true
}
}
registerProcessor('pcm-collector', PcmCollector)
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* 화자 분리 모델 내려받기.
*
* 두 모델 모두 CPU에서 도는 ONNX 파일이며 GPU나 파이썬 런타임이 필요 없다.
* 라이선스 문제로 저장소에 포함하지 않고 최초 1회 내려받는다.
*
* npm run setup:diarization
*/
import { createWriteStream } from 'node:fs'
import { mkdir, rename, rm, stat } from 'node:fs/promises'
import { pipeline } from 'node:stream/promises'
import path from 'node:path'
import { spawn } from 'node:child_process'
const DEST = path.join(process.cwd(), 'models', 'diarization')
const MODELS = [
{
name: 'segmentation.onnx',
// pyannote/segmentation-3.0 (MIT) — 발화 구간 분할
url: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-segmentation-models/sherpa-onnx-pyannote-segmentation-3-0.tar.bz2',
archiveEntry: 'sherpa-onnx-pyannote-segmentation-3-0/model.onnx',
approxMB: 6,
},
{
name: 'embedding.onnx',
// 3D-Speaker CAM++ — 화자 임베딩
url: 'https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/3dspeaker_speech_campplus_sv_zh-cn_16k-common.onnx',
approxMB: 28,
},
]
async function exists(p) {
try {
await stat(p)
return true
} catch {
return false
}
}
async function download(url, dest) {
const res = await fetch(url, { redirect: 'follow' })
if (!res.ok) throw new Error(`${res.status} ${res.statusText} — ${url}`)
await pipeline(res.body, createWriteStream(dest))
}
function run(cmd, args, cwd) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { cwd, stdio: 'inherit' })
child.on('error', reject)
child.on('exit', (code) =>
code === 0 ? resolve() : reject(new Error(`${cmd} 실패 (exit ${code})`)),
)
})
}
async function main() {
await mkdir(DEST, { recursive: true })
for (const model of MODELS) {
const target = path.join(DEST, model.name)
if (await exists(target)) {
console.log(`✓ ${model.name} — 이미 있음`)
continue
}
console.log(`↓ ${model.name} (약 ${model.approxMB}MB) 내려받는 중...`)
if (!model.archiveEntry) {
await download(model.url, target)
console.log(`✓ ${model.name}`)
continue
}
const archive = path.join(DEST, 'tmp.tar.bz2')
await download(model.url, archive)
await run('tar', ['xjf', archive], DEST)
await rename(path.join(DEST, model.archiveEntry), target)
await rm(archive)
await rm(path.join(DEST, path.dirname(model.archiveEntry)), {
recursive: true,
force: true,
})
console.log(`✓ ${model.name}`)
}
console.log(`\n완료. 모델 위치: ${DEST}`)
console.log('이제 녹음 화면에서 "대면 회의 화자 분리"를 쓸 수 있습니다.')
}
main().catch((err) => {
console.error(`\n✗ 실패: ${err.message}`)
console.error('네트워크를 확인하고 다시 실행해주세요.')
process.exit(1)
})
+100
View File
@@ -4,6 +4,10 @@ import {
setStoredApiKey, setStoredApiKey,
clearStoredApiKey, clearStoredApiKey,
maskApiKey, maskApiKey,
getStoredProviderConfig,
setStoredProviderConfig,
clearStoredProviderConfig,
getProviderRequestPayload,
} from '@/lib/api-key-storage' } from '@/lib/api-key-storage'
describe('LocalStorage api key helpers', () => { describe('LocalStorage api key helpers', () => {
@@ -48,3 +52,99 @@ describe('maskApiKey', () => {
expect(maskApiKey('123456789')).toBe('1234••••6789') expect(maskApiKey('123456789')).toBe('1234••••6789')
}) })
}) })
describe('프로바이더 설정 저장', () => {
beforeEach(() => {
window.localStorage.clear()
})
it('저장 후 동일한 설정을 돌려준다', () => {
setStoredProviderConfig({
presetId: 'orcarouter',
apiKey: 'sk-test',
baseUrl: 'https://api.orcarouter.ai/v1',
model: 'google/gemini-2.5-flash-lite',
})
expect(getStoredProviderConfig()).toEqual({
presetId: 'orcarouter',
apiKey: 'sk-test',
baseUrl: 'https://api.orcarouter.ai/v1',
model: 'google/gemini-2.5-flash-lite',
})
})
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: '',
})
})
it('아무것도 없으면 빈 gemini 설정을 돌려준다', () => {
expect(getProviderRequestPayload()).toEqual({
provider: 'gemini',
apiKey: '',
baseUrl: '',
model: '',
})
})
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',
})
})
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 { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { resolveGeminiApiKey, isEnvKeyConfigured } from '@/lib/api-keys' import {
resolveGeminiApiKey,
isEnvKeyConfigured,
isEnvProviderConfigured,
resolveProviderSettings,
} from '@/lib/api-keys'
describe('resolveGeminiApiKey', () => { describe('resolveGeminiApiKey', () => {
const originalEnv = process.env.GEMINI_API_KEY const originalEnv = process.env.GEMINI_API_KEY
@@ -70,3 +75,139 @@ describe('isEnvKeyConfigured', () => {
expect(isEnvKeyConfigured()).toBe(false) expect(isEnvKeyConfigured()).toBe(false)
}) })
}) })
describe('resolveProviderSettings', () => {
const ENV_KEYS = [
'GEMINI_API_KEY',
'LLM_PROVIDER',
'LLM_BASE_URL',
'LLM_API_KEY',
'LLM_MODEL',
] as const
const saved: Record<string, string | undefined> = {}
beforeEach(() => {
for (const key of ENV_KEYS) {
saved[key] = process.env[key]
delete process.env[key]
}
})
afterEach(() => {
for (const key of ENV_KEYS) {
if (saved[key] === undefined) delete process.env[key]
else process.env[key] = saved[key]
}
})
it('provider를 지정하지 않으면 gemini로 간주한다', () => {
expect(resolveProviderSettings({ apiKey: 'req-key' })).toEqual({
provider: 'gemini',
apiKey: 'req-key',
model: undefined,
})
})
it('gemini인데 키가 아무데도 없으면 null (단순 변환 폴백)', () => {
expect(resolveProviderSettings({})).toBeNull()
expect(resolveProviderSettings(null)).toBeNull()
})
it('gemini 키는 요청 → GEMINI_API_KEY 순으로 해석한다', () => {
process.env.GEMINI_API_KEY = 'env-key'
expect(resolveProviderSettings({ apiKey: 'req-key' })?.apiKey).toBe('req-key')
expect(resolveProviderSettings({})?.apiKey).toBe('env-key')
})
it('openai-compatible은 baseUrl과 model이 모두 있어야 한다', () => {
const base = { provider: 'openai-compatible', apiKey: 'sk-x' }
expect(resolveProviderSettings(base)).toBeNull()
expect(
resolveProviderSettings({ ...base, baseUrl: 'https://a.example/v1' }),
).toBeNull()
expect(
resolveProviderSettings({ ...base, model: 'gpt-4o-mini' }),
).toBeNull()
expect(
resolveProviderSettings({
...base,
baseUrl: 'https://a.example/v1',
model: 'gpt-4o-mini',
}),
).toEqual({
provider: 'openai-compatible',
apiKey: 'sk-x',
baseUrl: 'https://a.example/v1',
model: 'gpt-4o-mini',
})
})
it('openai-compatible은 키가 비어도 허용한다 (로컬 모델 서버)', () => {
const settings = resolveProviderSettings({
provider: 'openai-compatible',
apiKey: '',
baseUrl: 'http://localhost:11434/v1',
model: 'llama3.1',
})
expect(settings?.apiKey).toBe('')
})
it('LLM_* 환경변수만으로도 설정된다', () => {
process.env.LLM_PROVIDER = 'openai-compatible'
process.env.LLM_BASE_URL = 'https://api.orcarouter.ai/v1'
process.env.LLM_MODEL = 'google/gemini-2.5-flash-lite'
process.env.LLM_API_KEY = 'sk-env'
expect(resolveProviderSettings({})).toEqual({
provider: 'openai-compatible',
apiKey: 'sk-env',
baseUrl: 'https://api.orcarouter.ai/v1',
model: 'google/gemini-2.5-flash-lite',
})
})
it('알 수 없는 provider 값은 무시하고 기본값으로 떨어진다', () => {
process.env.GEMINI_API_KEY = 'env-key'
expect(resolveProviderSettings({ provider: 'anthropic' })?.provider).toBe(
'gemini',
)
})
})
describe('isEnvProviderConfigured', () => {
const ENV_KEYS = ['GEMINI_API_KEY', 'LLM_PROVIDER', 'LLM_BASE_URL', 'LLM_MODEL', 'LLM_API_KEY'] as const
const saved: Record<string, string | undefined> = {}
beforeEach(() => {
for (const key of ENV_KEYS) {
saved[key] = process.env[key]
delete process.env[key]
}
})
afterEach(() => {
for (const key of ENV_KEYS) {
if (saved[key] === undefined) delete process.env[key]
else process.env[key] = saved[key]
}
})
it('아무 것도 없으면 false', () => {
expect(isEnvProviderConfigured()).toBe(false)
})
it('GEMINI_API_KEY만 있어도 true', () => {
process.env.GEMINI_API_KEY = 'k'
expect(isEnvProviderConfigured()).toBe(true)
})
it('openai-compatible은 base URL과 모델까지 있어야 true', () => {
process.env.LLM_PROVIDER = 'openai-compatible'
process.env.LLM_API_KEY = 'k'
expect(isEnvProviderConfigured()).toBe(false)
process.env.LLM_BASE_URL = 'https://a.example/v1'
process.env.LLM_MODEL = 'm'
expect(isEnvProviderConfigured()).toBe(true)
})
})
+159 -1
View File
@@ -1,5 +1,8 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { generateLiveSummary } from '@/lib/live-summary' import {
generateLiveSummary,
planLiveSummaryRequest,
} from '@/lib/live-summary'
describe('generateLiveSummary', () => { describe('generateLiveSummary', () => {
it('Gemini API 응답을 받아 중간 요약 마크다운을 반환한다', async () => { it('Gemini API 응답을 받아 중간 요약 마크다운을 반환한다', async () => {
@@ -128,3 +131,158 @@ describe('generateLiveSummary', () => {
expect(init.headers['x-goog-api-key']).toBe('secret-key') expect(init.headers['x-goog-api-key']).toBe('secret-key')
}) })
}) })
describe('generateLiveSummary — openai-compatible', () => {
it('OpenAI 호환 엔드포인트로 중간 요약을 만든다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: '## 요약\n진행 중' } }],
}),
})
const result = await generateLiveSummary('회의 내용', {
provider: {
provider: 'openai-compatible',
apiKey: 'sk-test',
baseUrl: 'http://localhost:11434/v1',
model: 'llama3.1',
},
fetchFn: mockFetch,
})
expect(result.success).toBe(true)
expect(mockFetch.mock.calls[0][0]).toBe(
'http://localhost:11434/v1/chat/completions',
)
})
})
describe('planLiveSummaryRequest', () => {
const base = {
totalChunks: 50,
lastSummarizedIndex: 30,
incrementsSinceFull: 3,
fullRefreshEvery: 20,
hasPreviousSummary: true,
}
it('평상시에는 직전 지점부터 증분으로 보낸다', () => {
expect(planLiveSummaryRequest(base)).toEqual({
mode: 'incremental',
startIndex: 30,
})
})
it('첫 호출은 전체를 보낸다', () => {
expect(
planLiveSummaryRequest({ ...base, lastSummarizedIndex: 0 }),
).toEqual({ mode: 'full', startIndex: 0 })
})
it('갱신할 직전 요약이 없으면 전체를 보낸다', () => {
expect(
planLiveSummaryRequest({ ...base, hasPreviousSummary: false }),
).toEqual({ mode: 'full', startIndex: 0 })
})
it('증분이 누적되면 전체 재요약으로 오차를 끊는다', () => {
expect(
planLiveSummaryRequest({ ...base, incrementsSinceFull: 20 }),
).toEqual({ mode: 'full', startIndex: 0 })
})
it('fullRefreshEvery=0이면 전체 재요약을 하지 않는다', () => {
expect(
planLiveSummaryRequest({
...base,
fullRefreshEvery: 0,
incrementsSinceFull: 999,
}).mode,
).toBe('incremental')
})
it('전사가 초기화되어 인덱스가 범위를 벗어나면 전체를 보낸다', () => {
expect(
planLiveSummaryRequest({ ...base, totalChunks: 5 }),
).toEqual({ mode: 'full', startIndex: 0 })
})
})
describe('generateLiveSummary — 증분 모드', () => {
function mockOk(text = '## 요약\n갱신됨') {
return vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
candidates: [{ content: { parts: [{ text }] } }],
}),
})
}
function sentPrompt(mockFetch: ReturnType<typeof vi.fn>): string {
const body = JSON.parse(mockFetch.mock.calls[0][1].body)
return body.contents[0].parts[0].text
}
it('previousSummary가 있으면 요약과 신규 발화를 나눠 전달한다', async () => {
const mockFetch = mockOk()
await generateLiveSummary('새로 나온 이야기', {
apiKey: 'k',
previousSummary: '## 요약\n이전까지의 내용',
fetchFn: mockFetch,
})
const prompt = sentPrompt(mockFetch)
expect(prompt).toContain('[지금까지의 요약]')
expect(prompt).toContain('이전까지의 내용')
expect(prompt).toContain('[새로 추가된 발화]')
expect(prompt).toContain('새로 나온 이야기')
expect(prompt).toContain('갱신')
})
it('previousSummary가 없으면 기존 전체 요약 형식을 유지한다', async () => {
const mockFetch = mockOk()
await generateLiveSummary('전체 전사', { apiKey: 'k', fetchFn: mockFetch })
const prompt = sentPrompt(mockFetch)
expect(prompt).toContain('음성 인식 텍스트:')
expect(prompt).not.toContain('[지금까지의 요약]')
})
it('빈 문자열 previousSummary는 증분으로 취급하지 않는다', async () => {
const mockFetch = mockOk()
await generateLiveSummary('전체 전사', {
apiKey: 'k',
previousSummary: ' ',
fetchFn: mockFetch,
})
expect(sentPrompt(mockFetch)).not.toContain('[지금까지의 요약]')
})
it('긴 회의에서 증분 프롬프트가 전체 프롬프트보다 짧다', async () => {
const longTranscript = '회의 발화 한 줄입니다.\n'.repeat(500)
const fullFetch = mockOk()
await generateLiveSummary(longTranscript, {
apiKey: 'k',
fetchFn: fullFetch,
})
const incFetch = mockOk()
await generateLiveSummary('마지막 30초에 나온 이야기', {
apiKey: 'k',
previousSummary: '## 요약\n지금까지의 요약 본문',
fetchFn: incFetch,
})
expect(sentPrompt(incFetch).length).toBeLessThan(
sentPrompt(fullFetch).length / 5,
)
})
})
+47
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import { import {
generateSimpleMinutes, generateSimpleMinutes,
generateAiMinutes,
generateGeminiMinutes, generateGeminiMinutes,
type MinutesInput, type MinutesInput,
} from '@/lib/minutes-generator' } from '@/lib/minutes-generator'
@@ -105,3 +106,49 @@ describe('generateGeminiMinutes', () => {
} }
}) })
}) })
describe('generateAiMinutes — openai-compatible', () => {
const settings = {
provider: 'openai-compatible' as const,
apiKey: 'sk-test',
baseUrl: 'https://api.orcarouter.ai/v1',
model: 'google/gemini-2.5-flash-lite',
}
it('OpenAI 호환 엔드포인트 응답으로 회의록을 만든다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: '## 요약\n- 리팩토링 완료' } }],
}),
})
const result = await generateAiMinutes(sampleInput, {
provider: settings,
fetchFn: mockFetch,
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.markdown).toContain('# 주간 스프린트 회의')
expect(result.markdown).toContain('리팩토링 완료')
// 하단 문구는 실제 사용한 모델을 표기한다
expect(result.markdown).toContain('google/gemini-2.5-flash-lite')
}
})
it('응답이 비어 있으면 실패로 처리해 단순 변환 폴백을 유도한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: ' ' } }] }),
})
const result = await generateAiMinutes(sampleInput, {
provider: settings,
fetchFn: mockFetch,
})
expect(result.success).toBe(false)
})
})
+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)
}
})
})
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest'
import {
LOCAL_SPEAKER,
REMOTE_SPEAKER,
diarizedSpeakerId,
listAttendees,
resolveSpeakerName,
} from '@/lib/speakers'
describe('resolveSpeakerName', () => {
it('기본 화자에 한국어 표기를 붙인다', () => {
expect(resolveSpeakerName(LOCAL_SPEAKER)).toBe('나')
expect(resolveSpeakerName(REMOTE_SPEAKER)).toBe('상대')
})
it('사용자가 지정한 이름이 우선한다', () => {
const names = { [REMOTE_SPEAKER]: '김지훈' }
expect(resolveSpeakerName(REMOTE_SPEAKER, names)).toBe('김지훈')
expect(resolveSpeakerName(LOCAL_SPEAKER, names)).toBe('나')
})
it('공백뿐인 이름은 무시한다', () => {
expect(resolveSpeakerName(LOCAL_SPEAKER, { [LOCAL_SPEAKER]: ' ' })).toBe('나')
})
it('diarization 화자 번호를 한국어 표기로 바꾼다', () => {
expect(resolveSpeakerName('spk_1')).toBe('화자 1')
expect(resolveSpeakerName('spk_3')).toBe('화자 3')
})
it('diarization 화자에도 지정 이름이 우선한다', () => {
expect(resolveSpeakerName('spk_2', { spk_2: '박지연' })).toBe('박지연')
})
it('그 밖의 식별자는 그대로 돌려준다', () => {
expect(resolveSpeakerName('unknown')).toBe('unknown')
})
})
describe('listAttendees', () => {
it('중복을 제거하고 순서를 유지한다', () => {
expect(
listAttendees([REMOTE_SPEAKER, LOCAL_SPEAKER, REMOTE_SPEAKER]),
).toEqual(['상대', '나'])
})
it('지정된 이름을 반영한다', () => {
expect(
listAttendees([LOCAL_SPEAKER, REMOTE_SPEAKER], {
[LOCAL_SPEAKER]: '배철승',
[REMOTE_SPEAKER]: '김지훈',
}),
).toEqual(['배철승', '김지훈'])
})
})
describe('diarizedSpeakerId', () => {
it('0-based 번호를 1-based 식별자로 바꾼다', () => {
expect(diarizedSpeakerId(0)).toBe('spk_1')
expect(diarizedSpeakerId(3)).toBe('spk_4')
})
})
+116
View File
@@ -164,3 +164,119 @@ describe('liveEnabledFor / depthAdjustableFor', () => {
expect(depthAdjustableFor('custom')).toBe(false) expect(depthAdjustableFor('custom')).toBe(false)
}) })
}) })
describe('내용 기반 섹션 구조', () => {
const transcript = '이번에는 가격 비교 사이트를 만들어 봅시다'
it('meeting은 섹션 제목을 직접 짓도록 지시한다', () => {
const prompt = buildPrompt({
templateId: 'meeting',
depth: 'standard',
transcript,
})
expect(prompt).toContain('섹션 제목을 직접 지어서')
// 고정 제목을 쓰지 말라는 지시가 함께 있어야 한다
expect(prompt).toContain('일반적인 제목은 쓰지 마세요')
})
it('one_on_one도 주제별 제목을 직접 짓도록 지시한다', () => {
const prompt = buildPrompt({
templateId: 'one_on_one',
depth: 'standard',
transcript,
})
expect(prompt).toContain('제목을 직접 지어')
})
it('raw는 구조적 제목을 덧붙이지 말라는 지시를 유지한다', () => {
const prompt = buildPrompt({
templateId: 'raw',
depth: 'detailed',
transcript,
})
expect(prompt).toContain('구조적 제목(## 섹션)을 덧붙이지 마세요')
})
})
describe('액션 아이템 담당자 표기', () => {
const transcript = '다음 주까지 정리해 주세요'
it('담당자 표기를 요구한다', () => {
const prompt = buildPrompt({
templateId: 'meeting',
depth: 'standard',
transcript,
})
expect(prompt).toContain('(담당자)')
})
it('담당자가 불분명해도 항목을 버리지 않도록 지시한다', () => {
const prompt = buildPrompt({
templateId: 'meeting',
depth: 'standard',
transcript,
})
expect(prompt).toContain('담당자가 불분명해도')
})
it('체크박스 형식을 유지해 액션 아이템 파서와 호환된다', () => {
for (const id of ['meeting', 'one_on_one', 'brainstorm'] as const) {
const prompt = buildPrompt({ templateId: id, depth: 'standard', transcript })
expect(prompt).toContain('- [ ]')
}
})
})
describe('공통 규칙', () => {
const transcript = '테스트 발화'
it('프리셋 템플릿 전체에 공통 규칙이 붙는다', () => {
for (const id of Object.keys(TEMPLATES) as (keyof typeof TEMPLATES)[]) {
const prompt = buildPrompt({ templateId: id, depth: 'standard', transcript })
expect(prompt).toContain('지어내지 마세요')
expect(prompt).toContain('한국어로 작성하세요')
}
})
it('발화자 표기가 있으면 활용하도록 지시한다', () => {
const prompt = buildPrompt({
templateId: 'meeting',
depth: 'standard',
transcript,
})
expect(prompt).toContain('발화자 표기가 있다면')
})
it('영어 기술 용어는 원문 표기를 유지하도록 지시한다', () => {
const prompt = buildPrompt({
templateId: 'meeting',
depth: 'standard',
transcript,
})
expect(prompt).toContain('원문 표기를 유지하세요')
})
it('custom 프롬프트에는 공통 규칙을 덧붙이지 않는다', () => {
const prompt = buildPrompt({
templateId: 'custom',
depth: 'standard',
transcript,
customPrompt: '한 줄로만 요약해줘',
})
expect(prompt).toContain('한 줄로만 요약해줘')
expect(prompt).not.toContain('지어내지 마세요')
})
it('custom 프롬프트가 비어 있으면 meeting 템플릿 + 공통 규칙으로 대체한다', () => {
const prompt = buildPrompt({
templateId: 'custom',
depth: 'standard',
transcript,
customPrompt: ' ',
})
expect(prompt).toContain('회의록 작성 전문가')
expect(prompt).toContain('지어내지 마세요')
// 기존 동작 유지 — custom 경로에는 강도/라이브 모디파이어를 적용하지 않는다
expect(prompt).not.toContain('작성 강도')
})
})
+140
View File
@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'
import { import {
formatTranscriptChunks, formatTranscriptChunks,
mergeAdjacentChunks, mergeAdjacentChunks,
mergeSpeakerChunks,
assignSpeakers,
type TranscriptChunk, type TranscriptChunk,
} from '@/lib/transcript-formatter' } from '@/lib/transcript-formatter'
@@ -55,3 +57,141 @@ describe('mergeAdjacentChunks', () => {
expect(result).toEqual([]) expect(result).toEqual([])
}) })
}) })
describe('화자 표기', () => {
it('speaker가 있으면 이름을 붙여 출력한다', () => {
const out = formatTranscriptChunks([
{ text: '안녕하세요', startTime: 0, endTime: 2, isFinal: true, speaker: 'local' },
{ text: '네 반갑습니다', startTime: 3, endTime: 5, isFinal: true, speaker: 'remote' },
])
expect(out).toBe('[00:00] 나: 안녕하세요\n[00:03] 상대: 네 반갑습니다')
})
it('지정된 화자 이름을 사용한다', () => {
const out = formatTranscriptChunks(
[{ text: '안녕하세요', startTime: 0, endTime: 2, isFinal: true, speaker: 'remote' }],
{ remote: '김지훈' },
)
expect(out).toBe('[00:00] 김지훈: 안녕하세요')
})
it('speaker가 없으면 기존 형식을 유지한다', () => {
const out = formatTranscriptChunks([
{ text: '안녕하세요', startTime: 0, endTime: 2, isFinal: true },
])
expect(out).toBe('[00:00] 안녕하세요')
})
})
describe('mergeSpeakerChunks', () => {
const local = [
{ text: '먼저 말함', startTime: 1, endTime: 2, isFinal: true, speaker: 'local' },
{ text: '나중에 말함', startTime: 9, endTime: 10, isFinal: true, speaker: 'local' },
]
const remote = [
{ text: '중간에 답함', startTime: 4, endTime: 6, isFinal: true, speaker: 'remote' },
]
it('여러 트랙을 시간순으로 합친다', () => {
expect(mergeSpeakerChunks(local, remote).map((c) => c.text)).toEqual([
'먼저 말함',
'중간에 답함',
'나중에 말함',
])
})
it('같은 시각이면 넘긴 순서를 유지한다', () => {
const a = [{ text: 'A', startTime: 5, endTime: 6, isFinal: true, speaker: 'local' }]
const b = [{ text: 'B', startTime: 5, endTime: 6, isFinal: true, speaker: 'remote' }]
expect(mergeSpeakerChunks(a, b).map((c) => c.text)).toEqual(['A', 'B'])
})
it('빈 트랙을 섞어도 동작한다', () => {
expect(mergeSpeakerChunks([], remote, [])).toEqual(remote)
expect(mergeSpeakerChunks()).toEqual([])
})
})
describe('mergeAdjacentChunks — 화자 경계', () => {
it('화자가 다르면 간격이 좁아도 합치지 않는다', () => {
const merged = mergeAdjacentChunks(
[
{ text: '그건', startTime: 0, endTime: 1, isFinal: true, speaker: 'local' },
{ text: '어렵습니다', startTime: 1, endTime: 2, isFinal: true, speaker: 'remote' },
],
5,
)
expect(merged).toHaveLength(2)
})
it('같은 화자면 기존대로 합친다', () => {
const merged = mergeAdjacentChunks(
[
{ text: '그건', startTime: 0, endTime: 1, isFinal: true, speaker: 'local' },
{ text: '가능합니다', startTime: 1, endTime: 2, isFinal: true, speaker: 'local' },
],
5,
)
expect(merged).toHaveLength(1)
expect(merged[0].text).toBe('그건 가능합니다')
expect(merged[0].speaker).toBe('local')
})
})
describe('assignSpeakers', () => {
const id = (n: number) => `spk_${n + 1}`
const chunks: TranscriptChunk[] = [
{ text: '안녕하세요', startTime: 0, endTime: 3, isFinal: true },
{ text: '네 반갑습니다', startTime: 5, endTime: 8, isFinal: true },
{ text: '그럼 시작하죠', startTime: 10, endTime: 12, isFinal: true },
]
it('시간이 가장 많이 겹치는 화자를 붙인다', () => {
const out = assignSpeakers(
chunks,
[
{ start: 0, end: 4, speaker: 0 },
{ start: 4.5, end: 9, speaker: 1 },
{ start: 9.5, end: 13, speaker: 0 },
],
id,
)
expect(out.map((c) => c.speaker)).toEqual(['spk_1', 'spk_2', 'spk_1'])
})
it('겹치는 구간이 없으면 화자를 비워 둔다', () => {
const out = assignSpeakers(
chunks,
[{ start: 100, end: 110, speaker: 0 }],
id,
)
expect(out.every((c) => c.speaker === undefined)).toBe(true)
})
it('부분적으로만 겹치면 더 많이 겹치는 쪽을 고른다', () => {
const out = assignSpeakers(
[{ text: '경계에 걸침', startTime: 0, endTime: 10, isFinal: true }],
[
{ start: 0, end: 3, speaker: 0 },
{ start: 3, end: 10, speaker: 1 },
],
id,
)
expect(out[0].speaker).toBe('spk_2')
})
it('segments가 비면 원본을 그대로 돌려준다', () => {
const out = assignSpeakers(chunks, [], id)
expect(out).toEqual(chunks)
})
it('기존 화자 라벨은 새 결과로 덮어쓴다', () => {
const out = assignSpeakers(
[{ text: 'x', startTime: 0, endTime: 2, isFinal: true, speaker: 'spk_9' }],
[{ start: 0, end: 2, speaker: 0 }],
id,
)
expect(out[0].speaker).toBe('spk_1')
})
})
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest } from 'next/server'
import { appendAudio } from '@/lib/audio-session'
export const dynamic = 'force-dynamic'
/** 녹음 중 몇 초짜리 PCM 조각을 이어 붙인다. 본문은 16kHz 모노 Int16 raw PCM. */
export async function POST(request: NextRequest) {
const sessionId = request.nextUrl.searchParams.get('session')?.trim()
if (!sessionId) {
return Response.json({ error: 'session 파라미터가 필요합니다.' }, { status: 400 })
}
const buffer = await request.arrayBuffer()
if (buffer.byteLength === 0) {
return Response.json({ error: '오디오가 비어 있습니다.' }, { status: 400 })
}
if (buffer.byteLength % 2 !== 0) {
return Response.json(
{ error: 'Int16 PCM이 아닙니다 (홀수 바이트).' },
{ status: 400 },
)
}
const result = appendAudio(sessionId, new Int16Array(buffer))
if (!result.ok) {
return Response.json({ error: result.error }, { status: 413 })
}
return Response.json({ seconds: Math.round(result.seconds) })
}
+55
View File
@@ -0,0 +1,55 @@
import { NextRequest } from 'next/server'
import { clearSession, collectSamples, sessionSeconds } from '@/lib/audio-session'
import { diarize, modelsInstalled } from '@/lib/diarization'
export const dynamic = 'force-dynamic'
export async function GET() {
return Response.json({ available: modelsInstalled() })
}
/**
* 누적된 오디오 전체에 화자 분리를 돌린다.
*
* 창을 잘라 반복 실행하면 실행마다 화자 번호가 달라져 이어 붙일 수 없다.
* 전역 클러스터링을 위해 녹음 종료 시 한 번만 돌린다.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}))
const sessionId = typeof body.session === 'string' ? body.session.trim() : ''
if (!sessionId) {
return Response.json({ error: 'session이 필요합니다.' }, { status: 400 })
}
const samples = collectSamples(sessionId)
if (!samples) {
return Response.json({ error: '누적된 오디오가 없습니다.' }, { status: 400 })
}
const numSpeakers =
typeof body.numSpeakers === 'number' && body.numSpeakers > 1
? Math.floor(body.numSpeakers)
: undefined
const seconds = sessionSeconds(sessionId)
const result = await diarize(samples, { numSpeakers })
if (!result.success) {
const status = result.reason === 'models-missing' ? 503 : 500
return Response.json({ error: result.error, reason: result.reason }, { status })
}
clearSession(sessionId)
return Response.json({
segments: result.segments,
seconds: Math.round(seconds),
speakers: new Set(result.segments.map((s) => s.speaker)).size,
})
} 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 const dynamic = 'force-dynamic'
export async function GET() { export async function GET() {
return Response.json({ return Response.json({
envConfigured: isEnvKeyConfigured(), envConfigured: isEnvKeyConfigured(),
envProviderConfigured: isEnvProviderConfigured(),
}) })
} }
+11 -38
View File
@@ -1,5 +1,6 @@
import { NextRequest } from 'next/server' import { NextRequest } from 'next/server'
import { resolveGeminiApiKey } from '@/lib/api-keys' import { resolveProviderSettings } from '@/lib/api-keys'
import { complete, describeModel } from '@/lib/providers'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -8,60 +9,32 @@ const TEST_PROMPT = '"OK"라고만 한 단어로 답하세요.'
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json().catch(() => ({})) const body = await request.json().catch(() => ({}))
const apiKey = resolveGeminiApiKey(body.apiKey) const settings = resolveProviderSettings(body)
if (!apiKey) { if (!settings) {
return Response.json( return Response.json(
{ {
ok: false, ok: false,
error: '키가 비어 있습니다.', error: '설정이 비어 있습니다. 키(또는 base URL과 모델)를 확인해주세요.',
}, },
{ status: 400 }, { status: 400 },
) )
} }
const url = const result = await complete(TEST_PROMPT, settings)
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
const response = await fetch(url, { if (!result.success) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify({
contents: [{ parts: [{ text: TEST_PROMPT }] }],
}),
})
if (!response.ok) {
const errorText = await response.text().catch(() => '')
const message =
response.status === 400
? '잘못된 API 키 형식입니다.'
: response.status === 403
? '권한이 거부되었습니다. 키를 확인해주세요.'
: response.status === 429
? '요청 한도를 초과했지만 키 자체는 유효해 보입니다.'
: `Gemini API 오류: ${response.status}`
return Response.json( return Response.json(
{ { ok: false, error: result.error },
ok: false,
error: message,
detail: errorText.slice(0, 200),
},
{ status: 200 }, { status: 200 },
) )
} }
const data = await response.json()
const reply: string =
data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? ''
return Response.json({ return Response.json({
ok: true, ok: true,
message: '키가 정상적으로 동작합니다.', message: '설정이 정상적으로 동작합니다.',
reply: reply.slice(0, 50), model: describeModel(settings),
reply: result.text.trim().slice(0, 50),
}) })
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : '알 수 없는 오류' const message = err instanceof Error ? err.message : '알 수 없는 오류'
+14 -6
View File
@@ -1,14 +1,15 @@
import { NextRequest } from 'next/server' import { NextRequest } from 'next/server'
import { generateLiveSummary } from '@/lib/live-summary' import { generateLiveSummary } from '@/lib/live-summary'
import { resolveGeminiApiKey } from '@/lib/api-keys' import { resolveProviderSettings } from '@/lib/api-keys'
import type { SummaryDepth, TemplateId } from '@/lib/templates' import type { SummaryDepth, TemplateId } from '@/lib/templates'
const MAX_TRANSCRIPT_CHARS = 40_000 const MAX_TRANSCRIPT_CHARS = 40_000
const MAX_PREVIOUS_SUMMARY_CHARS = 8_000
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const { transcript, template, depth, customPrompt, apiKey } = body const { transcript, template, depth, customPrompt, previousSummary } = body
if (!transcript || typeof transcript !== 'string') { if (!transcript || typeof transcript !== 'string') {
return Response.json( return Response.json(
@@ -17,12 +18,12 @@ export async function POST(request: NextRequest) {
) )
} }
const resolvedKey = resolveGeminiApiKey(apiKey) const settings = resolveProviderSettings(body)
if (!resolvedKey) { if (!settings) {
return Response.json( return Response.json(
{ {
error: error:
'Gemini API 키가 설정되지 않았습니다. /settings에서 키를 입력해주세요.', 'AI 프로바이더가 설정되지 않았습니다. /settings에서 키를 입력해주세요.',
}, },
{ status: 503 }, { status: 503 },
) )
@@ -33,8 +34,15 @@ export async function POST(request: NextRequest) {
? transcript.slice(-MAX_TRANSCRIPT_CHARS) ? transcript.slice(-MAX_TRANSCRIPT_CHARS)
: transcript : transcript
// 증분 모드: 직전 요약 + 새 발화만 보내므로 호출당 토큰이 일정하다.
const previous =
typeof previousSummary === 'string'
? previousSummary.slice(-MAX_PREVIOUS_SUMMARY_CHARS)
: undefined
const result = await generateLiveSummary(truncated, { const result = await generateLiveSummary(truncated, {
apiKey: resolvedKey, provider: settings,
previousSummary: previous,
template: (template as TemplateId | undefined) ?? 'meeting', template: (template as TemplateId | undefined) ?? 'meeting',
depth: depth as SummaryDepth | undefined, depth: depth as SummaryDepth | undefined,
customPrompt: customPrompt:
+10 -18
View File
@@ -1,9 +1,9 @@
import { NextRequest } from 'next/server' import { NextRequest } from 'next/server'
import { import {
generateGeminiMinutes, generateAiMinutes,
generateSimpleMinutes, generateSimpleMinutes,
} from '@/lib/minutes-generator' } from '@/lib/minutes-generator'
import { resolveGeminiApiKey } from '@/lib/api-keys' import { resolveProviderSettings } from '@/lib/api-keys'
import type { SummaryDepth, TemplateId } from '@/lib/templates' import type { SummaryDepth, TemplateId } from '@/lib/templates'
const MAX_TRANSCRIPT_CHARS = 100_000 const MAX_TRANSCRIPT_CHARS = 100_000
@@ -11,16 +11,7 @@ const MAX_TRANSCRIPT_CHARS = 100_000
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const { const { title, transcript, mode, date, template, depth, customPrompt } = body
title,
transcript,
mode,
date,
template,
depth,
customPrompt,
apiKey,
} = body
if (!transcript || typeof transcript !== 'string') { if (!transcript || typeof transcript !== 'string') {
return Response.json( return Response.json(
@@ -48,26 +39,27 @@ export async function POST(request: NextRequest) {
typeof customPrompt === 'string' ? customPrompt : undefined, typeof customPrompt === 'string' ? customPrompt : undefined,
} }
if (mode === 'gemini') { // 'gemini'는 DB에 저장된 기존 값과의 호환을 위해 유지되는 AI 모드 식별자다.
const resolvedKey = resolveGeminiApiKey(apiKey) if (mode === 'gemini' || mode === 'ai') {
if (!resolvedKey) { const settings = resolveProviderSettings(body)
if (!settings) {
const fallback = generateSimpleMinutes(input) const fallback = generateSimpleMinutes(input)
return Response.json({ return Response.json({
markdown: fallback, markdown: fallback,
mode: 'simple', mode: 'simple',
warning: warning:
'Gemini API 키가 설정되지 않아 단순 변환으로 대체되었습니다. /settings에서 키를 설정하세요.', 'AI 프로바이더가 설정되지 않아 단순 변환으로 대체되었습니다. /settings에서 설정하세요.',
}) })
} }
const result = await generateGeminiMinutes(input, { apiKey: resolvedKey }) const result = await generateAiMinutes(input, { provider: settings })
if (!result.success) { if (!result.success) {
const fallback = generateSimpleMinutes(input) const fallback = generateSimpleMinutes(input)
return Response.json({ return Response.json({
markdown: fallback, markdown: fallback,
mode: 'simple', mode: 'simple',
warning: `Gemini 요약에 실패하여 단순 변환으로 대체되었습니다: ${result.error}`, warning: `AI 요약에 실패하여 단순 변환으로 대체되었습니다: ${result.error}`,
}) })
} }
+3 -3
View File
@@ -5,7 +5,7 @@ import Link from 'next/link'
import { AudioUploader } from '@/components/upload/AudioUploader' import { AudioUploader } from '@/components/upload/AudioUploader'
import { LiveRecorder } from '@/components/recorder/LiveRecorder' import { LiveRecorder } from '@/components/recorder/LiveRecorder'
import { MinutesViewer } from '@/components/minutes/MinutesViewer' import { MinutesViewer } from '@/components/minutes/MinutesViewer'
import { getStoredApiKey } from '@/lib/api-key-storage' import { getProviderRequestPayload } from '@/lib/api-key-storage'
import { import {
TEMPLATES, TEMPLATES,
DEFAULT_TEMPLATE_ID, DEFAULT_TEMPLATE_ID,
@@ -119,7 +119,7 @@ export default function HomePage() {
template, template,
depth, depth,
customPrompt: template === 'custom' ? customPrompt : undefined, customPrompt: template === 'custom' ? customPrompt : undefined,
apiKey: getStoredApiKey(), ...getProviderRequestPayload(),
}), }),
}) })
const data = await res.json() const data = await res.json()
@@ -212,7 +212,7 @@ export default function HomePage() {
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200' : 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
}`} }`}
> >
Gemini AI 요약 AI 요약
</button> </button>
</div> </div>
</div> </div>
+170 -50
View File
@@ -4,71 +4,124 @@ import Link from 'next/link'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { import {
clearStoredApiKey, clearStoredApiKey,
clearStoredProviderConfig,
getStoredApiKey, getStoredApiKey,
getStoredProviderConfig,
maskApiKey, maskApiKey,
setStoredApiKey, setStoredProviderConfig,
} from '@/lib/api-key-storage' } from '@/lib/api-key-storage'
import {
DEFAULT_PRESET_ID,
PROVIDER_PRESETS,
findPreset,
} from '@/lib/providers'
type TestState = type TestState =
| { status: 'idle' } | { status: 'idle' }
| { status: 'testing' } | { status: 'testing' }
| { status: 'success'; message: string; reply?: string } | { status: 'success'; message: string; model?: string; reply?: string }
| { status: 'failed'; message: string } | { status: 'failed'; message: string }
export default function SettingsPage() { export default function SettingsPage() {
const [storedKey, setStoredKey] = useState<string | null>(null) const [presetId, setPresetId] = useState(DEFAULT_PRESET_ID)
const [draftKey, setDraftKey] = useState('') const [apiKey, setApiKey] = useState('')
const [baseUrl, setBaseUrl] = useState('')
const [model, setModel] = useState('')
const [showKey, setShowKey] = useState(false) const [showKey, setShowKey] = useState(false)
const [savedKey, setSavedKey] = useState<string | null>(null)
const [envConfigured, setEnvConfigured] = useState<boolean | null>(null) const [envConfigured, setEnvConfigured] = useState<boolean | null>(null)
const [testState, setTestState] = useState<TestState>({ status: 'idle' }) const [testState, setTestState] = useState<TestState>({ status: 'idle' })
const [savedToast, setSavedToast] = useState(false) const [savedToast, setSavedToast] = useState(false)
const preset = findPreset(presetId)
const isOpenAiCompatible = preset.provider === 'openai-compatible'
useEffect(() => { useEffect(() => {
setStoredKey(getStoredApiKey()) const stored = getStoredProviderConfig()
if (stored) {
const storedPreset = findPreset(stored.presetId)
setPresetId(storedPreset.id)
setApiKey(stored.apiKey)
setBaseUrl(stored.baseUrl)
setModel(stored.model)
setSavedKey(stored.apiKey || null)
} else {
// 프로바이더 설정 이전에 저장해둔 Gemini 키를 그대로 이어받는다.
const legacyKey = getStoredApiKey()
setApiKey(legacyKey ?? '')
setModel(findPreset(DEFAULT_PRESET_ID).defaultModel)
setSavedKey(legacyKey)
}
fetch('/api/settings/status') fetch('/api/settings/status')
.then((r) => r.json()) .then((r) => r.json())
.then((d) => setEnvConfigured(!!d.envConfigured)) .then((d) => setEnvConfigured(!!d.envConfigured || !!d.envProviderConfigured))
.catch(() => setEnvConfigured(false)) .catch(() => setEnvConfigured(false))
}, []) }, [])
function handlePresetChange(nextId: string) {
const next = findPreset(nextId)
if (next.id === presetId) return
setPresetId(next.id)
setBaseUrl(next.baseUrl)
setModel(next.defaultModel)
// 프로바이더가 바뀌면 이전 키는 무의미할 뿐 아니라, 그대로 두면
// 다른 회사 엔드포인트로 전송될 수 있으므로 비운다.
setApiKey('')
setTestState({ status: 'idle' })
}
function handleSave() { function handleSave() {
const trimmed = draftKey.trim() setStoredProviderConfig({
if (!trimmed) return presetId,
setStoredApiKey(trimmed) apiKey: apiKey.trim(),
setStoredKey(trimmed) baseUrl: baseUrl.trim(),
setDraftKey('') model: model.trim(),
})
setSavedKey(apiKey.trim() || null)
setSavedToast(true) setSavedToast(true)
setTimeout(() => setSavedToast(false), 2000) setTimeout(() => setSavedToast(false), 2000)
} }
function handleClear() { function handleClear() {
if (!confirm('브라우저에 저장된 키를 삭제할까요? 환경변수가 설정되어 있다면 그것을 사용합니다.')) { if (
!confirm(
'브라우저에 저장된 프로바이더 설정과 키를 삭제할까요? 환경변수가 설정되어 있다면 그것을 사용합니다.',
)
) {
return return
} }
clearStoredProviderConfig()
clearStoredApiKey() clearStoredApiKey()
setStoredKey(null) const fallback = findPreset(DEFAULT_PRESET_ID)
setPresetId(fallback.id)
setApiKey('')
setBaseUrl(fallback.baseUrl)
setModel(fallback.defaultModel)
setSavedKey(null)
setTestState({ status: 'idle' }) setTestState({ status: 'idle' })
} }
async function handleTest() { async function handleTest() {
const keyToTest = draftKey.trim() || storedKey
if (!keyToTest) {
setTestState({ status: 'failed', message: '테스트할 키가 없습니다.' })
return
}
setTestState({ status: 'testing' }) setTestState({ status: 'testing' })
try { try {
const res = await fetch('/api/settings/test', { const res = await fetch('/api/settings/test', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey: keyToTest }), body: JSON.stringify({
provider: preset.provider,
apiKey: apiKey.trim(),
baseUrl: baseUrl.trim(),
model: model.trim(),
}),
}) })
const data = await res.json() const data = await res.json()
if (data.ok) { if (data.ok) {
setTestState({ setTestState({
status: 'success', status: 'success',
message: data.message, message: data.message,
model: data.model,
reply: data.reply, reply: data.reply,
}) })
} else { } else {
@@ -82,13 +135,18 @@ export default function SettingsPage() {
} }
} }
const activeSource = storedKey const canTest =
testState.status !== 'testing' &&
(preset.apiKeyOptional || apiKey.trim().length > 0) &&
(!isOpenAiCompatible || (baseUrl.trim().length > 0 && model.trim().length > 0))
const activeSource = savedKey
? '브라우저 (LocalStorage)' ? '브라우저 (LocalStorage)'
: envConfigured : envConfigured
? '환경변수 (서버)' ? '환경변수 (서버)'
: '없음' : '없음'
const activeBadgeStyle = storedKey const activeBadgeStyle = savedKey
? 'bg-purple-100 text-purple-700' ? 'bg-purple-100 text-purple-700'
: envConfigured : envConfigured
? 'bg-green-100 text-green-700' ? 'bg-green-100 text-green-700'
@@ -117,15 +175,15 @@ export default function SettingsPage() {
⚙️ 설정 ⚙️ 설정
</h1> </h1>
<p className="mt-2 text-sm text-neutral-500"> <p className="mt-2 text-sm text-neutral-500">
Gemini API 키를 브라우저에 저장합니다. 키는 서버에 저장되지 않으며, AI 요약에 사용할 프로바이더와 키를 브라우저에 저장합니다. 키는 서버 DB에
요청 시점에만 전송되어 사용됩니다. 저장되지 않으며, 요청 시점에만 전송되어 사용됩니다.
</p> </p>
</header> </header>
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-6"> <section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-6">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold text-neutral-800"> <h2 className="text-base font-semibold text-neutral-800">
현재 키 소스 현재 설정
</h2> </h2>
<span <span
className={`text-xs px-2.5 py-1 rounded-full font-medium ${activeBadgeStyle}`} className={`text-xs px-2.5 py-1 rounded-full font-medium ${activeBadgeStyle}`}
@@ -135,16 +193,28 @@ export default function SettingsPage() {
</div> </div>
<div className="space-y-1 text-sm text-neutral-600"> <div className="space-y-1 text-sm text-neutral-600">
<p>
<span className="inline-block w-32 text-neutral-500">프로바이더:</span>{' '}
<span className="font-medium text-neutral-800">{preset.label}</span>
</p>
<p>
<span className="inline-block w-32 text-neutral-500">모델:</span>{' '}
{model.trim() ? (
<span className="font-mono text-xs">{model.trim()}</span>
) : (
<span className="text-neutral-400">기본값</span>
)}
</p>
<p> <p>
<span className="inline-block w-32 text-neutral-500">브라우저 키:</span>{' '} <span className="inline-block w-32 text-neutral-500">브라우저 키:</span>{' '}
{storedKey ? ( {savedKey ? (
<span className="font-mono">{maskApiKey(storedKey)}</span> <span className="font-mono">{maskApiKey(savedKey)}</span>
) : ( ) : (
<span className="text-neutral-400">미설정</span> <span className="text-neutral-400">미설정</span>
)} )}
</p> </p>
<p> <p>
<span className="inline-block w-32 text-neutral-500">환경변수 키:</span>{' '} <span className="inline-block w-32 text-neutral-500">환경변수:</span>{' '}
{envConfigured === null ? ( {envConfigured === null ? (
<span className="text-neutral-400">확인 중...</span> <span className="text-neutral-400">확인 중...</span>
) : envConfigured ? ( ) : envConfigured ? (
@@ -156,24 +226,76 @@ export default function SettingsPage() {
</div> </div>
<p className="mt-3 text-xs text-neutral-500"> <p className="mt-3 text-xs text-neutral-500">
💡 우선순위: 브라우저 키 → 환경변수 → 단순 변환 폴백 💡 우선순위: 브라우저 설정 → 환경변수 → 단순 변환 폴백
</p> </p>
</section> </section>
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-6"> <section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-6">
<h2 className="mb-4 text-base font-semibold text-neutral-800"> <h2 className="mb-4 text-base font-semibold text-neutral-800">
Gemini API 키 프로바이더
</h2> </h2>
<label className="block mb-2 text-sm text-neutral-600"> <div className="flex flex-wrap gap-2">
새 키 입력 {PROVIDER_PRESETS.map((item) => (
<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">
{preset.apiKeyLabel}
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type={showKey ? 'text' : 'password'} type={showKey ? 'text' : 'password'}
value={draftKey} value={apiKey}
onChange={(e) => setDraftKey(e.target.value)} onChange={(e) => setApiKey(e.target.value)}
placeholder="AIzaSy..." 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" 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 <button
@@ -184,45 +306,41 @@ export default function SettingsPage() {
{showKey ? '🙈' : '👁️'} {showKey ? '🙈' : '👁️'}
</button> </button>
</div> </div>
{preset.docsUrl && (
<p className="mt-2 text-xs text-neutral-500"> <p className="mt-2 text-xs text-neutral-500">
<a <a
href="https://aistudio.google.com/apikey" href={preset.docsUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="underline hover:text-purple-600" className="underline hover:text-purple-600"
> >
Google AI Studio {preset.docsLabel ?? preset.docsUrl}
</a> </a>
에서 무료로 발급할 수 있습니다. 에서 발급할 수 있습니다.
</p> </p>
)}
</div>
<div className="mt-4 flex flex-wrap gap-2"> <div className="mt-5 flex flex-wrap gap-2">
<button <button
onClick={handleSave} onClick={handleSave}
disabled={!draftKey.trim()}
className="rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700 disabled:opacity-50 transition-colors" className="rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700 disabled:opacity-50 transition-colors"
> >
💾 저장 💾 저장
</button> </button>
<button <button
onClick={handleTest} onClick={handleTest}
disabled={ disabled={!canTest}
testState.status === 'testing' ||
(!draftKey.trim() && !storedKey)
}
className="rounded-lg border border-purple-300 bg-purple-50 px-4 py-2 text-sm font-medium text-purple-700 hover:bg-purple-100 disabled:opacity-50 transition-colors" className="rounded-lg border border-purple-300 bg-purple-50 px-4 py-2 text-sm font-medium text-purple-700 hover:bg-purple-100 disabled:opacity-50 transition-colors"
> >
{testState.status === 'testing' ? '테스트 중...' : '🧪 테스트 호출'} {testState.status === 'testing' ? '테스트 중...' : '🧪 테스트 호출'}
</button> </button>
{storedKey && (
<button <button
onClick={handleClear} onClick={handleClear}
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100 transition-colors" className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100 transition-colors"
> >
🗑️ 삭제 🗑️ 삭제
</button> </button>
)}
</div> </div>
{savedToast && ( {savedToast && (
@@ -234,11 +352,10 @@ export default function SettingsPage() {
{testState.status === 'success' && ( {testState.status === 'success' && (
<div className="mt-4 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"> <div className="mt-4 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
<strong>✓ {testState.message}</strong> <strong>✓ {testState.message}</strong>
{testState.reply && (
<p className="mt-1 font-mono text-xs text-green-600"> <p className="mt-1 font-mono text-xs text-green-600">
Gemini 응답: {testState.reply} {testState.model ? `${testState.model} → ` : ''}
{testState.reply}
</p> </p>
)}
</div> </div>
)} )}
@@ -265,6 +382,9 @@ export default function SettingsPage() {
<li className="text-amber-700"> <li className="text-amber-700">
⚠️ <strong>회의 내용에 민감 정보가 포함된 경우</strong>, 실시간 녹음 기능은 음성을 Google 서버로 전송합니다 (Chrome Web Speech API 동작). 사내 컴플라이언스 정책 확인 후 사용해주세요. ⚠️ <strong>회의 내용에 민감 정보가 포함된 경우</strong>, 실시간 녹음 기능은 음성을 Google 서버로 전송합니다 (Chrome Web Speech API 동작). 사내 컴플라이언스 정책 확인 후 사용해주세요.
</li> </li>
<li className="text-amber-700">
⚠️ <strong>중계 서비스를 고르면 회의 전문이 그 회사 서버를 거칩니다.</strong> OpenAI·Gemini 직접 호출은 해당 회사만, OrcaRouter 같은 라우터는 라우터 운영사 + 실제 모델 제공사 양쪽을 거칩니다. 외부 전송이 곤란하면 <strong>로컬 모델</strong>을 선택하세요.
</li>
</ul> </ul>
</section> </section>
</div> </div>
+1 -1
View File
@@ -126,7 +126,7 @@ export function MinutesViewer({
} }
} }
const modeLabel = mode === 'gemini' ? 'Gemini AI 요약' : '단순 변환' const modeLabel = mode === 'gemini' ? 'AI 요약' : '단순 변환'
return ( return (
<div className="space-y-4"> <div className="space-y-4">
+262 -12
View File
@@ -1,11 +1,26 @@
'use client' 'use client'
import { useEffect, useRef } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition' import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
import { useDualStreamCapture } from '@/hooks/useDualStreamCapture'
import { useDiarization } from '@/hooks/useDiarization'
import { useLiveSummary } from '@/hooks/useLiveSummary' import { useLiveSummary } from '@/hooks/useLiveSummary'
import { formatTranscriptChunks } from '@/lib/transcript-formatter' import {
assignSpeakers,
formatTranscriptChunks,
mergeSpeakerChunks,
type TimeSpan,
} from '@/lib/transcript-formatter'
import {
LOCAL_SPEAKER,
REMOTE_SPEAKER,
diarizedSpeakerId,
resolveSpeakerName,
} from '@/lib/speakers'
import type { SummaryDepth, TemplateId } from '@/lib/templates' import type { SummaryDepth, TemplateId } from '@/lib/templates'
type SeparationMode = 'off' | 'remote' | 'in-person'
interface LiveRecorderProps { interface LiveRecorderProps {
onTranscriptReady: (transcript: string) => void onTranscriptReady: (transcript: string) => void
liveSummaryEnabled: boolean liveSummaryEnabled: boolean
@@ -21,16 +36,63 @@ export function LiveRecorder({
depth, depth,
customPrompt, customPrompt,
}: LiveRecorderProps) { }: LiveRecorderProps) {
const [mode, setMode] = useState<SeparationMode>('off')
const [attendeeCount, setAttendeeCount] = useState(2)
const [pendingStart, setPendingStart] = useState(false)
const [speakerNames, setSpeakerNames] = useState<Record<string, string>>({})
const [segments, setSegments] = useState<TimeSpan[] | null>(null)
const timeOriginRef = useRef(0)
const capture = useDualStreamCapture()
const diarization = useDiarization()
const speakerSeparation = mode !== 'off'
// 화자 분리를 켜면 트랙마다 인식기를 붙인다. 트랙이 곧 화자가 되므로
// 추론 없이 화자가 갈린다.
// 두 트랙을 실제로 확보했을 때만 화자를 라벨링한다.
// 마이크만 잡힌 상태에서 라벨을 붙이면 상대 발언이 내 것으로 기록된다.
const isSeparating = capture.mode === 'dual-stream'
const local = useSpeechRecognition({
audioTrack: speakerSeparation ? capture.micTrack : null,
speaker: isSeparating ? LOCAL_SPEAKER : undefined,
timeOrigin: timeOriginRef.current || undefined,
})
const remote = useSpeechRecognition({
audioTrack: capture.systemTrack,
speaker: REMOTE_SPEAKER,
timeOrigin: timeOriginRef.current || undefined,
})
const { const {
isListening, isListening,
isSupported, isSupported,
chunks,
interimText, interimText,
error: recognitionError, error: recognitionError,
startListening, startListening,
stopListening, } = local
resetChunks,
} = useSpeechRecognition() const chunks = useMemo(() => {
if (isSeparating) return mergeSpeakerChunks(local.chunks, remote.chunks)
if (segments) return assignSpeakers(local.chunks, segments, diarizedSpeakerId)
return local.chunks
}, [isSeparating, segments, local.chunks, remote.chunks])
// 캡처가 끝나 트랙이 준비되면 그때 인식기를 시작한다.
useEffect(() => {
if (!pendingStart) return
if (capture.mode === 'idle') return
setPendingStart(false)
local.startListening()
if (capture.systemTrack) remote.startListening()
if (mode === 'in-person' && capture.micTrack) {
diarization.start(capture.micTrack)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingStart, capture.mode, capture.systemTrack, capture.micTrack])
const { const {
summary, summary,
@@ -78,14 +140,48 @@ export function LiveRecorder({
) )
} }
function handleStop() { async function handleStart() {
stopListening() timeOriginRef.current = Date.now()
const transcript = formatTranscriptChunks(chunks) setSegments(null)
if (mode === 'off') {
startListening()
return
}
setPendingStart(true)
await capture.start({ withSystemAudio: mode === 'remote' })
}
async function handleStop() {
local.stopListening()
remote.stopListening()
// 대면 모드는 녹음이 끝난 뒤 전체 오디오에 한 번 화자 분리를 돌린다.
let finalChunks = chunks
if (mode === 'in-person') {
const result = await diarization.finish(attendeeCount)
if (result.length > 0) {
setSegments(result)
finalChunks = assignSpeakers(local.chunks, result, diarizedSpeakerId)
}
}
capture.stop()
const transcript = formatTranscriptChunks(finalChunks, speakerNames)
if (transcript.length > 0) { if (transcript.length > 0) {
onTranscriptReady(transcript) onTranscriptReady(transcript)
} }
} }
function handleReset() {
local.resetChunks()
remote.resetChunks()
diarization.reset()
setSegments(null)
}
const lastUpdatedLabel = lastUpdatedAt const lastUpdatedLabel = lastUpdatedAt
? new Date(lastUpdatedAt).toLocaleTimeString('ko-KR', { ? new Date(lastUpdatedAt).toLocaleTimeString('ko-KR', {
hour: '2-digit', hour: '2-digit',
@@ -136,7 +232,7 @@ export function LiveRecorder({
</button> </button>
) : ( ) : (
<button <button
onClick={startListening} onClick={handleStart}
className="flex items-center gap-2 rounded-full bg-blue-600 px-6 py-3 text-white font-medium shadow-lg shadow-blue-600/25 hover:bg-blue-700 transition-colors" className="flex items-center gap-2 rounded-full bg-blue-600 px-6 py-3 text-white font-medium shadow-lg shadow-blue-600/25 hover:bg-blue-700 transition-colors"
> >
<span className="text-lg">🎤</span> <span className="text-lg">🎤</span>
@@ -146,7 +242,7 @@ export function LiveRecorder({
{chunks.length > 0 && !isListening && ( {chunks.length > 0 && !isListening && (
<button <button
onClick={resetChunks} onClick={handleReset}
className="rounded-full border border-neutral-300 px-4 py-2 text-sm text-neutral-600 hover:bg-neutral-50 transition-colors" className="rounded-full border border-neutral-300 px-4 py-2 text-sm text-neutral-600 hover:bg-neutral-50 transition-colors"
> >
초기화 초기화
@@ -159,7 +255,148 @@ export function LiveRecorder({
녹음 중 · {wordCount}단어 · {chunks.length}개 구간 녹음 중 · {wordCount}단어 · {chunks.length}개 구간
</div> </div>
)} )}
{isListening && capture.mode === 'dual-stream' && (
<div className="flex items-center gap-1.5 rounded-full bg-purple-50 px-4 py-1.5 text-xs text-purple-700">
<span className="h-2 w-2 rounded-full bg-purple-500" />
화자 분리 중
</div> </div>
)}
</div>
{!isListening && chunks.length === 0 && (
<div className="rounded-xl border border-neutral-200 bg-white p-4">
<p className="mb-3 text-sm font-medium text-neutral-800">화자 분리</p>
<div className="flex flex-wrap gap-2">
{(
[
{ id: 'off', label: '사용 안 함' },
{ id: 'remote', label: '원격 회의' },
{ id: 'in-person', label: '대면 회의' },
] as const
).map((option) => (
<button
key={option.id}
onClick={() => setMode(option.id)}
disabled={option.id === 'in-person' && diarization.available === false}
className={`rounded-lg border px-3 py-2 text-sm font-medium transition-all disabled:opacity-40 ${
mode === option.id
? 'border-purple-400 bg-purple-50 text-purple-700'
: 'border-neutral-200 bg-white text-neutral-600 hover:border-neutral-300'
}`}
>
{option.label}
</button>
))}
</div>
{mode === 'remote' && (
<>
<p className="mt-3 text-xs text-neutral-500">
내 마이크와 상대 목소리를 별도 트랙으로 받아 구분합니다. 추론이 없어
정확합니다. 시작 시{' '}
<strong>화면 공유 대화상자에서 &ldquo;탭 오디오 공유&rdquo;를 반드시 켜주세요.</strong>
</p>
<p className="mt-1.5 text-xs text-amber-700">
노트북 한 대를 두고 마주 앉은 대면 회의에서는 동작하지 않습니다.
그 경우 &ldquo;대면 회의&rdquo;를 선택하세요.
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{(
[
{ id: LOCAL_SPEAKER, label: '내 이름', placeholder: '나' },
{ id: REMOTE_SPEAKER, label: '상대 이름', placeholder: '상대' },
] as const
).map((field) => (
<label key={field.id} className="block">
<span className="mb-1 block text-xs text-neutral-500">
{field.label}
</span>
<input
type="text"
value={speakerNames[field.id] ?? ''}
onChange={(e) =>
setSpeakerNames((prev) => ({
...prev,
[field.id]: e.target.value,
}))
}
placeholder={field.placeholder}
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-purple-400 focus:outline-none"
/>
</label>
))}
</div>
</>
)}
{mode === 'in-person' && (
<>
<p className="mt-3 text-xs text-neutral-500">
마이크 하나에 섞여 들어온 목소리를 내 PC에서 분석해 화자를 나눕니다.
외부로 오디오를 보내지 않습니다.{' '}
<strong>녹음이 끝난 뒤 한 번에 분석</strong>하므로 실시간 화자 표시는 없습니다.
</p>
<label className="mt-4 block max-w-40">
<span className="mb-1 block text-xs text-neutral-500">참석자 수</span>
<input
type="number"
min={2}
max={8}
value={attendeeCount}
onChange={(e) =>
setAttendeeCount(Math.max(2, Math.min(8, Number(e.target.value) || 2)))
}
className="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-purple-400 focus:outline-none"
/>
<span className="mt-1 block text-xs text-neutral-500">
정확한 수를 넣을수록 결과가 좋아집니다. 자동 추정은 화자 수를 잘못 세는
경우가 많습니다.
</span>
</label>
</>
)}
{diarization.available === false && (
<p className="mt-3 text-xs text-amber-700">
대면 회의 화자 분리를 쓰려면 모델이 필요합니다 —{' '}
<code className="rounded bg-amber-50 px-1">npm run setup:diarization</code>{' '}
을 실행한 뒤 서버를 다시 시작하세요.
</p>
)}
</div>
)}
{diarization.status.state === 'analyzing' && (
<div className="rounded-xl border border-purple-200 bg-purple-50 p-4 text-sm text-purple-700">
화자를 분석하는 중입니다... 회의가 길수록 시간이 걸립니다.
</div>
)}
{diarization.status.state === 'done' && (
<div className="rounded-xl border border-green-200 bg-green-50 p-4 text-sm text-green-700">
화자 {diarization.status.speakers}명을 구분했습니다.
</div>
)}
{diarization.status.state === 'error' && (
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
<strong>화자 분리 오류:</strong> {diarization.status.message}
</div>
)}
{capture.error && (
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
<strong>오디오 캡처 오류:</strong> {capture.error}
</div>
)}
{capture.warning && (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
⚠️ {capture.warning}
</div>
)}
{recognitionError && ( {recognitionError && (
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700"> <div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
@@ -204,7 +441,20 @@ export function LiveRecorder({
{hasTranscript ? ( {hasTranscript ? (
<div className="space-y-1 text-sm font-mono leading-relaxed text-neutral-700"> <div className="space-y-1 text-sm font-mono leading-relaxed text-neutral-700">
{chunks.map((chunk, i) => ( {chunks.map((chunk, i) => (
<p key={i}>{chunk.text}</p> <p key={i}>
{chunk.speaker && (
<span
className={`mr-1.5 font-semibold ${
chunk.speaker === LOCAL_SPEAKER
? 'text-purple-600'
: 'text-teal-600'
}`}
>
{resolveSpeakerName(chunk.speaker, speakerNames)}:
</span>
)}
{chunk.text}
</p>
))} ))}
{interimText && ( {interimText && (
<p className="text-neutral-400 italic"> <p className="text-neutral-400 italic">
+169
View File
@@ -0,0 +1,169 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { TimeSpan } from '@/lib/transcript-formatter'
export type DiarizationStatus =
| { state: 'idle' }
| { state: 'unavailable'; message: string }
| { state: 'recording'; seconds: number }
| { state: 'analyzing' }
| { state: 'done'; speakers: number; segments: TimeSpan[] }
| { state: 'error'; message: string }
interface UseDiarizationResult {
/** 모델 설치 여부. null이면 확인 중. */
available: boolean | null
status: DiarizationStatus
/** 오디오 수집 시작. 마이크 트랙을 넘긴다. */
start: (track: MediaStreamTrack) => Promise<void>
/** 수집을 멈추고 전체 오디오에 화자 분리를 돌린다. */
finish: (numSpeakers?: number) => Promise<TimeSpan[]>
reset: () => void
}
function newSessionId(): string {
return `s_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
}
/**
* 대면 회의용 화자 분리.
*
* 녹음 중에는 4초짜리 PCM 조각을 서버에 쌓아두기만 하고, 녹음이 끝나면
* 전체 오디오에 한 번 분리를 돌린다. 창을 잘라 반복 실행하면 실행마다
* 화자 번호가 달라져 이어 붙일 수 없기 때문이다.
*/
export function useDiarization(): UseDiarizationResult {
const [available, setAvailable] = useState<boolean | null>(null)
const [status, setStatus] = useState<DiarizationStatus>({ state: 'idle' })
const sessionRef = useRef<string>('')
const contextRef = useRef<AudioContext | null>(null)
const nodeRef = useRef<AudioWorkletNode | null>(null)
const secondsRef = useRef(0)
useEffect(() => {
let cancelled = false
fetch('/api/diarize')
.then((r) => r.json())
.then((d) => {
if (!cancelled) setAvailable(!!d.available)
})
.catch(() => {
if (!cancelled) setAvailable(false)
})
return () => {
cancelled = true
}
}, [])
const teardown = useCallback(() => {
nodeRef.current?.port.close()
nodeRef.current?.disconnect()
nodeRef.current = null
contextRef.current?.close().catch(() => {})
contextRef.current = null
}, [])
const start = useCallback(
async (track: MediaStreamTrack) => {
sessionRef.current = newSessionId()
secondsRef.current = 0
try {
// 16kHz로 열면 브라우저가 리샘플링해준다 — 모델이 받는 샘플레이트.
const context = new AudioContext({ sampleRate: 16_000 })
contextRef.current = context
await context.audioWorklet.addModule('/pcm-worklet.js')
const source = context.createMediaStreamSource(new MediaStream([track]))
const node = new AudioWorkletNode(context, 'pcm-collector')
nodeRef.current = node
node.port.onmessage = (event) => {
const pcm = event.data as Int16Array
secondsRef.current += pcm.length / 16_000
setStatus({
state: 'recording',
seconds: Math.round(secondsRef.current),
})
// 실패해도 녹음을 막지 않는다. 그 구간만 화자 분리에서 빠진다.
fetch(`/api/diarize/chunk?session=${sessionRef.current}`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
// 워크릿이 매번 새 버퍼를 transfer 하므로 buffer 전체가 곧 이 조각이다.
body: pcm.buffer as ArrayBuffer,
}).catch(() => {})
}
source.connect(node)
setStatus({ state: 'recording', seconds: 0 })
} catch (err) {
teardown()
setStatus({
state: 'error',
message:
err instanceof Error
? `오디오 수집 실패: ${err.message}`
: '오디오 수집에 실패했습니다.',
})
}
},
[teardown],
)
const finish = useCallback(
async (numSpeakers?: number): Promise<TimeSpan[]> => {
teardown()
if (!sessionRef.current || secondsRef.current < 1) {
setStatus({ state: 'idle' })
return []
}
setStatus({ state: 'analyzing' })
try {
const res = await fetch('/api/diarize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: sessionRef.current, numSpeakers }),
})
const data = await res.json()
if (!res.ok) {
setStatus({
state: 'error',
message: data.error ?? '화자 분리에 실패했습니다.',
})
return []
}
const segments: TimeSpan[] = data.segments ?? []
setStatus({ state: 'done', speakers: data.speakers ?? 0, segments })
return segments
} catch (err) {
setStatus({
state: 'error',
message:
err instanceof Error ? err.message : '화자 분리 요청에 실패했습니다.',
})
return []
}
},
[teardown],
)
const reset = useCallback(() => {
teardown()
sessionRef.current = ''
secondsRef.current = 0
setStatus({ state: 'idle' })
}, [teardown])
useEffect(() => teardown, [teardown])
return { available, status, start, finish, reset }
}
+121
View File
@@ -0,0 +1,121 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
export type CaptureMode = 'idle' | 'mic-only' | 'dual-stream'
interface DualStreamCapture {
mode: CaptureMode
/** 내 목소리 트랙 (마이크) */
micTrack: MediaStreamTrack | null
/** 상대 목소리 트랙 (시스템·탭 오디오). 화면 공유를 거부하거나 오디오를 안 켜면 null */
systemTrack: MediaStreamTrack | null
error: string | null
/** 시스템 오디오 없이 마이크만 잡힌 경우의 안내 */
warning: string | null
start: (options?: { withSystemAudio?: boolean }) => Promise<void>
stop: () => void
}
function describeCaptureError(err: unknown): string {
if (!(err instanceof Error)) return '오디오 캡처에 실패했습니다.'
switch (err.name) {
case 'NotAllowedError':
return '오디오 캡처 권한이 거부되었습니다. 마이크와 화면 공유를 모두 허용해주세요.'
case 'NotFoundError':
return '사용할 수 있는 마이크를 찾을 수 없습니다.'
case 'NotReadableError':
return '다른 앱이 마이크를 사용 중입니다. 해당 앱을 종료하고 다시 시도해주세요.'
default:
return `오디오 캡처 실패: ${err.message}`
}
}
/**
* 화자 분리를 위한 2트랙 캡처.
*
* 내 마이크와 시스템(탭) 오디오를 **별도 트랙으로** 잡는다. 트랙이 곧 화자이므로
* 추론 없이 정확도 100%로 화자가 갈린다. 원격 1:1 회의를 겨냥한 경로다.
*
* 시스템 오디오는 Chrome/Windows와 Chrome 141+/macOS 14.2+ 에서만 캡처된다.
* 실패하면 마이크 단독 모드로 떨어지며, 그 경우 화자 분리는 되지 않는다.
*/
export function useDualStreamCapture(): DualStreamCapture {
const [mode, setMode] = useState<CaptureMode>('idle')
const [micTrack, setMicTrack] = useState<MediaStreamTrack | null>(null)
const [systemTrack, setSystemTrack] = useState<MediaStreamTrack | null>(null)
const [error, setError] = useState<string | null>(null)
const [warning, setWarning] = useState<string | null>(null)
const streamsRef = useRef<MediaStream[]>([])
const stop = useCallback(() => {
for (const stream of streamsRef.current) {
for (const track of stream.getTracks()) track.stop()
}
streamsRef.current = []
setMicTrack(null)
setSystemTrack(null)
setMode('idle')
}, [])
const start = useCallback(
async (options?: { withSystemAudio?: boolean }) => {
const withSystemAudio = options?.withSystemAudio ?? true
setError(null)
setWarning(null)
let micStream: MediaStream
try {
micStream = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch (err) {
setError(describeCaptureError(err))
return
}
streamsRef.current.push(micStream)
const mic = micStream.getAudioTracks()[0] ?? null
setMicTrack(mic)
if (!withSystemAudio) {
setMode('mic-only')
return
}
try {
// 오디오만 필요해도 video:true를 함께 요청해야 한다 (스펙 요구사항).
const displayStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true,
})
streamsRef.current.push(displayStream)
// 영상은 쓰지 않으므로 즉시 끊어 자원과 프라이버시 노출을 줄인다.
for (const track of displayStream.getVideoTracks()) track.stop()
const system = displayStream.getAudioTracks()[0] ?? null
if (!system) {
setWarning(
'시스템 오디오가 공유되지 않아 화자 분리가 비활성화됩니다. 공유 대화상자에서 "탭 오디오 공유"를 켜주세요.',
)
setMode('mic-only')
return
}
setSystemTrack(system)
setMode('dual-stream')
} catch (err) {
setWarning(
`${describeCaptureError(err)} 마이크만으로 계속 녹음합니다 (화자 분리 없음).`,
)
setMode('mic-only')
}
},
[],
)
useEffect(() => stop, [stop])
return { mode, micTrack, systemTrack, error, warning, start, stop }
}
+39 -5
View File
@@ -4,13 +4,16 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import type { TranscriptChunk } from '@/lib/transcript-formatter' import type { TranscriptChunk } from '@/lib/transcript-formatter'
import { formatTranscriptChunks } from '@/lib/transcript-formatter' import { formatTranscriptChunks } from '@/lib/transcript-formatter'
import type { SummaryDepth, TemplateId } from '@/lib/templates' import type { SummaryDepth, TemplateId } from '@/lib/templates'
import { getStoredApiKey } from '@/lib/api-key-storage' import { getProviderRequestPayload } from '@/lib/api-key-storage'
import { planLiveSummaryRequest } from '@/lib/live-summary'
interface UseLiveSummaryOptions { interface UseLiveSummaryOptions {
enabled: boolean enabled: boolean
pollIntervalMs?: number pollIntervalMs?: number
minWords?: number minWords?: number
incrementWords?: number incrementWords?: number
/** 증분 요약을 이 횟수만큼 반복하면 전사 전체로 한 번 다시 요약한다. 0이면 끈다. */
fullRefreshEvery?: number
template?: TemplateId template?: TemplateId
depth?: SummaryDepth depth?: SummaryDepth
customPrompt?: string customPrompt?: string
@@ -41,6 +44,7 @@ export function useLiveSummary(
pollIntervalMs = 30_000, pollIntervalMs = 30_000,
minWords = 25, minWords = 25,
incrementWords = 40, incrementWords = 40,
fullRefreshEvery = 20,
template = 'meeting', template = 'meeting',
depth, depth,
customPrompt, customPrompt,
@@ -64,6 +68,11 @@ export function useLiveSummary(
const cooldownUntilRef = useRef<number | null>(null) const cooldownUntilRef = useRef<number | null>(null)
const consecutiveFailuresRef = useRef(0) const consecutiveFailuresRef = useRef(0)
// 증분 요약 상태 — 성공했을 때만 전진시켜서 실패해도 발화를 잃지 않는다.
const summaryRef = useRef('')
const lastSummarizedIndexRef = useRef(0)
const incrementsSinceFullRef = useRef(0)
useEffect(() => { useEffect(() => {
chunksRef.current = chunks chunksRef.current = chunks
}, [chunks]) }, [chunks])
@@ -98,12 +107,31 @@ export function useLiveSummary(
return return
} }
const transcript = formatTranscriptChunks(chunksRef.current) const allChunks = chunksRef.current
const transcript = formatTranscriptChunks(allChunks)
const currentWordCount = countWords(transcript) const currentWordCount = countWords(transcript)
if (currentWordCount < minWords) return if (currentWordCount < minWords) return
if (currentWordCount - lastWordCountRef.current < incrementWords) return if (currentWordCount - lastWordCountRef.current < incrementWords) return
const plan = planLiveSummaryRequest({
totalChunks: allChunks.length,
lastSummarizedIndex: lastSummarizedIndexRef.current,
incrementsSinceFull: incrementsSinceFullRef.current,
fullRefreshEvery,
hasPreviousSummary: summaryRef.current.trim().length > 0,
})
// 요청을 보내는 시점의 길이를 고정해 둔다. 응답을 기다리는 동안
// 새 청크가 쌓여도 그 부분은 다음 회차로 넘어간다.
const chunkCountAtSend = allChunks.length
const payloadTranscript =
plan.mode === 'full'
? transcript
: formatTranscriptChunks(allChunks.slice(plan.startIndex))
if (payloadTranscript.trim().length === 0) return
const controller = new AbortController() const controller = new AbortController()
abortRef.current = controller abortRef.current = controller
inFlightRef.current = true inFlightRef.current = true
@@ -115,11 +143,13 @@ export function useLiveSummary(
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
transcript, transcript: payloadTranscript,
previousSummary:
plan.mode === 'incremental' ? summaryRef.current : undefined,
template: configRef.current.template, template: configRef.current.template,
depth: configRef.current.depth, depth: configRef.current.depth,
customPrompt: configRef.current.customPrompt, customPrompt: configRef.current.customPrompt,
apiKey: getStoredApiKey(), ...getProviderRequestPayload(),
}), }),
signal: controller.signal, signal: controller.signal,
}) })
@@ -142,8 +172,12 @@ export function useLiveSummary(
const data = await res.json() const data = await res.json()
setSummary(data.markdown) setSummary(data.markdown)
summaryRef.current = data.markdown
setLastUpdatedAt(Date.now()) setLastUpdatedAt(Date.now())
lastWordCountRef.current = currentWordCount lastWordCountRef.current = currentWordCount
lastSummarizedIndexRef.current = chunkCountAtSend
incrementsSinceFullRef.current =
plan.mode === 'full' ? 0 : incrementsSinceFullRef.current + 1
consecutiveFailuresRef.current = 0 consecutiveFailuresRef.current = 0
cooldownUntilRef.current = null cooldownUntilRef.current = null
setCooldownUntil(null) setCooldownUntil(null)
@@ -164,7 +198,7 @@ export function useLiveSummary(
return () => { return () => {
clearInterval(interval) clearInterval(interval)
} }
}, [enabled, pollIntervalMs, minWords, incrementWords]) }, [enabled, pollIntervalMs, minWords, incrementWords, fullRefreshEvery])
useEffect(() => { useEffect(() => {
return () => { return () => {
+40 -5
View File
@@ -3,6 +3,22 @@
import { useState, useRef, useCallback, useEffect } from 'react' import { useState, useRef, useCallback, useEffect } from 'react'
import type { TranscriptChunk } from '@/lib/transcript-formatter' import type { TranscriptChunk } from '@/lib/transcript-formatter'
export interface UseSpeechRecognitionOptions {
/**
* 전사할 오디오 트랙. 지정하면 기본 마이크 대신 이 트랙을 인식한다.
* 트랙마다 인식기를 붙이면 화자가 트랙으로 확정된다.
*/
audioTrack?: MediaStreamTrack | null
/** 이 인식기가 만든 청크에 붙일 화자 식별자. */
speaker?: string
/**
* 타임스탬프 기준 시각(ms). 여러 인식기를 병합하려면 같은 값을 넘겨야
* 한 시간축 위에 놓인다. 생략하면 startListening 호출 시각을 쓴다.
*/
timeOrigin?: number
lang?: string
}
interface SpeechRecognitionHook { interface SpeechRecognitionHook {
isListening: boolean isListening: boolean
isSupported: boolean | null isSupported: boolean | null
@@ -30,7 +46,10 @@ function describeError(code: string): string {
} }
} }
export function useSpeechRecognition(): SpeechRecognitionHook { export function useSpeechRecognition(
options: UseSpeechRecognitionOptions = {},
): SpeechRecognitionHook {
const { audioTrack, speaker, timeOrigin, lang = 'ko-KR' } = options
const [isListening, setIsListening] = useState(false) const [isListening, setIsListening] = useState(false)
const [chunks, setChunks] = useState<TranscriptChunk[]>([]) const [chunks, setChunks] = useState<TranscriptChunk[]>([])
const [interimText, setInterimText] = useState('') const [interimText, setInterimText] = useState('')
@@ -40,6 +59,12 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
const startTimeRef = useRef<number>(0) const startTimeRef = useRef<number>(0)
const networkRetryRef = useRef<number>(0) const networkRetryRef = useRef<number>(0)
// start()/onend 콜백이 항상 최신 값을 보도록 ref로 들고 있는다.
const configRef = useRef({ audioTrack, speaker, timeOrigin, lang })
useEffect(() => {
configRef.current = { audioTrack, speaker, timeOrigin, lang }
}, [audioTrack, speaker, timeOrigin, lang])
const MAX_NETWORK_RETRIES = 8 const MAX_NETWORK_RETRIES = 8
useEffect(() => { useEffect(() => {
@@ -59,12 +84,15 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
const SpeechRecognitionAPI = const SpeechRecognitionAPI =
window.SpeechRecognition || window.webkitSpeechRecognition window.SpeechRecognition || window.webkitSpeechRecognition
const { audioTrack: track, speaker: speakerId, timeOrigin: origin, lang: language } =
configRef.current
const recognition = new SpeechRecognitionAPI() const recognition = new SpeechRecognitionAPI()
recognition.lang = 'ko-KR' recognition.lang = language
recognition.continuous = true recognition.continuous = true
recognition.interimResults = true recognition.interimResults = true
startTimeRef.current = Date.now() startTimeRef.current = origin ?? Date.now()
networkRetryRef.current = 0 networkRetryRef.current = 0
setError(null) setError(null)
@@ -88,6 +116,7 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
startTime: Math.max(0, now - 2), startTime: Math.max(0, now - 2),
endTime: now, endTime: now,
isFinal: true, isFinal: true,
...(speakerId ? { speaker: speakerId } : {}),
}, },
]) ])
setInterimText('') setInterimText('')
@@ -103,8 +132,14 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
const delay = networkRetryRef.current > 0 ? 1500 : 0 const delay = networkRetryRef.current > 0 ? 1500 : 0
setTimeout(() => { setTimeout(() => {
if (recognitionRef.current !== recognition) return if (recognitionRef.current !== recognition) return
// 트랙이 끝났으면(화면 공유 중단 등) 재시작하지 않는다.
if (track && track.readyState !== 'live') {
setIsListening(false)
recognitionRef.current = null
return
}
try { try {
recognition.start() recognition.start(track ?? undefined)
} catch { } catch {
setIsListening(false) setIsListening(false)
recognitionRef.current = null recognitionRef.current = null
@@ -141,7 +176,7 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
recognitionRef.current = recognition recognitionRef.current = recognition
try { try {
recognition.start() recognition.start(track ?? undefined)
setIsListening(true) setIsListening(true)
} catch (err) { } catch (err) {
setError( setError(
+85
View File
@@ -1,5 +1,7 @@
'use client' 'use client'
import { findPreset, type ProviderId } from './providers'
const STORAGE_KEY = 'meeting-minutes:gemini-api-key' const STORAGE_KEY = 'meeting-minutes:gemini-api-key'
export function getStoredApiKey(): string | null { export function getStoredApiKey(): string | null {
@@ -34,3 +36,86 @@ export function maskApiKey(key: string): string {
if (key.length <= 8) return '••••' if (key.length <= 8) return '••••'
return `${key.slice(0, 4)}••••${key.slice(-4)}` return `${key.slice(0, 4)}••••${key.slice(-4)}`
} }
/* ------------------------------------------------------------------------- *
* 프로바이더 설정 (Gemini / OpenAI 호환 엔드포인트)
* ------------------------------------------------------------------------- */
const PROVIDER_STORAGE_KEY = 'meeting-minutes:llm-provider'
export interface StoredProviderConfig {
presetId: string
apiKey: string
baseUrl: string
model: string
}
export interface ProviderRequestPayload {
provider: ProviderId
apiKey: string
baseUrl: string
model: 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 : '',
}
} 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: '',
}
}
const preset = findPreset(stored.presetId)
return {
provider: preset.provider,
apiKey: stored.apiKey || (preset.provider === 'gemini' ? getStoredApiKey() ?? '' : ''),
baseUrl: stored.baseUrl,
model: stored.model,
}
}
+69
View File
@@ -1,3 +1,5 @@
import { isProviderId, type ProviderId, type ProviderSettings } from './providers'
/** /**
* Gemini API 키 해석 우선순위: * Gemini API 키 해석 우선순위:
* 1) 요청 body로 전달된 키 (브라우저 LocalStorage에서 옴) * 1) 요청 body로 전달된 키 (브라우저 LocalStorage에서 옴)
@@ -19,3 +21,70 @@ export function resolveGeminiApiKey(
export function isEnvKeyConfigured(): boolean { export function isEnvKeyConfigured(): boolean {
return (process.env.GEMINI_API_KEY ?? '').trim().length > 0 return (process.env.GEMINI_API_KEY ?? '').trim().length > 0
} }
/** LLM_* 환경변수로 프로바이더가 지정되어 있는지 */
export function isEnvProviderConfigured(): boolean {
const provider = env('LLM_PROVIDER')
if (provider === 'openai-compatible') {
return env('LLM_BASE_URL').length > 0 && env('LLM_MODEL').length > 0
}
return isEnvKeyConfigured() || env('LLM_API_KEY').length > 0
}
export interface ProviderRequestBody {
provider?: unknown
apiKey?: unknown
baseUrl?: unknown
model?: unknown
}
/**
* 요청 body + 환경변수로부터 프로바이더 설정을 만든다.
* 필드별 우선순위는 기존 키 해석과 동일하게 "요청 → 환경변수" 순이다.
*
* 사용 가능한 설정이 없으면 null을 돌려주고, 호출부는 단순 변환으로 폴백한다.
*/
export function resolveProviderSettings(
body: ProviderRequestBody | null | undefined,
): ProviderSettings | null {
const provider = resolveProvider(body?.provider)
if (provider === 'gemini') {
const apiKey = resolveGeminiApiKey(str(body?.apiKey) || env('LLM_API_KEY'))
if (!apiKey) return null
return {
provider,
apiKey,
model: str(body?.model) || env('LLM_MODEL') || undefined,
}
}
const baseUrl = str(body?.baseUrl) || env('LLM_BASE_URL')
const model = str(body?.model) || env('LLM_MODEL')
if (baseUrl.length === 0 || model.length === 0) return null
// 로컬 모델 서버는 키가 없어도 되므로 빈 문자열을 허용한다.
return {
provider,
apiKey: str(body?.apiKey) || env('LLM_API_KEY'),
baseUrl,
model,
}
}
function resolveProvider(requested: unknown): ProviderId {
if (isProviderId(requested)) return requested
const fromEnv = env('LLM_PROVIDER')
if (isProviderId(fromEnv)) return fromEnv
return 'gemini'
}
function str(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function env(name: string): string {
return (process.env[name] ?? '').trim()
}
+93
View File
@@ -0,0 +1,93 @@
/**
* 녹음 중 들어오는 PCM 조각을 세션별로 모아 둔다.
*
* 화자 분리는 녹음이 끝난 뒤 전체 오디오에 한 번 돌린다. 누적분을 매번
* 다시 보내면 1시간 회의에서 100MB가 넘는 요청이 되므로, 녹음 중에는
* 몇 초짜리 조각만 올리고 서버가 이어 붙인다.
*
* 단일 사용자 로컬 실행을 전제로 한 메모리 저장소다. 여러 사용자가 붙는
* 환경이라면 디스크나 외부 저장소로 옮겨야 한다.
*/
export const SAMPLE_RATE = 16_000
/** 세션당 최대 녹음 길이. 초과분은 거부한다. */
export const MAX_SESSION_SECONDS = 3 * 60 * 60
/** 마지막 갱신 이후 이 시간이 지난 세션은 정리한다. */
const SESSION_TTL_MS = 6 * 60 * 60 * 1000
interface Session {
chunks: Int16Array[]
totalSamples: number
updatedAt: number
}
const sessions = new Map<string, Session>()
function sweep(): void {
const cutoff = Date.now() - SESSION_TTL_MS
for (const [id, session] of sessions) {
if (session.updatedAt < cutoff) sessions.delete(id)
}
}
export type AppendResult =
| { ok: true; totalSamples: number; seconds: number }
| { ok: false; error: string }
export function appendAudio(sessionId: string, pcm: Int16Array): AppendResult {
sweep()
const session = sessions.get(sessionId) ?? {
chunks: [],
totalSamples: 0,
updatedAt: Date.now(),
}
const nextTotal = session.totalSamples + pcm.length
if (nextTotal > MAX_SESSION_SECONDS * SAMPLE_RATE) {
return {
ok: false,
error: `녹음이 최대 길이(${MAX_SESSION_SECONDS / 3600}시간)를 초과했습니다.`,
}
}
session.chunks.push(pcm)
session.totalSamples = nextTotal
session.updatedAt = Date.now()
sessions.set(sessionId, session)
return {
ok: true,
totalSamples: nextTotal,
seconds: nextTotal / SAMPLE_RATE,
}
}
/** 누적된 PCM을 모델이 받는 [-1, 1] float32로 펼친다. */
export function collectSamples(sessionId: string): Float32Array | null {
const session = sessions.get(sessionId)
if (!session || session.totalSamples === 0) return null
const out = new Float32Array(session.totalSamples)
let offset = 0
for (const chunk of session.chunks) {
for (let i = 0; i < chunk.length; i++) {
out[offset + i] = chunk[i] / 32768
}
offset += chunk.length
}
return out
}
export function clearSession(sessionId: string): void {
sessions.delete(sessionId)
}
export function sessionSeconds(sessionId: string): number {
const session = sessions.get(sessionId)
return session ? session.totalSamples / SAMPLE_RATE : 0
}
+135
View File
@@ -0,0 +1,135 @@
import path from 'node:path'
import { existsSync } from 'node:fs'
/**
* 로컬 화자 분리 (speaker diarization).
*
* pyannote-segmentation-3.0 + 3D-Speaker 임베딩을 ONNX Runtime으로 돌린다.
* GPU도 파이썬 런타임도 필요 없고, 노트북 CPU에서 실시간의 수십 배 속도로 처리된다.
*
* 마이크 하나에 여러 사람이 섞여 들어오는 대면 회의용 경로다. 원격 회의처럼
* 트랙이 이미 나뉘어 있으면 이 모듈을 쓰지 않는 편이 정확하다.
*/
export interface SpeakerSegment {
/** 발화 시작 (초) */
start: number
/** 발화 종료 (초) */
end: number
/** 0부터 시작하는 화자 번호 */
speaker: number
}
export type DiarizationResult =
| { success: true; segments: SpeakerSegment[] }
| { success: false; error: string; reason: 'models-missing' | 'failed' }
export interface DiarizeOptions {
/**
* 참석자 수. 알면 반드시 넘길 것.
*
* 자동 추정은 화자 수를 과다·과소 계수하는 것이 주 실패 모드다.
* 실측에서 4인 오디오가 자동 모드로는 5명, 힌트를 주면 4명으로 나왔다.
*/
numSpeakers?: number
/** 자동 추정 시 클러스터 분리 임계값. 낮을수록 화자를 많이 잡는다. */
threshold?: number
}
const MODEL_DIR = process.env.DIARIZATION_MODEL_DIR ?? 'models/diarization'
const SEGMENTATION_MODEL = 'segmentation.onnx'
const EMBEDDING_MODEL = 'embedding.onnx'
export function resolveModelPaths(): {
segmentation: string
embedding: string
} {
const root = path.isAbsolute(MODEL_DIR)
? MODEL_DIR
: path.join(process.cwd(), MODEL_DIR)
return {
segmentation: path.join(root, SEGMENTATION_MODEL),
embedding: path.join(root, EMBEDDING_MODEL),
}
}
export function modelsInstalled(): boolean {
const { segmentation, embedding } = resolveModelPaths()
return existsSync(segmentation) && existsSync(embedding)
}
/**
* 모델 로딩이 수백 ms 걸리므로 프로세스당 한 번만 만들고 재사용한다.
* 클러스터링 설정은 요청마다 바뀔 수 있어 setConfig로 갈아끼운다.
*/
type Diarizer = {
process: (samples: Float32Array) => SpeakerSegment[]
setConfig: (config: {
clustering: { numClusters?: number; threshold?: number }
}) => void
}
let cached: Diarizer | null = null
async function getDiarizer(): Promise<Diarizer> {
if (cached) return cached
const { segmentation, embedding } = resolveModelPaths()
const { OfflineSpeakerDiarization } = await import('sherpa-onnx-node')
cached = new OfflineSpeakerDiarization({
segmentation: {
pyannote: { model: segmentation },
numThreads: 2,
},
embedding: { model: embedding, numThreads: 2 },
clustering: { threshold: 0.5 },
}) as unknown as Diarizer
return cached
}
export async function diarize(
samples: Float32Array,
options: DiarizeOptions = {},
): Promise<DiarizationResult> {
if (samples.length === 0) {
return { success: true, segments: [] }
}
if (!modelsInstalled()) {
return {
success: false,
reason: 'models-missing',
error:
'화자 분리 모델이 설치되지 않았습니다. `npm run setup:diarization`을 실행해주세요.',
}
}
try {
const diarizer = await getDiarizer()
// 참석자 수를 알면 클러스터 개수를 고정한다. 정확도가 크게 오른다.
diarizer.setConfig({
clustering:
options.numSpeakers && options.numSpeakers > 0
? { numClusters: options.numSpeakers }
: { threshold: options.threshold ?? 0.5 },
})
const segments = diarizer.process(samples)
return {
success: true,
segments: segments.map((s) => ({
start: s.start,
end: s.end,
speaker: s.speaker,
})),
}
} catch (err) {
const message = err instanceof Error ? err.message : '알 수 없는 오류'
return { success: false, reason: 'failed', error: `화자 분리 실패: ${message}` }
}
}
+77 -39
View File
@@ -1,3 +1,4 @@
import { complete, toProviderSettings, type ProviderSettings } from './providers'
import { import {
buildPrompt, buildPrompt,
resolveDepth, resolveDepth,
@@ -10,7 +11,17 @@ type LiveSummaryResult =
| { success: false; error: string; rateLimited?: boolean } | { success: false; error: string; rateLimited?: boolean }
interface LiveSummaryOptions { interface LiveSummaryOptions {
apiKey: string /** 프로바이더 설정. 생략하면 apiKey로 Gemini를 호출한다. */
provider?: ProviderSettings
apiKey?: string
/**
* 직전 롤링 요약.
*
* 값이 있으면 증분 모드로 동작하며, 이때 `transcript` 인자는 전사 전체가 아니라
* **직전 요약 이후 새로 추가된 발화**만 담아야 한다. 호출당 토큰이 회의 길이와
* 무관하게 일정해진다.
*/
previousSummary?: string
template?: TemplateId template?: TemplateId
depth?: SummaryDepth depth?: SummaryDepth
customPrompt?: string customPrompt?: string
@@ -21,67 +32,94 @@ export async function generateLiveSummary(
transcript: string, transcript: string,
options: LiveSummaryOptions, options: LiveSummaryOptions,
): Promise<LiveSummaryResult> { ): Promise<LiveSummaryResult> {
const { apiKey, fetchFn = fetch } = options const { fetchFn } = options
const settings = toProviderSettings(options.provider, options.apiKey)
if (!apiKey || apiKey.trim().length === 0) {
return { success: false, error: 'Gemini API 키가 필요합니다.' }
}
const trimmed = transcript.trim() const trimmed = transcript.trim()
if (trimmed.length === 0) { if (trimmed.length === 0) {
return { success: false, error: '요약할 텍스트가 비어 있습니다.' } return { success: false, error: '요약할 텍스트가 비어 있습니다.' }
} }
const previous = options.previousSummary?.trim() ?? ''
const incremental = previous.length > 0
const templateId = options.template ?? 'meeting' const templateId = options.template ?? 'meeting'
const depth = resolveDepth(templateId, options.depth) const depth = resolveDepth(templateId, options.depth)
const prompt = buildPrompt({ const prompt = buildPrompt({
templateId, templateId,
depth, depth,
transcript: trimmed, transcript: incremental
? `[지금까지의 요약]\n${previous}\n\n[새로 추가된 발화]\n${trimmed}`
: trimmed,
live: true, live: true,
incremental,
customPrompt: options.customPrompt, customPrompt: options.customPrompt,
}) })
const url = const result = await complete(prompt, settings, { fetchFn })
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
try { if (!result.success) {
const response = await fetchFn(url, {
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 { return {
success: false, success: false,
error: 'Gemini 요청 한도 초과. 잠시 후 자동 재시도됩니다.', error: result.error,
rateLimited: true, ...(result.rateLimited ? { rateLimited: true } : {}),
}
}
return {
success: false,
error: `Gemini API 호출 실패: ${response.status}`,
} }
} }
const data = await response.json() if (result.text.trim().length === 0) {
const markdown: string =
data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''
if (markdown.trim().length === 0) {
return { success: false, error: '요약 결과가 비어 있습니다.' } return { success: false, error: '요약 결과가 비어 있습니다.' }
} }
return { success: true, markdown } return { success: true, markdown: result.text }
} catch (err) {
const message = err instanceof Error ? err.message : '알 수 없는 오류'
return { success: false, error: `실시간 요약 중 오류: ${message}` }
} }
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 }
} }
+31 -39
View File
@@ -1,3 +1,9 @@
import {
complete,
describeModel,
toProviderSettings,
type ProviderSettings,
} from './providers'
import { import {
buildPrompt, buildPrompt,
resolveDepth, resolveDepth,
@@ -14,12 +20,14 @@ export interface MinutesInput {
customPrompt?: string customPrompt?: string
} }
type GeminiResult = type AiMinutesResult =
| { success: true; markdown: string } | { success: true; markdown: string }
| { success: false; error: string } | { success: false; error: string; rateLimited?: boolean }
interface GeminiOptions { interface AiMinutesOptions {
apiKey: string /** 프로바이더 설정. 생략하면 apiKey로 Gemini를 호출한다. */
provider?: ProviderSettings
apiKey?: string
fetchFn?: typeof fetch fetchFn?: typeof fetch
} }
@@ -49,15 +57,12 @@ ${content}
` `
} }
export async function generateGeminiMinutes( export async function generateAiMinutes(
input: MinutesInput, input: MinutesInput,
options: GeminiOptions, options: AiMinutesOptions,
): Promise<GeminiResult> { ): Promise<AiMinutesResult> {
const { apiKey, fetchFn = fetch } = options const { fetchFn } = options
const settings = toProviderSettings(options.provider, options.apiKey)
if (!apiKey || apiKey.trim().length === 0) {
return { success: false, error: 'Gemini API 키가 필요합니다.' }
}
const templateId = input.template ?? 'meeting' const templateId = input.template ?? 'meeting'
const depth = resolveDepth(templateId, input.depth) const depth = resolveDepth(templateId, input.depth)
@@ -69,31 +74,20 @@ export async function generateGeminiMinutes(
customPrompt: input.customPrompt, customPrompt: input.customPrompt,
}) })
const url = const result = await complete(prompt, settings, { fetchFn })
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
try { if (!result.success) {
const response = await fetchFn(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
}),
})
if (!response.ok) {
return { return {
success: false, success: false,
error: `Gemini API 호출 실패: ${response.status} ${response.statusText}`, error: result.error,
...(result.rateLimited ? { rateLimited: true } : {}),
} }
} }
const data = await response.json() const generatedText = result.text.trim()
const generatedText = if (generatedText.length === 0) {
data?.candidates?.[0]?.content?.parts?.[0]?.text ?? '' return { success: false, error: '요약 결과가 비어 있습니다.' }
}
const dateStr = formatDate(input.date) const dateStr = formatDate(input.date)
const markdown = `# ${input.title} const markdown = `# ${input.title}
@@ -106,15 +100,13 @@ ${generatedText}
--- ---
*이 회의록은 Gemini AI를 활용하여 자동 생성되었습니다.* *이 회의록은 AI(${describeModel(settings)})를 활용하여 자동 생성되었습니다.*
` `
return { success: true, markdown } return { success: true, markdown }
} catch (err) {
const message = err instanceof Error ? err.message : '알 수 없는 오류'
return {
success: false,
error: `Gemini API 호출 중 오류 발생: ${message}`,
}
}
} }
/**
* @deprecated `generateAiMinutes`를 사용하세요. Gemini 전용 호출부 하위 호환용입니다.
*/
export const generateGeminiMinutes = generateAiMinutes
+76
View File
@@ -0,0 +1,76 @@
import type {
CompletionOptions,
CompletionResult,
ProviderSettings,
} 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}`
}
+43
View File
@@ -0,0 +1,43 @@
import { completeWithGemini, resolveGeminiModel } from './gemini'
import { completeWithOpenAICompatible } from './openai-compatible'
import type {
CompletionOptions,
CompletionResult,
ProviderSettings,
} from './types'
export * from './types'
export * from './presets'
export { DEFAULT_GEMINI_MODEL, resolveGeminiModel } from './gemini'
export { 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 ?? '' }
}
+112
View File
@@ -0,0 +1,112 @@
import type {
CompletionOptions,
CompletionResult,
ProviderSettings,
} 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}`
}
+98
View File
@@ -0,0 +1,98 @@
import { DEFAULT_GEMINI_MODEL } from './gemini'
import type { ProviderId } from './types'
export interface ProviderPreset {
id: string
label: string
provider: ProviderId
/** openai-compatible 프리셋의 기본 base URL. 사용자가 수정할 수 있다. */
baseUrl: string
defaultModel: string
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,
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',
description: 'OpenAI Chat Completions API를 사용합니다.',
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',
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',
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: '',
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]
)
}
+32
View File
@@ -0,0 +1,32 @@
/**
* 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
}
+55
View File
@@ -0,0 +1,55 @@
/**
* 화자 식별자.
*
* dual-stream 캡처에서는 트랙이 곧 화자이므로 추론이 개입하지 않는다.
* 단일 트랙에서 화자를 추정하는 경로가 생기면 'spk_1' 같은 식별자가 추가된다.
*/
export const LOCAL_SPEAKER = 'local'
export const REMOTE_SPEAKER = 'remote'
export type SpeakerNames = Readonly<Record<string, string>>
/** diarization이 매긴 화자 번호를 식별자로 바꾼다. 0-based → 1-based. */
export function diarizedSpeakerId(index: number): string {
return `spk_${index + 1}`
}
const DEFAULT_NAMES: SpeakerNames = {
[LOCAL_SPEAKER]: '나',
[REMOTE_SPEAKER]: '상대',
}
/** 화자 식별자를 표시용 이름으로 바꾼다. 사용자가 지정한 이름이 우선한다. */
export function resolveSpeakerName(
speaker: string,
names?: SpeakerNames,
): string {
const custom = names?.[speaker]?.trim()
if (custom) return custom
const preset = DEFAULT_NAMES[speaker]
if (preset) return preset
// spk_1 → "화자 1"
const diarized = /^spk_(\d+)$/.exec(speaker)
if (diarized) return `화자 ${diarized[1]}`
return speaker
}
/** 회의록에 저장할 참석자 목록. 지정된 이름이 없으면 기본 표기를 쓴다. */
export function listAttendees(
speakers: readonly string[],
names?: SpeakerNames,
): string[] {
const seen = new Set<string>()
const result: string[] = []
for (const speaker of speakers) {
if (seen.has(speaker)) continue
seen.add(speaker)
result.push(resolveSpeakerName(speaker, names))
}
return result
}
+69 -32
View File
@@ -34,20 +34,24 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
depthAdjustable: true, depthAdjustable: true,
basePrompt: `당신은 회의록 작성 전문가입니다. 아래 발화 내용을 분석하여 마크다운 회의록을 작성하세요. basePrompt: `당신은 회의록 작성 전문가입니다. 아래 발화 내용을 분석하여 마크다운 회의록을 작성하세요.
포함할 섹션:
## 요약 ## 요약
(3~5줄 핵심 내용) 회의 전체를 3~5줄로. 무엇을 논의했고 무엇이 정해졌는지.
## 주요 논의 사항 이어지는 본문은 **섹션 제목을 직접 지어서** 구성하세요:
1. 순번 매기기 - 실제로 논의된 주제를 스스로 파악해 \`##\` 제목을 붙여 나눕니다.
- "주요 논의 사항", "논의 내용" 같은 일반적인 제목은 쓰지 마세요.
이 회의가 무엇을 다뤘는지 제목만 봐도 알 수 있어야 합니다.
(예: "단계별 개발 계획", "수익화 방안", "기술적 고려사항")
- 주제 수는 내용에 따라 정하되 보통 3~6개입니다.
## 액션 아이템 ## 액션 아이템
- [ ] 담당자와 기한이 명확한 항목만 반드시 \`- [ ]\` 체크박스 형식으로 작성하세요.
담당자가 파악되면 \`(담당자)\`를 앞에, 기한이 언급됐으면 뒤에 덧붙입니다.
예: \`- [ ] (김지훈) 경쟁 사이트 리스트업 — 5/17까지\`
담당자가 불분명해도 해야 할 일이 명확하면 포함하세요.
## 결정 사항 ## 결정 사항
- 합의 또는 확정된 것만 - 합의되었거나 확정된 것만. 없으면 이 섹션을 생략하세요.`,
한국어로 작성하세요.`,
}, },
lecture: { lecture: {
@@ -60,20 +64,18 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
depthAdjustable: true, depthAdjustable: true,
basePrompt: `당신은 학습용 노트를 작성하는 전문가입니다. 강의/세미나 녹취를 구조화된 학습 자료로 정리하세요. basePrompt: `당신은 학습용 노트를 작성하는 전문가입니다. 강의/세미나 녹취를 구조화된 학습 자료로 정리하세요.
포함할 섹션:
## 주요 주제 ## 주요 주제
(한 줄 요약) (한 줄 요약)
## 핵심 개념 ## 핵심 개념
각 개념마다 ### 소제목 + 설명 + 구체 예시 각 개념마다 ### 소제목 + 설명 + 구체 예시
소제목은 강의에서 실제로 다룬 개념 이름을 그대로 쓰세요.
## 기억할 인용·예시 ## 기억할 인용·예시
> 중요한 문장 인용 형식으로 > 중요한 문장 인용 형식으로
## 후속 질문 ## 후속 질문
- 스스로 탐구해볼 만한 질문 - 스스로 탐구해볼 만한 질문`,
한국어로 작성하세요.`,
}, },
one_on_one: { one_on_one: {
@@ -86,12 +88,11 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
depthAdjustable: true, depthAdjustable: true,
basePrompt: `당신은 1:1 미팅 정리 전문가입니다. 개인적이고 신뢰 기반의 대화임을 존중하며 정리하세요. basePrompt: `당신은 1:1 미팅 정리 전문가입니다. 개인적이고 신뢰 기반의 대화임을 존중하며 정리하세요.
포함할 섹션:
## 이번 세션 요지 ## 이번 세션 요지
(2~3줄) (2~3줄)
## 논의한 주제 이어서 대화에서 실제로 다룬 주제마다 **\`##\` 제목을 직접 지어** 나누세요.
- 주요 대화 흐름 "논의한 주제" 같은 일반적인 제목 대신, 무엇에 대한 이야기였는지 드러내는 제목을 쓰세요.
## 고민·블로커 ## 고민·블로커
- 공유된 어려움 - 공유된 어려움
@@ -100,9 +101,7 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
- 건설적 피드백 위주 - 건설적 피드백 위주
## 다음 미팅까지 할 일 ## 다음 미팅까지 할 일
- [ ] 합의된 후속 조치 - [ ] 합의된 후속 조치. 담당자가 있으면 \`(담당자)\` 표기`,
한국어로 작성하세요.`,
}, },
brainstorm: { brainstorm: {
@@ -115,12 +114,12 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
depthAdjustable: true, depthAdjustable: true,
basePrompt: `당신은 브레인스토밍 세션을 정리하는 전문가입니다. 나온 아이디어를 분류하고 실행 가능성 관점에서 정리하세요. basePrompt: `당신은 브레인스토밍 세션을 정리하는 전문가입니다. 나온 아이디어를 분류하고 실행 가능성 관점에서 정리하세요.
포함할 섹션:
## 세션 개요 ## 세션 개요
(1~2줄 — 주제/목표) (1~2줄 — 주제/목표)
## 아이디어 (카테고리별) ## 아이디어 (카테고리별)
### [카테고리 이름] ### [카테고리 이름]
카테고리는 미리 정해진 목록이 아니라, 나온 아이디어를 보고 직접 묶어서 이름 붙이세요.
- 아이디어 요약 - 아이디어 요약
## 즉시 시도 가능 ## 즉시 시도 가능
@@ -130,9 +129,7 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
- 사유 간단히 - 사유 간단히
## 다음 스텝 ## 다음 스텝
- 구체적인 후속 행동 - [ ] 구체적인 후속 행동. 담당자가 있으면 \`(담당자)\` 표기`,
한국어로 작성하세요.`,
}, },
interview: { interview: {
@@ -145,7 +142,6 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
depthAdjustable: true, depthAdjustable: true,
basePrompt: `당신은 인터뷰 녹취를 Q&A 형식으로 정리하는 전문가입니다. basePrompt: `당신은 인터뷰 녹취를 Q&A 형식으로 정리하는 전문가입니다.
포함할 섹션:
## 개요 ## 개요
(인터뷰 대상/주제 1~2줄) (인터뷰 대상/주제 1~2줄)
@@ -159,9 +155,7 @@ A: [답변 요약]
> 직접 인용 > 직접 인용
## 종합 인상 ## 종합 인상
- 전반적 테마 / 놓치지 말 것 - 전반적 테마 / 놓치지 말 것`,
한국어로 작성하세요.`,
}, },
raw: { raw: {
@@ -181,8 +175,7 @@ A: [답변 요약]
엄격한 규칙: 엄격한 규칙:
- 내용을 삭제하거나 축약하지 마세요 - 내용을 삭제하거나 축약하지 마세요
- 구조적 제목(## 섹션)을 덧붙이지 마세요 - 구조적 제목(## 섹션)을 덧붙이지 마세요
- 타임스탬프가 있으면 그대로 유지하세요 - 타임스탬프가 있으면 그대로 유지하세요`,
- 한국어로 작성하세요.`,
}, },
} }
@@ -195,13 +188,41 @@ const DEPTH_MODIFIERS: Record<SummaryDepth, string> = {
'작성 강도: **상세**. 원문의 주요 세부사항·수치·고유명사를 누락 없이 포함하세요. 구조만 바꾸고 내용은 최대한 보존합니다.', '작성 강도: **상세**. 원문의 주요 세부사항·수치·고유명사를 누락 없이 포함하세요. 구조만 바꾸고 내용은 최대한 보존합니다.',
} }
const LIVE_MODIFIER = `회의가 아직 진행 중입니다. 지금까지의 내용을 기반으로 **중간 정리**를 작성하세요. 확정되지 않은 결정은 "(논의 중)"으로 표시하세요.` const LIVE_MODIFIER = `회의가 아직 진행 중입니다. 지금까지의 내용을 기반으로 **중간 정리**를 작성하세요. 확정되지 않은 결정은 "(논의 중)"으로 표시하세요.
섹션 제목은 지금까지 나온 주제를 기준으로 붙이되, 이후 갱신될 수 있으므로 간결하게 유지하세요.`
/**
* 프리셋 템플릿 전체에 공통으로 덧붙는 규칙.
*
* custom 프롬프트에는 적용하지 않는다 — 사용자가 전적으로 제어하는 영역이기 때문.
*/
const COMMON_RULES = `공통 규칙:
- 발화에 없는 내용을 지어내지 마세요. 불확실하면 쓰지 마세요.
- 발화자 표기가 있다면 누가 무엇을 주장하고 약속했는지 반영하세요.
- 영어 기술 용어·제품명은 번역하거나 음차하지 말고 원문 표기를 유지하세요 (예: latency, deployment).
- 한국어로 작성하세요.`
/**
* 증분 갱신 모드.
*
* 전사 전체를 매번 다시 보내는 대신 [지금까지의 요약] + [새로 추가된 발화]만 보낸다.
* 호출당 토큰이 회의 길이와 무관하게 일정해져, 비용이 제곱이 아닌 선형으로 늘어난다.
*/
const INCREMENTAL_MODIFIER = `아래에는 [지금까지의 요약]과 [새로 추가된 발화]가 주어집니다.
기존 요약을 처음부터 다시 쓰지 말고 **갱신**하세요:
- 기존 요약의 내용과 구조를 유지한 채 새 발화를 반영합니다.
- 기존 항목이 새 발화로 확정되거나 번복되었다면 그 항목을 고치세요.
- 새 발화에 언급되지 않았다는 이유로 기존 내용을 삭제하지 마세요.
- 출력은 항상 갱신된 회의록 **전체**입니다. 변경분만 출력하지 마세요.`
export interface BuildPromptArgs { export interface BuildPromptArgs {
templateId: TemplateId templateId: TemplateId
depth: SummaryDepth depth: SummaryDepth
/** 증분 모드에서는 [지금까지의 요약] + [새로 추가된 발화]를 담은 블록이 들어온다. */
transcript: string transcript: string
live?: boolean live?: boolean
/** 직전 요약을 갱신하는 모드. live와 함께 쓴다. */
incremental?: boolean
customPrompt?: string customPrompt?: string
} }
@@ -222,14 +243,21 @@ export function resolveDepth(
} }
export function buildPrompt(args: BuildPromptArgs): string { export function buildPrompt(args: BuildPromptArgs): string {
const { templateId, depth, transcript, live = false, customPrompt } = args const {
templateId,
depth,
transcript,
live = false,
incremental = false,
customPrompt,
} = args
let instruction: string let instruction: string
if (templateId === 'custom') { if (templateId === 'custom') {
const custom = customPrompt?.trim() const custom = customPrompt?.trim()
if (!custom) { if (!custom) {
instruction = TEMPLATES.meeting.basePrompt instruction = `${TEMPLATES.meeting.basePrompt}\n\n${COMMON_RULES}`
} else { } else {
instruction = custom instruction = custom
} }
@@ -243,12 +271,21 @@ export function buildPrompt(args: BuildPromptArgs): string {
if (live && template.liveSupported) { if (live && template.liveSupported) {
parts.push(LIVE_MODIFIER) parts.push(LIVE_MODIFIER)
if (incremental) {
parts.push(INCREMENTAL_MODIFIER)
} }
}
parts.push(COMMON_RULES)
instruction = parts.join('\n\n') instruction = parts.join('\n\n')
} }
return `${instruction}\n\n---\n음성 인식 텍스트:\n${transcript}` // 증분 모드의 transcript는 자체 라벨([지금까지의 요약] 등)을 이미 포함한다.
const body = incremental ? transcript : `음성 인식 텍스트:\n${transcript}`
return `${instruction}\n\n---\n${body}`
} }
export function liveEnabledFor(templateId: TemplateId): boolean { export function liveEnabledFor(templateId: TemplateId): boolean {
+102 -2
View File
@@ -1,8 +1,12 @@
import { resolveSpeakerName, type SpeakerNames } from './speakers'
export interface TranscriptChunk { export interface TranscriptChunk {
text: string text: string
startTime: number startTime: number
endTime: number endTime: number
isFinal: boolean isFinal: boolean
/** 화자 식별자. 단일 트랙 녹음에서는 없다. */
speaker?: string
} }
function formatTimestamp(seconds: number): string { function formatTimestamp(seconds: number): string {
@@ -16,14 +20,42 @@ function formatTimestamp(seconds: number): string {
return `[${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}]` return `[${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}]`
} }
export function formatTranscriptChunks(chunks: readonly TranscriptChunk[]): string { export function formatTranscriptChunks(
chunks: readonly TranscriptChunk[],
names?: SpeakerNames,
): string {
if (chunks.length === 0) return '' if (chunks.length === 0) return ''
return chunks return chunks
.map((chunk) => `${formatTimestamp(chunk.startTime)} ${chunk.text}`) .map((chunk) => {
const stamp = formatTimestamp(chunk.startTime)
if (!chunk.speaker) return `${stamp} ${chunk.text}`
return `${stamp} ${resolveSpeakerName(chunk.speaker, names)}: ${chunk.text}`
})
.join('\n') .join('\n')
} }
/**
* 여러 트랙에서 나온 청크를 하나의 시간축으로 합친다.
*
* 각 인식기가 독립적으로 동작하므로 도착 순서가 발화 순서와 다를 수 있다.
* 모든 트랙이 같은 timeOrigin을 공유한다는 전제 아래 startTime으로 정렬한다.
*/
export function mergeSpeakerChunks(
...streams: readonly (readonly TranscriptChunk[])[]
): TranscriptChunk[] {
const merged = streams.flat()
return merged
.map((chunk, index) => ({ chunk, index }))
.sort((a, b) => {
const diff = a.chunk.startTime - b.chunk.startTime
// 같은 시각이면 원래 순서를 유지해 결과가 흔들리지 않게 한다.
return diff !== 0 ? diff : a.index - b.index
})
.map(({ chunk }) => chunk)
}
export function mergeAdjacentChunks( export function mergeAdjacentChunks(
chunks: readonly TranscriptChunk[], chunks: readonly TranscriptChunk[],
gapThreshold: number, gapThreshold: number,
@@ -36,12 +68,19 @@ export function mergeAdjacentChunks(
const current = chunks[i] const current = chunks[i]
const last = result[result.length - 1] const last = result[result.length - 1]
// 화자가 다르면 붙이지 않는다 — 서로 다른 사람의 발화가 한 덩어리가 되면 안 된다.
if (current.speaker !== last.speaker) {
result.push({ ...current })
continue
}
if (current.startTime - last.endTime <= gapThreshold) { if (current.startTime - last.endTime <= gapThreshold) {
result[result.length - 1] = { result[result.length - 1] = {
text: `${last.text} ${current.text}`, text: `${last.text} ${current.text}`,
startTime: last.startTime, startTime: last.startTime,
endTime: current.endTime, endTime: current.endTime,
isFinal: current.isFinal, isFinal: current.isFinal,
...(last.speaker ? { speaker: last.speaker } : {}),
} }
} else { } else {
result.push({ ...current }) result.push({ ...current })
@@ -50,3 +89,64 @@ export function mergeAdjacentChunks(
return result return result
} }
export interface TimeSpan {
start: number
end: number
speaker: number
}
function overlap(
aStart: number,
aEnd: number,
bStart: number,
bEnd: number,
): number {
return Math.max(0, Math.min(aEnd, bEnd) - Math.max(aStart, bStart))
}
/**
* diarization 결과를 전사 청크에 붙인다.
*
* 청크마다 시간이 가장 많이 겹치는 화자 구간을 고른다. 겹치는 구간이 없으면
* 화자를 비워 둔다 — 틀린 화자를 붙이는 것보다 없는 편이 낫다.
*
* Web Speech API가 주는 청크 타임스탬프는 근사값이므로 이 매칭도 근사다.
*/
export function assignSpeakers(
chunks: readonly TranscriptChunk[],
segments: readonly TimeSpan[],
speakerId: (index: number) => string,
): TranscriptChunk[] {
if (segments.length === 0) return chunks.map((chunk) => ({ ...chunk }))
return chunks.map((chunk) => {
let best: TimeSpan | null = null
let bestOverlap = 0
for (const segment of segments) {
const value = overlap(
chunk.startTime,
chunk.endTime,
segment.start,
segment.end,
)
if (value > bestOverlap) {
bestOverlap = value
best = segment
}
}
if (!best) {
// 겹치는 구간이 없으면 이전 라벨도 남기지 않는다.
return {
text: chunk.text,
startTime: chunk.startTime,
endTime: chunk.endTime,
isFinal: chunk.isFinal,
}
}
return { ...chunk, speaker: speakerId(best.speaker) }
})
}
+36
View File
@@ -0,0 +1,36 @@
/**
* sherpa-onnx-node는 JSDoc만 제공하고 .d.ts를 배포하지 않는다.
* 이 프로젝트가 실제로 쓰는 화자 분리 API만 선언한다.
*/
declare module 'sherpa-onnx-node' {
export interface SherpaSpeakerSegment {
start: number
end: number
speaker: number
}
export interface FastClusteringConfig {
numClusters?: number
threshold?: number
}
export interface OfflineSpeakerDiarizationConfig {
segmentation?: {
pyannote?: { model?: string; windowShiftRatio?: number }
numThreads?: number
provider?: string
debug?: boolean | number
}
embedding?: { model?: string; numThreads?: number; provider?: string }
clustering?: FastClusteringConfig
minDurationOn?: number
minDurationOff?: number
}
export class OfflineSpeakerDiarization {
constructor(config: OfflineSpeakerDiarizationConfig)
readonly sampleRate: number
process(samples: Float32Array): SherpaSpeakerSegment[]
setConfig(config: { clustering: FastClusteringConfig }): void
}
}
+5 -1
View File
@@ -5,7 +5,11 @@ interface SpeechRecognition extends EventTarget {
onresult: ((event: SpeechRecognitionEvent) => void) | null onresult: ((event: SpeechRecognitionEvent) => void) | null
onend: (() => void) | null onend: (() => void) | null
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
start(): void /**
* audioTrack을 넘기면 기본 마이크 대신 그 트랙을 전사한다 (Chrome 한정).
* 트랙별로 인식기를 붙이면 화자가 트랙 번호로 확정된다.
*/
start(audioTrack?: MediaStreamTrack): void
stop(): void stop(): void
abort(): void abort(): void
} }