mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +09:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
874cf11a7a | ||
|
|
73d5790dcc | ||
|
|
4af0a03de5 | ||
|
|
a8cfab8a4a | ||
|
|
d2cb5b0009 | ||
|
|
9af00c3016 | ||
|
|
ecd3192b40 | ||
|
|
352ac2ffd1 |
No files matched your search
+22
-6
@@ -25,13 +25,29 @@ POSTGRES_PASSWORD=
|
||||
DATABASE_URL=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 선택 — Gemini API 키
|
||||
# 선택 — AI 프로바이더
|
||||
# -----------------------------------------------------------------------------
|
||||
# Gemini AI 요약 기능을 쓰려면 키가 필요합니다.
|
||||
# 두 가지 방법 중 선택:
|
||||
# (A) 웹 UI 방식 — 앱 기동 후 /settings에서 입력 (브라우저 LocalStorage 저장, 추천)
|
||||
# (B) 환경변수 방식 — 아래 값 채우면 모든 사용자가 공통으로 사용
|
||||
# AI 요약 기능을 쓰려면 프로바이더 설정이 필요합니다. 두 가지 방법 중 선택:
|
||||
# (A) 웹 UI 방식 — 앱 기동 후 /settings에서 선택·입력 (브라우저 LocalStorage, 추천)
|
||||
# (B) 환경변수 방식 — 아래 값을 채우면 모든 사용자가 공통으로 사용
|
||||
#
|
||||
# 설정이 없어도 "단순 변환" 모드는 정상 동작합니다.
|
||||
|
||||
# --- (기본) Google Gemini 직접 호출 ---
|
||||
# 무료 발급: https://aistudio.google.com/apikey
|
||||
# 키 없이도 "단순 변환" 모드는 정상 동작합니다.
|
||||
GEMINI_API_KEY=
|
||||
|
||||
# --- (선택) OpenAI 호환 엔드포인트 ---
|
||||
# OrcaRouter · OpenAI · Ollama · LM Studio · vLLM 등 OpenAI Chat Completions
|
||||
# 형식을 따르는 엔드포인트라면 무엇이든 연결됩니다.
|
||||
#
|
||||
# LLM_PROVIDER=openai-compatible 로 두었을 때만 아래 값들이 사용됩니다.
|
||||
# OpenAI : LLM_BASE_URL=https://api.openai.com/v1 LLM_MODEL=gpt-4o-mini
|
||||
# OrcaRouter : LLM_BASE_URL=https://api.orcarouter.ai/v1 LLM_MODEL=google/gemini-3.5-flash-lite
|
||||
# Ollama : LLM_BASE_URL=http://localhost:11434/v1 LLM_MODEL=llama3.1 (키 불필요)
|
||||
#
|
||||
# ⚠️ 중계 서비스를 쓰면 회의 전문이 그 회사 서버를 거칩니다. SECURITY.md 참고.
|
||||
# LLM_PROVIDER=
|
||||
# LLM_BASE_URL=
|
||||
# LLM_MODEL=
|
||||
# LLM_API_KEY=
|
||||
@@ -49,3 +49,9 @@ next-env.d.ts
|
||||
.env
|
||||
!.env.example
|
||||
|
||||
|
||||
# 화자 분리 모델 (npm run setup:diarization 으로 내려받음)
|
||||
models/
|
||||
|
||||
# 로컬 개발용 compose 오버라이드
|
||||
docker-compose.override.yml
|
||||
+8
-3
@@ -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
|
||||
WORKDIR /app
|
||||
@@ -15,8 +17,8 @@ RUN npm run build
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
RUN groupadd --system --gid 1001 nodejs \
|
||||
&& useradd --system --uid 1001 --gid nodejs nextjs
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
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
|
||||
|
||||
# sherpa-onnx의 .so는 표준 검색 경로에 없으므로 명시해준다.
|
||||
ENV LD_LIBRARY_PATH=/app/node_modules/sherpa-onnx-linux-arm64:/app/node_modules/sherpa-onnx-linux-x64
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
|
||||
@@ -24,11 +24,12 @@ docker compose up -d
|
||||
|
||||
- [✨ 주요 기능](#-주요-기능)
|
||||
- [🎤 실시간 녹음 & 전사](#-실시간-녹음--전사)
|
||||
- [🗣️ 화자 분리](#️-화자-분리)
|
||||
- [🤖 AI 회의록 (6 템플릿 × 3 강도)](#-ai-회의록-6-템플릿--3-강도)
|
||||
- [⚡ 실시간 롤링 요약](#-실시간-롤링-요약)
|
||||
- [💾 회의록 저장/조회/검색](#-회의록-저장조회검색)
|
||||
- [✅ 액션 아이템 자동 추출](#-액션-아이템-자동-추출)
|
||||
- [🔑 웹에서 API 키 설정](#-웹에서-api-키-설정)
|
||||
- [🔑 AI 프로바이더 & API 키 설정](#-ai-프로바이더--api-키-설정)
|
||||
- [📋 Google Docs 호환 복사](#-google-docs-호환-복사)
|
||||
- [🚀 시작하기](#-시작하기)
|
||||
- [Docker Compose (권장)](#docker-compose-권장)
|
||||
@@ -69,14 +70,25 @@ docker compose up -d
|
||||
|
||||
| 템플릿 | 생성되는 구조 | 기본 강도 | 라이브 요약 |
|
||||
|---|---|---|---|
|
||||
| 🗂️ 회의록 | 요약 / 논의 / 액션 / 결정 | 표준 | ✅ |
|
||||
| 🗂️ 회의록 | 요약 / **내용 기반 주제 섹션** / 액션 / 결정 | 표준 | ✅ |
|
||||
| 🎓 강의·세미나 노트 | 핵심 개념 / 예시 / 인용 / 후속 질문 | 상세 | ✅ |
|
||||
| 🤝 1:1 미팅 | 주제 / 고민 / 피드백 / 다음 액션 | 표준 | ✅ |
|
||||
| 🤝 1:1 미팅 | 요지 / **내용 기반 주제 섹션** / 고민 / 피드백 / 다음 액션 | 표준 | ✅ |
|
||||
| 💡 브레인스토밍 | 카테고리별 아이디어 / 즉시 시도 / 보류 | 상세 | ✅ |
|
||||
| 🎤 인터뷰 | Q&A 포맷 / 인상적 발언 / 종합 | 표준 | ❌ |
|
||||
| 📝 원문 정리 | 요약 없이 문단화·오탈자 정리만 | 상세 고정 | ❌ |
|
||||
| ⚙️ 커스텀 | 자유 프롬프트 입력 | - | ✅ |
|
||||
|
||||
**내용 기반 주제 섹션**
|
||||
|
||||
회의록·1:1 템플릿은 `## 주요 논의 사항` 같은 고정 제목을 쓰지 않습니다. 그 회의가 실제로 무엇을
|
||||
다뤘는지에 따라 AI가 섹션 제목을 직접 짓습니다 — 예를 들어 `## 단계별 개발 계획`,
|
||||
`## 수익화 방안`, `## 기술적 고려사항` 처럼. 제목만 훑어도 회의 내용이 파악됩니다.
|
||||
|
||||
**액션 아이템 담당자**
|
||||
|
||||
발화에서 담당자가 파악되면 `- [ ] (김지훈) 경쟁 사이트 리스트업 — 5/17까지` 형태로 이름과 기한이 붙습니다.
|
||||
담당자가 정해지지 않은 항목도 버리지 않고 그대로 수집합니다.
|
||||
|
||||
**강도 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가 그동안의 발화를 분석해 중간 회의록을 자동 갱신합니다.
|
||||
@@ -101,7 +149,7 @@ docker compose up -d
|
||||
- 429 쿼터 초과 시 60초 자동 쿨다운, UI에 카운트다운 노출
|
||||
- 일반 실패 시 지수 백오프 (15s → 30s → 최대 120s)
|
||||
|
||||
**비용 가드 (Gemini 2.5 Flash Lite 무료 등급 기준)**
|
||||
**비용 가드 (Gemini Flash Lite 무료 등급 기준)**
|
||||
- 15 RPM / 1000 RPD / 250K TPM
|
||||
- 30초 폴링 + 증분 게이트 → 1시간 회의 ≤ 60회 호출, 발화량 적으면 훨씬 적음
|
||||
- 개인 사용 시 일일 한도 도달 거의 불가
|
||||
@@ -154,9 +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에 저장되지 않음)
|
||||
@@ -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 표시)
|
||||
summarize-live → 503 + 안내
|
||||
@@ -175,12 +236,14 @@ docker compose up -d
|
||||
**기능**
|
||||
- 마스킹된 현재 키 표시 (`AIza••••XYZ12`)
|
||||
- 보이기/숨기기 토글
|
||||
- **🧪 테스트 호출** — 가벼운 Gemini 응답으로 즉시 키 검증
|
||||
- 현재 키 소스 배지 (`브라우저` / `환경변수` / `없음`)
|
||||
- **🧪 테스트 호출** — 가벼운 응답으로 즉시 설정 검증 (사용된 모델명 표시)
|
||||
- 현재 소스 배지 (`브라우저` / `환경변수` / `없음`)
|
||||
- 기존에 Gemini 키만 저장해둔 사용자는 **재입력 없이 그대로 동작** (자동 승계)
|
||||
|
||||
**보안**
|
||||
- HTTPS 권장 (LocalStorage는 동일 출처 정책 의존)
|
||||
- GET 응답에 절대 풀 키 노출 안 함
|
||||
- base URL은 `http`/`https`만 허용 — 자세한 내용은 [SECURITY.md](SECURITY.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -231,12 +294,13 @@ DATABASE_URL=postgresql://meetinguser:<위와_같은_비번>@localhost:5432/meet
|
||||
|
||||
> ⚠️ `POSTGRES_PASSWORD`를 비워두면 docker compose가 명시적 에러로 실패합니다. 보안을 위한 의도된 동작.
|
||||
|
||||
> 💡 **Gemini API 키는 두 가지 방법 중 선택**
|
||||
> 💡 **AI 프로바이더 설정은 두 가지 방법 중 선택**
|
||||
>
|
||||
> - **(A) 웹 UI** — 앱 기동 후 `/settings`에서 입력 (브라우저 LocalStorage, 추천)
|
||||
> - **(B) `.env` 환경변수** — `GEMINI_API_KEY=AIza...` 작성
|
||||
> - **(A) 웹 UI** — 앱 기동 후 `/settings`에서 프로바이더 선택 + 키 입력 (브라우저 LocalStorage, 추천)
|
||||
> - **(B) `.env` 환경변수** — `GEMINI_API_KEY=AIza...` 또는 `LLM_PROVIDER` / `LLM_BASE_URL` / `LLM_MODEL` / `LLM_API_KEY`
|
||||
>
|
||||
> [Google AI Studio](https://aistudio.google.com/apikey)에서 무료로 발급. 키 없이도 "단순 변환" 모드는 동작.
|
||||
> 기본값인 Gemini 키는 [Google AI Studio](https://aistudio.google.com/apikey)에서 무료로 발급.
|
||||
> 설정이 없어도 "단순 변환" 모드는 동작.
|
||||
|
||||
#### 3. DB 마이그레이션 (최초 1회)
|
||||
|
||||
@@ -304,11 +368,12 @@ npm run test:coverage # 커버리지 리포트
|
||||
|------|------|
|
||||
| 프레임워크 | Next.js 16 (App Router) + React 19 + TypeScript |
|
||||
| STT (실시간) | Web Speech API — Chrome 내장, 무료 |
|
||||
| AI 요약 | Gemini 2.5 Flash Lite (무료 등급 15 RPM / 1000 RPD) |
|
||||
| AI 요약 | Gemini 3.5 Flash Lite (기본) · OpenAI 호환 엔드포인트 선택 가능 |
|
||||
| DB | PostgreSQL 16 + Prisma 7 (driver adapter `@prisma/adapter-pg`) |
|
||||
| Markdown | `marked` + `isomorphic-dompurify` |
|
||||
| 스타일 | Tailwind CSS v4 |
|
||||
| 테스트 | Vitest 4 (jsdom, 102 tests) |
|
||||
| 화자 분리 | pyannote-segmentation-3.0 + CAM++ (ONNX Runtime, CPU) |
|
||||
| 테스트 | Vitest 4 (jsdom, 184 tests) |
|
||||
| 배포 | Docker Compose + standalone Next.js 빌드 |
|
||||
|
||||
---
|
||||
@@ -339,14 +404,20 @@ src/
|
||||
│ ├── page.tsx # 메인 (녹음/요약)
|
||||
│ └── layout.tsx
|
||||
├── lib/
|
||||
│ ├── providers/ # AI 프로바이더 어댑터
|
||||
│ │ ├── index.ts # complete() 디스패치
|
||||
│ │ ├── types.ts # ProviderSettings / CompletionResult
|
||||
│ │ ├── presets.ts # 설정 UI용 프리셋 목록
|
||||
│ │ ├── gemini.ts # Google Generative Language API
|
||||
│ │ └── openai-compatible.ts # OpenAI Chat Completions 호환
|
||||
│ ├── db.ts # Prisma 싱글톤 (pg adapter)
|
||||
│ ├── markdown.ts # marked + DOMPurify + Docs용 복사
|
||||
│ ├── action-items.ts # - [ ] 휴리스틱 파서
|
||||
│ ├── api-keys.ts # 서버: 요청 키 → env fallback
|
||||
│ ├── api-key-storage.ts # 클라: LocalStorage + 마스킹
|
||||
│ ├── api-keys.ts # 서버: 요청 설정 → env fallback
|
||||
│ ├── api-key-storage.ts # 클라: LocalStorage + 마스킹 + 프로바이더 설정
|
||||
│ ├── templates.ts # 템플릿 레지스트리 + 프롬프트 빌더
|
||||
│ ├── minutes-generator.ts # 최종 요약 생성
|
||||
│ ├── live-summary.ts # 롤링 요약 생성
|
||||
│ ├── minutes-generator.ts # 최종 요약 생성 (프로바이더 무관)
|
||||
│ ├── live-summary.ts # 롤링 요약 생성 (프로바이더 무관)
|
||||
│ ├── transcript-formatter.ts
|
||||
│ ├── audio-validation.ts
|
||||
│ ├── upload-handler.ts
|
||||
@@ -409,7 +480,7 @@ prisma/
|
||||
- 같은 머신을 다른 사람과 공유하지 않는 것을 가정
|
||||
- 회의 transcript / 회의록은 평문으로 PostgreSQL에 저장됨 (디스크 암호화는 호스트 OS에 위임)
|
||||
|
||||
### Gemini API 키
|
||||
### API 키
|
||||
- LocalStorage 저장 (브라우저 동일 출처 정책으로 보호)
|
||||
- 서버 DB에 저장되지 않음
|
||||
- 요청 시점에만 body로 전송, 일회성 사용
|
||||
@@ -417,10 +488,14 @@ prisma/
|
||||
|
||||
### 외부 데이터 송출
|
||||
- **Web Speech API** — Chrome이 마이크 오디오를 Google 서버로 전송하여 전사 (Chrome 자체 동작, 우리 서버 경유 X)
|
||||
- **Gemini API** — 사용자가 명시적으로 활성화한 경우에만 transcript를 Google 서버로 전송
|
||||
- **AI 요약 API** — 사용자가 명시적으로 활성화한 경우에만 transcript를 선택한 프로바이더로 전송
|
||||
- Gemini / OpenAI 직접 호출 → 해당 회사 서버 1곳
|
||||
- OrcaRouter 등 중계 라우터 → **라우터 운영사 + 실제 모델 제공사** 양쪽
|
||||
- 로컬 모델 (Ollama / LM Studio / vLLM) → **외부 전송 없음**
|
||||
- **그 외** — 외부 호출 없음 (텔레메트리 / 분석 도구 미설치)
|
||||
|
||||
> ⚠️ **회사 회의 등 민감 정보가 외부 클라우드(Google)로 송출되는 점에 유의.** 사내 컴플라이언스 정책 확인 후 사용 권장.
|
||||
> ⚠️ **회사 회의 등 민감 정보가 외부 클라우드로 송출되는 점에 유의.** 사내 컴플라이언스 정책 확인 후 사용 권장.
|
||||
> 외부 전송이 곤란하다면 `/settings`에서 **로컬 모델**을 선택하세요.
|
||||
|
||||
### 권장 배포 방식
|
||||
|
||||
|
||||
+45
-4
@@ -48,10 +48,11 @@
|
||||
|
||||
| 데이터 | 저장 위치 | 외부 송출 |
|
||||
|---|---|---|
|
||||
| 회의 transcript / 회의록 | PostgreSQL (`meetings.markdownMinutes`, `rawTranscript`) | Gemini AI 요약 활성화 시 transcript가 Google로 전송 |
|
||||
| 회의 transcript / 회의록 | PostgreSQL (`meetings.markdownMinutes`, `rawTranscript`) | AI 요약 활성화 시 transcript가 **선택한 프로바이더**로 전송 |
|
||||
| 액션 아이템 | PostgreSQL (`action_items`) | 외부 송출 없음 |
|
||||
| Gemini API 키 | 브라우저 LocalStorage 또는 `.env` | 요청 시 Google에만 전송 |
|
||||
| API 키 | 브라우저 LocalStorage 또는 `.env` | 요청 시 선택한 프로바이더에만 전송 |
|
||||
| 음성 데이터 | 메모리 (실시간), `/app/uploads` (파일 업로드) | **Chrome Web Speech API → Google 서버** ⚠️ |
|
||||
| 화자 분리용 PCM | 서버 프로세스 메모리 (녹음 중에만) | **외부 송출 없음** — 로컬에서 분석하고 분석 후 즉시 폐기 |
|
||||
|
||||
### ⚠️ 주의: Web Speech API의 음성 외부 전송
|
||||
|
||||
@@ -63,6 +64,45 @@ Chrome의 `SpeechRecognition` API는 **음성 데이터를 Google 서버로 전
|
||||
|
||||
이 경우 **파일 업로드 탭**도 같은 한계가 있으므로(현재 업로드 후 전사는 미구현, 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/settings/status`: 키 존재 여부만 응답 (값 노출 X)
|
||||
- `/api/settings/test`: 401/403/429 분류, detail 200자로 제한
|
||||
- `getStoredApiKey` GET 요청에 절대 포함 안 함 (body 전송만)
|
||||
- `/api/settings/test`: 401/403/429 분류, 업스트림 응답 본문을 그대로 돌려주지 않음
|
||||
- 키는 GET 요청에 절대 포함 안 함 (body 전송만)
|
||||
- `maskApiKey()`: UI에 표시 시 `AIza••••XYZ12` 형태로 마스킹
|
||||
- Gemini는 `x-goog-api-key` 헤더, OpenAI 호환은 `Authorization: Bearer` 헤더 — **키를 URL에 넣지 않음** (로그/리퍼러 유출 방지)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -43,8 +43,17 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:?required}:${POSTGRES_PASSWORD:?required}@db:5432/${POSTGRES_DB:?required}
|
||||
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:
|
||||
- uploads:/app/uploads
|
||||
# 화자 분리 모델 — 호스트에서 npm run setup:diarization 으로 받은 것을 읽기 전용으로 마운트.
|
||||
# 없으면 빈 디렉터리가 되고 대면 화자 분리만 비활성화된다 (앱은 정상 동작).
|
||||
- ./models:/app/models:ro
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -12,6 +12,13 @@ const securityHeaders = [
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
// sherpa-onnx-node는 네이티브 애드온이라 번들링하면 안 된다.
|
||||
serverExternalPackages: ["sherpa-onnx-node"],
|
||||
// 트레이싱은 .node 파일만 잡고 동반 공유 라이브러리(.so/.dylib)를 놓친다.
|
||||
// 플랫폼 패키지를 통째로 포함시켜야 standalone 빌드에서 로드된다.
|
||||
outputFileTracingIncludes: {
|
||||
"/api/diarize": ["./node_modules/sherpa-onnx-*/**"],
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
Generated
+94
-1
@@ -16,7 +16,8 @@
|
||||
"pg": "^8.13.1",
|
||||
"prisma": "^7.7.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"sherpa-onnx-node": "^1.13.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -8422,6 +8423,98 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
|
||||
+4
-2
@@ -9,7 +9,8 @@
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"setup:diarization": "node scripts/setup-diarization.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.7.0",
|
||||
@@ -20,7 +21,8 @@
|
||||
"pg": "^8.13.1",
|
||||
"prisma": "^7.7.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"sherpa-onnx-node": "^1.13.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
})
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
setStoredApiKey,
|
||||
clearStoredApiKey,
|
||||
maskApiKey,
|
||||
getStoredProviderConfig,
|
||||
setStoredProviderConfig,
|
||||
clearStoredProviderConfig,
|
||||
getProviderRequestPayload,
|
||||
} from '@/lib/api-key-storage'
|
||||
|
||||
describe('LocalStorage api key helpers', () => {
|
||||
@@ -48,3 +52,99 @@ describe('maskApiKey', () => {
|
||||
expect(maskApiKey('123456789')).toBe('1234••••6789')
|
||||
})
|
||||
})
|
||||
|
||||
describe('프로바이더 설정 저장', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('저장 후 동일한 설정을 돌려준다', () => {
|
||||
setStoredProviderConfig({
|
||||
presetId: 'orcarouter',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.orcarouter.ai/v1',
|
||||
model: 'google/gemini-2.5-flash-lite',
|
||||
})
|
||||
|
||||
expect(getStoredProviderConfig()).toEqual({
|
||||
presetId: 'orcarouter',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.orcarouter.ai/v1',
|
||||
model: 'google/gemini-2.5-flash-lite',
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { resolveGeminiApiKey, isEnvKeyConfigured } from '@/lib/api-keys'
|
||||
import {
|
||||
resolveGeminiApiKey,
|
||||
isEnvKeyConfigured,
|
||||
isEnvProviderConfigured,
|
||||
resolveProviderSettings,
|
||||
} from '@/lib/api-keys'
|
||||
|
||||
describe('resolveGeminiApiKey', () => {
|
||||
const originalEnv = process.env.GEMINI_API_KEY
|
||||
@@ -70,3 +75,139 @@ describe('isEnvKeyConfigured', () => {
|
||||
expect(isEnvKeyConfigured()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveProviderSettings', () => {
|
||||
const ENV_KEYS = [
|
||||
'GEMINI_API_KEY',
|
||||
'LLM_PROVIDER',
|
||||
'LLM_BASE_URL',
|
||||
'LLM_API_KEY',
|
||||
'LLM_MODEL',
|
||||
] as const
|
||||
const saved: Record<string, string | undefined> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
saved[key] = process.env[key]
|
||||
delete process.env[key]
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (saved[key] === undefined) delete process.env[key]
|
||||
else process.env[key] = saved[key]
|
||||
}
|
||||
})
|
||||
|
||||
it('provider를 지정하지 않으면 gemini로 간주한다', () => {
|
||||
expect(resolveProviderSettings({ apiKey: 'req-key' })).toEqual({
|
||||
provider: 'gemini',
|
||||
apiKey: 'req-key',
|
||||
model: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('gemini인데 키가 아무데도 없으면 null (단순 변환 폴백)', () => {
|
||||
expect(resolveProviderSettings({})).toBeNull()
|
||||
expect(resolveProviderSettings(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('gemini 키는 요청 → GEMINI_API_KEY 순으로 해석한다', () => {
|
||||
process.env.GEMINI_API_KEY = 'env-key'
|
||||
expect(resolveProviderSettings({ apiKey: 'req-key' })?.apiKey).toBe('req-key')
|
||||
expect(resolveProviderSettings({})?.apiKey).toBe('env-key')
|
||||
})
|
||||
|
||||
it('openai-compatible은 baseUrl과 model이 모두 있어야 한다', () => {
|
||||
const base = { provider: 'openai-compatible', apiKey: 'sk-x' }
|
||||
expect(resolveProviderSettings(base)).toBeNull()
|
||||
expect(
|
||||
resolveProviderSettings({ ...base, baseUrl: 'https://a.example/v1' }),
|
||||
).toBeNull()
|
||||
expect(
|
||||
resolveProviderSettings({ ...base, model: 'gpt-4o-mini' }),
|
||||
).toBeNull()
|
||||
expect(
|
||||
resolveProviderSettings({
|
||||
...base,
|
||||
baseUrl: 'https://a.example/v1',
|
||||
model: 'gpt-4o-mini',
|
||||
}),
|
||||
).toEqual({
|
||||
provider: 'openai-compatible',
|
||||
apiKey: 'sk-x',
|
||||
baseUrl: 'https://a.example/v1',
|
||||
model: 'gpt-4o-mini',
|
||||
})
|
||||
})
|
||||
|
||||
it('openai-compatible은 키가 비어도 허용한다 (로컬 모델 서버)', () => {
|
||||
const settings = resolveProviderSettings({
|
||||
provider: 'openai-compatible',
|
||||
apiKey: '',
|
||||
baseUrl: 'http://localhost:11434/v1',
|
||||
model: 'llama3.1',
|
||||
})
|
||||
expect(settings?.apiKey).toBe('')
|
||||
})
|
||||
|
||||
it('LLM_* 환경변수만으로도 설정된다', () => {
|
||||
process.env.LLM_PROVIDER = 'openai-compatible'
|
||||
process.env.LLM_BASE_URL = 'https://api.orcarouter.ai/v1'
|
||||
process.env.LLM_MODEL = 'google/gemini-2.5-flash-lite'
|
||||
process.env.LLM_API_KEY = 'sk-env'
|
||||
|
||||
expect(resolveProviderSettings({})).toEqual({
|
||||
provider: 'openai-compatible',
|
||||
apiKey: 'sk-env',
|
||||
baseUrl: 'https://api.orcarouter.ai/v1',
|
||||
model: 'google/gemini-2.5-flash-lite',
|
||||
})
|
||||
})
|
||||
|
||||
it('알 수 없는 provider 값은 무시하고 기본값으로 떨어진다', () => {
|
||||
process.env.GEMINI_API_KEY = 'env-key'
|
||||
expect(resolveProviderSettings({ provider: 'anthropic' })?.provider).toBe(
|
||||
'gemini',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEnvProviderConfigured', () => {
|
||||
const ENV_KEYS = ['GEMINI_API_KEY', 'LLM_PROVIDER', 'LLM_BASE_URL', 'LLM_MODEL', 'LLM_API_KEY'] as const
|
||||
const saved: Record<string, string | undefined> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
saved[key] = process.env[key]
|
||||
delete process.env[key]
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (saved[key] === undefined) delete process.env[key]
|
||||
else process.env[key] = saved[key]
|
||||
}
|
||||
})
|
||||
|
||||
it('아무 것도 없으면 false', () => {
|
||||
expect(isEnvProviderConfigured()).toBe(false)
|
||||
})
|
||||
|
||||
it('GEMINI_API_KEY만 있어도 true', () => {
|
||||
process.env.GEMINI_API_KEY = 'k'
|
||||
expect(isEnvProviderConfigured()).toBe(true)
|
||||
})
|
||||
|
||||
it('openai-compatible은 base URL과 모델까지 있어야 true', () => {
|
||||
process.env.LLM_PROVIDER = 'openai-compatible'
|
||||
process.env.LLM_API_KEY = 'k'
|
||||
expect(isEnvProviderConfigured()).toBe(false)
|
||||
|
||||
process.env.LLM_BASE_URL = 'https://a.example/v1'
|
||||
process.env.LLM_MODEL = 'm'
|
||||
expect(isEnvProviderConfigured()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { generateLiveSummary } from '@/lib/live-summary'
|
||||
import {
|
||||
generateLiveSummary,
|
||||
planLiveSummaryRequest,
|
||||
} from '@/lib/live-summary'
|
||||
|
||||
describe('generateLiveSummary', () => {
|
||||
it('Gemini API 응답을 받아 중간 요약 마크다운을 반환한다', async () => {
|
||||
@@ -128,3 +131,158 @@ describe('generateLiveSummary', () => {
|
||||
expect(init.headers['x-goog-api-key']).toBe('secret-key')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateLiveSummary — openai-compatible', () => {
|
||||
it('OpenAI 호환 엔드포인트로 중간 요약을 만든다', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: '## 요약\n진행 중' } }],
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await generateLiveSummary('회의 내용', {
|
||||
provider: {
|
||||
provider: 'openai-compatible',
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'http://localhost:11434/v1',
|
||||
model: 'llama3.1',
|
||||
},
|
||||
fetchFn: mockFetch,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(mockFetch.mock.calls[0][0]).toBe(
|
||||
'http://localhost:11434/v1/chat/completions',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('planLiveSummaryRequest', () => {
|
||||
const base = {
|
||||
totalChunks: 50,
|
||||
lastSummarizedIndex: 30,
|
||||
incrementsSinceFull: 3,
|
||||
fullRefreshEvery: 20,
|
||||
hasPreviousSummary: true,
|
||||
}
|
||||
|
||||
it('평상시에는 직전 지점부터 증분으로 보낸다', () => {
|
||||
expect(planLiveSummaryRequest(base)).toEqual({
|
||||
mode: 'incremental',
|
||||
startIndex: 30,
|
||||
})
|
||||
})
|
||||
|
||||
it('첫 호출은 전체를 보낸다', () => {
|
||||
expect(
|
||||
planLiveSummaryRequest({ ...base, lastSummarizedIndex: 0 }),
|
||||
).toEqual({ mode: 'full', startIndex: 0 })
|
||||
})
|
||||
|
||||
it('갱신할 직전 요약이 없으면 전체를 보낸다', () => {
|
||||
expect(
|
||||
planLiveSummaryRequest({ ...base, hasPreviousSummary: false }),
|
||||
).toEqual({ mode: 'full', startIndex: 0 })
|
||||
})
|
||||
|
||||
it('증분이 누적되면 전체 재요약으로 오차를 끊는다', () => {
|
||||
expect(
|
||||
planLiveSummaryRequest({ ...base, incrementsSinceFull: 20 }),
|
||||
).toEqual({ mode: 'full', startIndex: 0 })
|
||||
})
|
||||
|
||||
it('fullRefreshEvery=0이면 전체 재요약을 하지 않는다', () => {
|
||||
expect(
|
||||
planLiveSummaryRequest({
|
||||
...base,
|
||||
fullRefreshEvery: 0,
|
||||
incrementsSinceFull: 999,
|
||||
}).mode,
|
||||
).toBe('incremental')
|
||||
})
|
||||
|
||||
it('전사가 초기화되어 인덱스가 범위를 벗어나면 전체를 보낸다', () => {
|
||||
expect(
|
||||
planLiveSummaryRequest({ ...base, totalChunks: 5 }),
|
||||
).toEqual({ mode: 'full', startIndex: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateLiveSummary — 증분 모드', () => {
|
||||
function mockOk(text = '## 요약\n갱신됨') {
|
||||
return vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
candidates: [{ content: { parts: [{ text }] } }],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function sentPrompt(mockFetch: ReturnType<typeof vi.fn>): string {
|
||||
const body = JSON.parse(mockFetch.mock.calls[0][1].body)
|
||||
return body.contents[0].parts[0].text
|
||||
}
|
||||
|
||||
it('previousSummary가 있으면 요약과 신규 발화를 나눠 전달한다', async () => {
|
||||
const mockFetch = mockOk()
|
||||
|
||||
await generateLiveSummary('새로 나온 이야기', {
|
||||
apiKey: 'k',
|
||||
previousSummary: '## 요약\n이전까지의 내용',
|
||||
fetchFn: mockFetch,
|
||||
})
|
||||
|
||||
const prompt = sentPrompt(mockFetch)
|
||||
expect(prompt).toContain('[지금까지의 요약]')
|
||||
expect(prompt).toContain('이전까지의 내용')
|
||||
expect(prompt).toContain('[새로 추가된 발화]')
|
||||
expect(prompt).toContain('새로 나온 이야기')
|
||||
expect(prompt).toContain('갱신')
|
||||
})
|
||||
|
||||
it('previousSummary가 없으면 기존 전체 요약 형식을 유지한다', async () => {
|
||||
const mockFetch = mockOk()
|
||||
|
||||
await generateLiveSummary('전체 전사', { apiKey: 'k', fetchFn: mockFetch })
|
||||
|
||||
const prompt = sentPrompt(mockFetch)
|
||||
expect(prompt).toContain('음성 인식 텍스트:')
|
||||
expect(prompt).not.toContain('[지금까지의 요약]')
|
||||
})
|
||||
|
||||
it('빈 문자열 previousSummary는 증분으로 취급하지 않는다', async () => {
|
||||
const mockFetch = mockOk()
|
||||
|
||||
await generateLiveSummary('전체 전사', {
|
||||
apiKey: 'k',
|
||||
previousSummary: ' ',
|
||||
fetchFn: mockFetch,
|
||||
})
|
||||
|
||||
expect(sentPrompt(mockFetch)).not.toContain('[지금까지의 요약]')
|
||||
})
|
||||
|
||||
it('긴 회의에서 증분 프롬프트가 전체 프롬프트보다 짧다', async () => {
|
||||
const longTranscript = '회의 발화 한 줄입니다.\n'.repeat(500)
|
||||
|
||||
const fullFetch = mockOk()
|
||||
await generateLiveSummary(longTranscript, {
|
||||
apiKey: 'k',
|
||||
fetchFn: fullFetch,
|
||||
})
|
||||
|
||||
const incFetch = mockOk()
|
||||
await generateLiveSummary('마지막 30초에 나온 이야기', {
|
||||
apiKey: 'k',
|
||||
previousSummary: '## 요약\n지금까지의 요약 본문',
|
||||
fetchFn: incFetch,
|
||||
})
|
||||
|
||||
expect(sentPrompt(incFetch).length).toBeLessThan(
|
||||
sentPrompt(fullFetch).length / 5,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import {
|
||||
generateSimpleMinutes,
|
||||
generateAiMinutes,
|
||||
generateGeminiMinutes,
|
||||
type MinutesInput,
|
||||
} from '@/lib/minutes-generator'
|
||||
@@ -105,3 +106,49 @@ describe('generateGeminiMinutes', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateAiMinutes — openai-compatible', () => {
|
||||
const settings = {
|
||||
provider: 'openai-compatible' as const,
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.orcarouter.ai/v1',
|
||||
model: 'google/gemini-2.5-flash-lite',
|
||||
}
|
||||
|
||||
it('OpenAI 호환 엔드포인트 응답으로 회의록을 만든다', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
choices: [{ message: { content: '## 요약\n- 리팩토링 완료' } }],
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await generateAiMinutes(sampleInput, {
|
||||
provider: settings,
|
||||
fetchFn: mockFetch,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.markdown).toContain('# 주간 스프린트 회의')
|
||||
expect(result.markdown).toContain('리팩토링 완료')
|
||||
// 하단 문구는 실제 사용한 모델을 표기한다
|
||||
expect(result.markdown).toContain('google/gemini-2.5-flash-lite')
|
||||
}
|
||||
})
|
||||
|
||||
it('응답이 비어 있으면 실패로 처리해 단순 변환 폴백을 유도한다', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ choices: [{ message: { content: ' ' } }] }),
|
||||
})
|
||||
|
||||
const result = await generateAiMinutes(sampleInput, {
|
||||
provider: settings,
|
||||
fetchFn: mockFetch,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import {
|
||||
complete,
|
||||
describeModel,
|
||||
normalizeBaseUrl,
|
||||
toProviderSettings,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
findPreset,
|
||||
PROVIDER_PRESETS,
|
||||
} from '@/lib/providers'
|
||||
|
||||
function okResponse(content: string) {
|
||||
return {
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ choices: [{ message: { content } }] }),
|
||||
}
|
||||
}
|
||||
|
||||
const OPENAI_SETTINGS = {
|
||||
provider: 'openai-compatible' as const,
|
||||
apiKey: 'sk-test',
|
||||
baseUrl: 'https://api.orcarouter.ai/v1',
|
||||
model: 'google/gemini-2.5-flash-lite',
|
||||
}
|
||||
|
||||
describe('normalizeBaseUrl', () => {
|
||||
it('끝의 슬래시를 제거한다', () => {
|
||||
expect(normalizeBaseUrl('https://api.example.com/v1/')).toBe(
|
||||
'https://api.example.com/v1',
|
||||
)
|
||||
})
|
||||
|
||||
it('앞뒤 공백을 제거한다', () => {
|
||||
expect(normalizeBaseUrl(' https://api.example.com/v1 ')).toBe(
|
||||
'https://api.example.com/v1',
|
||||
)
|
||||
})
|
||||
|
||||
it('http/https 이외의 스킴은 거부한다', () => {
|
||||
expect(normalizeBaseUrl('file:///etc/passwd')).toBeNull()
|
||||
expect(normalizeBaseUrl('ftp://example.com')).toBeNull()
|
||||
})
|
||||
|
||||
it('URL이 아니거나 비어 있으면 null', () => {
|
||||
expect(normalizeBaseUrl('not-a-url')).toBeNull()
|
||||
expect(normalizeBaseUrl(' ')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('complete — openai-compatible', () => {
|
||||
it('/chat/completions로 OpenAI 형식 요청을 보낸다', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(okResponse('## 요약'))
|
||||
|
||||
const result = await complete('프롬프트', OPENAI_SETTINGS, {
|
||||
fetchFn: mockFetch,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) expect(result.text).toBe('## 요약')
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[0]
|
||||
expect(url).toBe('https://api.orcarouter.ai/v1/chat/completions')
|
||||
expect(init.headers.Authorization).toBe('Bearer sk-test')
|
||||
|
||||
const body = JSON.parse(init.body)
|
||||
expect(body.model).toBe('google/gemini-2.5-flash-lite')
|
||||
expect(body.messages).toEqual([{ role: 'user', content: '프롬프트' }])
|
||||
})
|
||||
|
||||
it('키가 없으면 Authorization 헤더를 붙이지 않는다 (로컬 모델 서버)', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue(okResponse('요약'))
|
||||
|
||||
await complete(
|
||||
'프롬프트',
|
||||
{ ...OPENAI_SETTINGS, apiKey: '', baseUrl: 'http://localhost:11434/v1' },
|
||||
{ fetchFn: mockFetch },
|
||||
)
|
||||
|
||||
const [, init] = mockFetch.mock.calls[0]
|
||||
expect(init.headers.Authorization).toBeUndefined()
|
||||
})
|
||||
|
||||
it('base URL이 없으면 호출하지 않고 에러를 반환한다', async () => {
|
||||
const mockFetch = vi.fn()
|
||||
|
||||
const result = await complete(
|
||||
'프롬프트',
|
||||
{ ...OPENAI_SETTINGS, baseUrl: '' },
|
||||
{ fetchFn: mockFetch },
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('모델이 없으면 호출하지 않고 에러를 반환한다', async () => {
|
||||
const mockFetch = vi.fn()
|
||||
|
||||
const result = await complete(
|
||||
'프롬프트',
|
||||
{ ...OPENAI_SETTINGS, model: ' ' },
|
||||
{ fetchFn: mockFetch },
|
||||
)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) expect(result.error).toContain('모델')
|
||||
expect(mockFetch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('429는 rateLimited 플래그를 세운다', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 429 })
|
||||
|
||||
const result = await complete('프롬프트', OPENAI_SETTINGS, {
|
||||
fetchFn: mockFetch,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) expect(result.rateLimited).toBe(true)
|
||||
})
|
||||
|
||||
it('401은 인증 실패로 안내한다', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 401 })
|
||||
|
||||
const result = await complete('프롬프트', OPENAI_SETTINGS, {
|
||||
fetchFn: mockFetch,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) expect(result.error).toContain('인증')
|
||||
})
|
||||
|
||||
it('네트워크 예외를 결과 객체로 감싼다', async () => {
|
||||
const mockFetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))
|
||||
|
||||
const result = await complete('프롬프트', OPENAI_SETTINGS, {
|
||||
fetchFn: mockFetch,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
if (!result.success) expect(result.error).toContain('ECONNREFUSED')
|
||||
})
|
||||
})
|
||||
|
||||
describe('complete — gemini 라우팅', () => {
|
||||
it('provider가 gemini면 Google 엔드포인트를 호출한다', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
candidates: [{ content: { parts: [{ text: '요약' }] } }],
|
||||
}),
|
||||
})
|
||||
|
||||
await complete(
|
||||
'프롬프트',
|
||||
{ provider: 'gemini', apiKey: 'AIza-test' },
|
||||
{ fetchFn: mockFetch },
|
||||
)
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[0]
|
||||
expect(url).toContain('generativelanguage.googleapis.com')
|
||||
expect(url).toContain(DEFAULT_GEMINI_MODEL)
|
||||
expect(init.headers['x-goog-api-key']).toBe('AIza-test')
|
||||
})
|
||||
|
||||
it('모델을 지정하면 해당 모델 엔드포인트를 호출한다', async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
candidates: [{ content: { parts: [{ text: '요약' }] } }],
|
||||
}),
|
||||
})
|
||||
|
||||
await complete(
|
||||
'프롬프트',
|
||||
{ provider: 'gemini', apiKey: 'AIza-test', model: 'gemini-2.5-pro' },
|
||||
{ fetchFn: mockFetch },
|
||||
)
|
||||
|
||||
expect(mockFetch.mock.calls[0][0]).toContain('gemini-2.5-pro')
|
||||
})
|
||||
})
|
||||
|
||||
describe('toProviderSettings', () => {
|
||||
it('provider가 없으면 기존 Gemini 호출부와 동일하게 동작한다', () => {
|
||||
expect(toProviderSettings(undefined, 'legacy-key')).toEqual({
|
||||
provider: 'gemini',
|
||||
apiKey: 'legacy-key',
|
||||
})
|
||||
})
|
||||
|
||||
it('provider가 있으면 그대로 사용한다', () => {
|
||||
expect(toProviderSettings(OPENAI_SETTINGS, 'ignored')).toBe(OPENAI_SETTINGS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeModel', () => {
|
||||
it('gemini는 기본 모델명을 돌려준다', () => {
|
||||
expect(describeModel({ provider: 'gemini', apiKey: 'k' })).toBe(
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
)
|
||||
})
|
||||
|
||||
it('openai-compatible은 설정한 모델명을 돌려준다', () => {
|
||||
expect(describeModel(OPENAI_SETTINGS)).toBe('google/gemini-2.5-flash-lite')
|
||||
})
|
||||
})
|
||||
|
||||
describe('프리셋', () => {
|
||||
it('알 수 없는 id는 기본 프리셋으로 폴백한다', () => {
|
||||
expect(findPreset('없는-프리셋').id).toBe('gemini')
|
||||
expect(findPreset(null).id).toBe('gemini')
|
||||
})
|
||||
|
||||
it('openai-compatible 프리셋은 base URL 또는 직접 입력 안내를 갖는다', () => {
|
||||
for (const preset of PROVIDER_PRESETS) {
|
||||
if (preset.provider !== 'openai-compatible') continue
|
||||
expect(preset.id === 'custom' || preset.baseUrl.length > 0).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,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')
|
||||
})
|
||||
})
|
||||
@@ -164,3 +164,119 @@ describe('liveEnabledFor / depthAdjustableFor', () => {
|
||||
expect(depthAdjustableFor('custom')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('내용 기반 섹션 구조', () => {
|
||||
const transcript = '이번에는 가격 비교 사이트를 만들어 봅시다'
|
||||
|
||||
it('meeting은 섹션 제목을 직접 짓도록 지시한다', () => {
|
||||
const prompt = buildPrompt({
|
||||
templateId: 'meeting',
|
||||
depth: 'standard',
|
||||
transcript,
|
||||
})
|
||||
expect(prompt).toContain('섹션 제목을 직접 지어서')
|
||||
// 고정 제목을 쓰지 말라는 지시가 함께 있어야 한다
|
||||
expect(prompt).toContain('일반적인 제목은 쓰지 마세요')
|
||||
})
|
||||
|
||||
it('one_on_one도 주제별 제목을 직접 짓도록 지시한다', () => {
|
||||
const prompt = buildPrompt({
|
||||
templateId: 'one_on_one',
|
||||
depth: 'standard',
|
||||
transcript,
|
||||
})
|
||||
expect(prompt).toContain('제목을 직접 지어')
|
||||
})
|
||||
|
||||
it('raw는 구조적 제목을 덧붙이지 말라는 지시를 유지한다', () => {
|
||||
const prompt = buildPrompt({
|
||||
templateId: 'raw',
|
||||
depth: 'detailed',
|
||||
transcript,
|
||||
})
|
||||
expect(prompt).toContain('구조적 제목(## 섹션)을 덧붙이지 마세요')
|
||||
})
|
||||
})
|
||||
|
||||
describe('액션 아이템 담당자 표기', () => {
|
||||
const transcript = '다음 주까지 정리해 주세요'
|
||||
|
||||
it('담당자 표기를 요구한다', () => {
|
||||
const prompt = buildPrompt({
|
||||
templateId: 'meeting',
|
||||
depth: 'standard',
|
||||
transcript,
|
||||
})
|
||||
expect(prompt).toContain('(담당자)')
|
||||
})
|
||||
|
||||
it('담당자가 불분명해도 항목을 버리지 않도록 지시한다', () => {
|
||||
const prompt = buildPrompt({
|
||||
templateId: 'meeting',
|
||||
depth: 'standard',
|
||||
transcript,
|
||||
})
|
||||
expect(prompt).toContain('담당자가 불분명해도')
|
||||
})
|
||||
|
||||
it('체크박스 형식을 유지해 액션 아이템 파서와 호환된다', () => {
|
||||
for (const id of ['meeting', 'one_on_one', 'brainstorm'] as const) {
|
||||
const prompt = buildPrompt({ templateId: id, depth: 'standard', transcript })
|
||||
expect(prompt).toContain('- [ ]')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('공통 규칙', () => {
|
||||
const transcript = '테스트 발화'
|
||||
|
||||
it('프리셋 템플릿 전체에 공통 규칙이 붙는다', () => {
|
||||
for (const id of Object.keys(TEMPLATES) as (keyof typeof TEMPLATES)[]) {
|
||||
const prompt = buildPrompt({ templateId: id, depth: 'standard', transcript })
|
||||
expect(prompt).toContain('지어내지 마세요')
|
||||
expect(prompt).toContain('한국어로 작성하세요')
|
||||
}
|
||||
})
|
||||
|
||||
it('발화자 표기가 있으면 활용하도록 지시한다', () => {
|
||||
const prompt = buildPrompt({
|
||||
templateId: 'meeting',
|
||||
depth: 'standard',
|
||||
transcript,
|
||||
})
|
||||
expect(prompt).toContain('발화자 표기가 있다면')
|
||||
})
|
||||
|
||||
it('영어 기술 용어는 원문 표기를 유지하도록 지시한다', () => {
|
||||
const prompt = buildPrompt({
|
||||
templateId: 'meeting',
|
||||
depth: 'standard',
|
||||
transcript,
|
||||
})
|
||||
expect(prompt).toContain('원문 표기를 유지하세요')
|
||||
})
|
||||
|
||||
it('custom 프롬프트에는 공통 규칙을 덧붙이지 않는다', () => {
|
||||
const prompt = buildPrompt({
|
||||
templateId: 'custom',
|
||||
depth: 'standard',
|
||||
transcript,
|
||||
customPrompt: '한 줄로만 요약해줘',
|
||||
})
|
||||
expect(prompt).toContain('한 줄로만 요약해줘')
|
||||
expect(prompt).not.toContain('지어내지 마세요')
|
||||
})
|
||||
|
||||
it('custom 프롬프트가 비어 있으면 meeting 템플릿 + 공통 규칙으로 대체한다', () => {
|
||||
const prompt = buildPrompt({
|
||||
templateId: 'custom',
|
||||
depth: 'standard',
|
||||
transcript,
|
||||
customPrompt: ' ',
|
||||
})
|
||||
expect(prompt).toContain('회의록 작성 전문가')
|
||||
expect(prompt).toContain('지어내지 마세요')
|
||||
// 기존 동작 유지 — custom 경로에는 강도/라이브 모디파이어를 적용하지 않는다
|
||||
expect(prompt).not.toContain('작성 강도')
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
formatTranscriptChunks,
|
||||
mergeAdjacentChunks,
|
||||
mergeSpeakerChunks,
|
||||
assignSpeakers,
|
||||
type TranscriptChunk,
|
||||
} from '@/lib/transcript-formatter'
|
||||
|
||||
@@ -55,3 +57,141 @@ describe('mergeAdjacentChunks', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { isEnvKeyConfigured } from '@/lib/api-keys'
|
||||
import { isEnvKeyConfigured, isEnvProviderConfigured } from '@/lib/api-keys'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET() {
|
||||
return Response.json({
|
||||
envConfigured: isEnvKeyConfigured(),
|
||||
envProviderConfigured: isEnvProviderConfigured(),
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { resolveGeminiApiKey } from '@/lib/api-keys'
|
||||
import { resolveProviderSettings } from '@/lib/api-keys'
|
||||
import { complete, describeModel } from '@/lib/providers'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -8,60 +9,32 @@ const TEST_PROMPT = '"OK"라고만 한 단어로 답하세요.'
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const apiKey = resolveGeminiApiKey(body.apiKey)
|
||||
const settings = resolveProviderSettings(body)
|
||||
|
||||
if (!apiKey) {
|
||||
if (!settings) {
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
error: '키가 비어 있습니다.',
|
||||
error: '설정이 비어 있습니다. 키(또는 base URL과 모델)를 확인해주세요.',
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const url =
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
|
||||
const result = await complete(TEST_PROMPT, settings)
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-goog-api-key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: TEST_PROMPT }] }],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => '')
|
||||
const message =
|
||||
response.status === 400
|
||||
? '잘못된 API 키 형식입니다.'
|
||||
: response.status === 403
|
||||
? '권한이 거부되었습니다. 키를 확인해주세요.'
|
||||
: response.status === 429
|
||||
? '요청 한도를 초과했지만 키 자체는 유효해 보입니다.'
|
||||
: `Gemini API 오류: ${response.status}`
|
||||
if (!result.success) {
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
error: message,
|
||||
detail: errorText.slice(0, 200),
|
||||
},
|
||||
{ ok: false, error: result.error },
|
||||
{ status: 200 },
|
||||
)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const reply: string =
|
||||
data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? ''
|
||||
|
||||
return Response.json({
|
||||
ok: true,
|
||||
message: '키가 정상적으로 동작합니다.',
|
||||
reply: reply.slice(0, 50),
|
||||
message: '설정이 정상적으로 동작합니다.',
|
||||
model: describeModel(settings),
|
||||
reply: result.text.trim().slice(0, 50),
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { generateLiveSummary } from '@/lib/live-summary'
|
||||
import { resolveGeminiApiKey } from '@/lib/api-keys'
|
||||
import { resolveProviderSettings } from '@/lib/api-keys'
|
||||
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
||||
|
||||
const MAX_TRANSCRIPT_CHARS = 40_000
|
||||
const MAX_PREVIOUS_SUMMARY_CHARS = 8_000
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { transcript, template, depth, customPrompt, apiKey } = body
|
||||
const { transcript, template, depth, customPrompt, previousSummary } = body
|
||||
|
||||
if (!transcript || typeof transcript !== 'string') {
|
||||
return Response.json(
|
||||
@@ -17,12 +18,12 @@ export async function POST(request: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
const resolvedKey = resolveGeminiApiKey(apiKey)
|
||||
if (!resolvedKey) {
|
||||
const settings = resolveProviderSettings(body)
|
||||
if (!settings) {
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
'Gemini API 키가 설정되지 않았습니다. /settings에서 키를 입력해주세요.',
|
||||
'AI 프로바이더가 설정되지 않았습니다. /settings에서 키를 입력해주세요.',
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
@@ -33,8 +34,15 @@ export async function POST(request: NextRequest) {
|
||||
? transcript.slice(-MAX_TRANSCRIPT_CHARS)
|
||||
: transcript
|
||||
|
||||
// 증분 모드: 직전 요약 + 새 발화만 보내므로 호출당 토큰이 일정하다.
|
||||
const previous =
|
||||
typeof previousSummary === 'string'
|
||||
? previousSummary.slice(-MAX_PREVIOUS_SUMMARY_CHARS)
|
||||
: undefined
|
||||
|
||||
const result = await generateLiveSummary(truncated, {
|
||||
apiKey: resolvedKey,
|
||||
provider: settings,
|
||||
previousSummary: previous,
|
||||
template: (template as TemplateId | undefined) ?? 'meeting',
|
||||
depth: depth as SummaryDepth | undefined,
|
||||
customPrompt:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import {
|
||||
generateGeminiMinutes,
|
||||
generateAiMinutes,
|
||||
generateSimpleMinutes,
|
||||
} from '@/lib/minutes-generator'
|
||||
import { resolveGeminiApiKey } from '@/lib/api-keys'
|
||||
import { resolveProviderSettings } from '@/lib/api-keys'
|
||||
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
||||
|
||||
const MAX_TRANSCRIPT_CHARS = 100_000
|
||||
@@ -11,16 +11,7 @@ const MAX_TRANSCRIPT_CHARS = 100_000
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const {
|
||||
title,
|
||||
transcript,
|
||||
mode,
|
||||
date,
|
||||
template,
|
||||
depth,
|
||||
customPrompt,
|
||||
apiKey,
|
||||
} = body
|
||||
const { title, transcript, mode, date, template, depth, customPrompt } = body
|
||||
|
||||
if (!transcript || typeof transcript !== 'string') {
|
||||
return Response.json(
|
||||
@@ -48,26 +39,27 @@ export async function POST(request: NextRequest) {
|
||||
typeof customPrompt === 'string' ? customPrompt : undefined,
|
||||
}
|
||||
|
||||
if (mode === 'gemini') {
|
||||
const resolvedKey = resolveGeminiApiKey(apiKey)
|
||||
if (!resolvedKey) {
|
||||
// 'gemini'는 DB에 저장된 기존 값과의 호환을 위해 유지되는 AI 모드 식별자다.
|
||||
if (mode === 'gemini' || mode === 'ai') {
|
||||
const settings = resolveProviderSettings(body)
|
||||
if (!settings) {
|
||||
const fallback = generateSimpleMinutes(input)
|
||||
return Response.json({
|
||||
markdown: fallback,
|
||||
mode: 'simple',
|
||||
warning:
|
||||
'Gemini API 키가 설정되지 않아 단순 변환으로 대체되었습니다. /settings에서 키를 설정하세요.',
|
||||
'AI 프로바이더가 설정되지 않아 단순 변환으로 대체되었습니다. /settings에서 설정하세요.',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await generateGeminiMinutes(input, { apiKey: resolvedKey })
|
||||
const result = await generateAiMinutes(input, { provider: settings })
|
||||
|
||||
if (!result.success) {
|
||||
const fallback = generateSimpleMinutes(input)
|
||||
return Response.json({
|
||||
markdown: fallback,
|
||||
mode: 'simple',
|
||||
warning: `Gemini 요약에 실패하여 단순 변환으로 대체되었습니다: ${result.error}`,
|
||||
warning: `AI 요약에 실패하여 단순 변환으로 대체되었습니다: ${result.error}`,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+3
-3
@@ -5,7 +5,7 @@ import Link from 'next/link'
|
||||
import { AudioUploader } from '@/components/upload/AudioUploader'
|
||||
import { LiveRecorder } from '@/components/recorder/LiveRecorder'
|
||||
import { MinutesViewer } from '@/components/minutes/MinutesViewer'
|
||||
import { getStoredApiKey } from '@/lib/api-key-storage'
|
||||
import { getProviderRequestPayload } from '@/lib/api-key-storage'
|
||||
import {
|
||||
TEMPLATES,
|
||||
DEFAULT_TEMPLATE_ID,
|
||||
@@ -119,7 +119,7 @@ export default function HomePage() {
|
||||
template,
|
||||
depth,
|
||||
customPrompt: template === 'custom' ? customPrompt : undefined,
|
||||
apiKey: getStoredApiKey(),
|
||||
...getProviderRequestPayload(),
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
@@ -212,7 +212,7 @@ export default function HomePage() {
|
||||
: 'bg-neutral-100 text-neutral-600 hover:bg-neutral-200'
|
||||
}`}
|
||||
>
|
||||
Gemini AI 요약
|
||||
AI 요약
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+198
-78
@@ -4,71 +4,124 @@ import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
clearStoredApiKey,
|
||||
clearStoredProviderConfig,
|
||||
getStoredApiKey,
|
||||
getStoredProviderConfig,
|
||||
maskApiKey,
|
||||
setStoredApiKey,
|
||||
setStoredProviderConfig,
|
||||
} from '@/lib/api-key-storage'
|
||||
import {
|
||||
DEFAULT_PRESET_ID,
|
||||
PROVIDER_PRESETS,
|
||||
findPreset,
|
||||
} from '@/lib/providers'
|
||||
|
||||
type TestState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'testing' }
|
||||
| { status: 'success'; message: string; reply?: string }
|
||||
| { status: 'success'; message: string; model?: string; reply?: string }
|
||||
| { status: 'failed'; message: string }
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [storedKey, setStoredKey] = useState<string | null>(null)
|
||||
const [draftKey, setDraftKey] = useState('')
|
||||
const [presetId, setPresetId] = useState(DEFAULT_PRESET_ID)
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [model, setModel] = useState('')
|
||||
const [showKey, setShowKey] = useState(false)
|
||||
const [savedKey, setSavedKey] = useState<string | null>(null)
|
||||
const [envConfigured, setEnvConfigured] = useState<boolean | null>(null)
|
||||
const [testState, setTestState] = useState<TestState>({ status: 'idle' })
|
||||
const [savedToast, setSavedToast] = useState(false)
|
||||
|
||||
const preset = findPreset(presetId)
|
||||
const isOpenAiCompatible = preset.provider === 'openai-compatible'
|
||||
|
||||
useEffect(() => {
|
||||
setStoredKey(getStoredApiKey())
|
||||
const stored = getStoredProviderConfig()
|
||||
if (stored) {
|
||||
const storedPreset = findPreset(stored.presetId)
|
||||
setPresetId(storedPreset.id)
|
||||
setApiKey(stored.apiKey)
|
||||
setBaseUrl(stored.baseUrl)
|
||||
setModel(stored.model)
|
||||
setSavedKey(stored.apiKey || null)
|
||||
} else {
|
||||
// 프로바이더 설정 이전에 저장해둔 Gemini 키를 그대로 이어받는다.
|
||||
const legacyKey = getStoredApiKey()
|
||||
setApiKey(legacyKey ?? '')
|
||||
setModel(findPreset(DEFAULT_PRESET_ID).defaultModel)
|
||||
setSavedKey(legacyKey)
|
||||
}
|
||||
|
||||
fetch('/api/settings/status')
|
||||
.then((r) => r.json())
|
||||
.then((d) => setEnvConfigured(!!d.envConfigured))
|
||||
.then((d) => setEnvConfigured(!!d.envConfigured || !!d.envProviderConfigured))
|
||||
.catch(() => setEnvConfigured(false))
|
||||
}, [])
|
||||
|
||||
function handlePresetChange(nextId: string) {
|
||||
const next = findPreset(nextId)
|
||||
if (next.id === presetId) return
|
||||
|
||||
setPresetId(next.id)
|
||||
setBaseUrl(next.baseUrl)
|
||||
setModel(next.defaultModel)
|
||||
// 프로바이더가 바뀌면 이전 키는 무의미할 뿐 아니라, 그대로 두면
|
||||
// 다른 회사 엔드포인트로 전송될 수 있으므로 비운다.
|
||||
setApiKey('')
|
||||
setTestState({ status: 'idle' })
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
const trimmed = draftKey.trim()
|
||||
if (!trimmed) return
|
||||
setStoredApiKey(trimmed)
|
||||
setStoredKey(trimmed)
|
||||
setDraftKey('')
|
||||
setStoredProviderConfig({
|
||||
presetId,
|
||||
apiKey: apiKey.trim(),
|
||||
baseUrl: baseUrl.trim(),
|
||||
model: model.trim(),
|
||||
})
|
||||
setSavedKey(apiKey.trim() || null)
|
||||
setSavedToast(true)
|
||||
setTimeout(() => setSavedToast(false), 2000)
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
if (!confirm('브라우저에 저장된 키를 삭제할까요? 환경변수가 설정되어 있다면 그것을 사용합니다.')) {
|
||||
if (
|
||||
!confirm(
|
||||
'브라우저에 저장된 프로바이더 설정과 키를 삭제할까요? 환경변수가 설정되어 있다면 그것을 사용합니다.',
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
clearStoredProviderConfig()
|
||||
clearStoredApiKey()
|
||||
setStoredKey(null)
|
||||
const fallback = findPreset(DEFAULT_PRESET_ID)
|
||||
setPresetId(fallback.id)
|
||||
setApiKey('')
|
||||
setBaseUrl(fallback.baseUrl)
|
||||
setModel(fallback.defaultModel)
|
||||
setSavedKey(null)
|
||||
setTestState({ status: 'idle' })
|
||||
}
|
||||
|
||||
async function handleTest() {
|
||||
const keyToTest = draftKey.trim() || storedKey
|
||||
if (!keyToTest) {
|
||||
setTestState({ status: 'failed', message: '테스트할 키가 없습니다.' })
|
||||
return
|
||||
}
|
||||
|
||||
setTestState({ status: 'testing' })
|
||||
try {
|
||||
const res = await fetch('/api/settings/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey: keyToTest }),
|
||||
body: JSON.stringify({
|
||||
provider: preset.provider,
|
||||
apiKey: apiKey.trim(),
|
||||
baseUrl: baseUrl.trim(),
|
||||
model: model.trim(),
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.ok) {
|
||||
setTestState({
|
||||
status: 'success',
|
||||
message: data.message,
|
||||
model: data.model,
|
||||
reply: data.reply,
|
||||
})
|
||||
} else {
|
||||
@@ -82,13 +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)'
|
||||
: envConfigured
|
||||
? '환경변수 (서버)'
|
||||
: '없음'
|
||||
|
||||
const activeBadgeStyle = storedKey
|
||||
const activeBadgeStyle = savedKey
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: envConfigured
|
||||
? 'bg-green-100 text-green-700'
|
||||
@@ -117,15 +175,15 @@ export default function SettingsPage() {
|
||||
⚙️ 설정
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
Gemini API 키를 브라우저에 저장합니다. 키는 서버에 저장되지 않으며,
|
||||
요청 시점에만 전송되어 사용됩니다.
|
||||
AI 요약에 사용할 프로바이더와 키를 브라우저에 저장합니다. 키는 서버 DB에
|
||||
저장되지 않으며, 요청 시점에만 전송되어 사용됩니다.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-neutral-800">
|
||||
현재 키 소스
|
||||
현재 설정
|
||||
</h2>
|
||||
<span
|
||||
className={`text-xs px-2.5 py-1 rounded-full font-medium ${activeBadgeStyle}`}
|
||||
@@ -135,16 +193,28 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm text-neutral-600">
|
||||
<p>
|
||||
<span className="inline-block w-32 text-neutral-500">프로바이더:</span>{' '}
|
||||
<span className="font-medium text-neutral-800">{preset.label}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="inline-block w-32 text-neutral-500">모델:</span>{' '}
|
||||
{model.trim() ? (
|
||||
<span className="font-mono text-xs">{model.trim()}</span>
|
||||
) : (
|
||||
<span className="text-neutral-400">기본값</span>
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="inline-block w-32 text-neutral-500">브라우저 키:</span>{' '}
|
||||
{storedKey ? (
|
||||
<span className="font-mono">{maskApiKey(storedKey)}</span>
|
||||
{savedKey ? (
|
||||
<span className="font-mono">{maskApiKey(savedKey)}</span>
|
||||
) : (
|
||||
<span className="text-neutral-400">미설정</span>
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="inline-block w-32 text-neutral-500">환경변수 키:</span>{' '}
|
||||
<span className="inline-block w-32 text-neutral-500">환경변수:</span>{' '}
|
||||
{envConfigured === null ? (
|
||||
<span className="text-neutral-400">확인 중...</span>
|
||||
) : envConfigured ? (
|
||||
@@ -156,73 +226,121 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-neutral-500">
|
||||
💡 우선순위: 브라우저 키 → 환경변수 → 단순 변환 폴백
|
||||
💡 우선순위: 브라우저 설정 → 환경변수 → 단순 변환 폴백
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-6 rounded-2xl border border-neutral-200 bg-white p-6">
|
||||
<h2 className="mb-4 text-base font-semibold text-neutral-800">
|
||||
Gemini API 키
|
||||
프로바이더
|
||||
</h2>
|
||||
|
||||
<label className="block mb-2 text-sm text-neutral-600">
|
||||
새 키 입력
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={draftKey}
|
||||
onChange={(e) => setDraftKey(e.target.value)}
|
||||
placeholder="AIzaSy..."
|
||||
className="flex-1 rounded-xl border border-neutral-300 px-4 py-3 font-mono text-sm focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
className="rounded-xl border border-neutral-300 px-3 text-sm hover:bg-neutral-50 transition-colors"
|
||||
title={showKey ? '숨기기' : '보이기'}
|
||||
>
|
||||
{showKey ? '🙈' : '👁️'}
|
||||
</button>
|
||||
<div 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-2 text-xs text-neutral-500">
|
||||
<a
|
||||
href="https://aistudio.google.com/apikey"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-purple-600"
|
||||
>
|
||||
Google AI Studio
|
||||
</a>
|
||||
에서 무료로 발급할 수 있습니다.
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-neutral-500">{preset.description}</p>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{isOpenAiCompatible && (
|
||||
<div className="mt-5">
|
||||
<label className="mb-2 block text-sm text-neutral-600">
|
||||
API 주소 (base URL)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="w-full rounded-xl border border-neutral-300 px-4 py-3 font-mono text-sm focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
OpenAI 호환 엔드포인트의 <code>/chat/completions</code> 앞부분까지
|
||||
입력하세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="mb-2 block text-sm text-neutral-600">모델</label>
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder={preset.defaultModel || 'model-id'}
|
||||
className="w-full rounded-xl border border-neutral-300 px-4 py-3 font-mono text-sm focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all"
|
||||
/>
|
||||
{preset.modelHint && (
|
||||
<p className="mt-1 text-xs text-neutral-500">{preset.modelHint}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<label className="mb-2 block text-sm text-neutral-600">
|
||||
{preset.apiKeyLabel}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showKey ? 'text' : 'password'}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={preset.apiKeyPlaceholder}
|
||||
className="flex-1 rounded-xl border border-neutral-300 px-4 py-3 font-mono text-sm focus:border-purple-400 focus:outline-none focus:ring-2 focus:ring-purple-100 transition-all"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setShowKey((v) => !v)}
|
||||
className="rounded-xl border border-neutral-300 px-3 text-sm hover:bg-neutral-50 transition-colors"
|
||||
title={showKey ? '숨기기' : '보이기'}
|
||||
>
|
||||
{showKey ? '🙈' : '👁️'}
|
||||
</button>
|
||||
</div>
|
||||
{preset.docsUrl && (
|
||||
<p className="mt-2 text-xs text-neutral-500">
|
||||
<a
|
||||
href={preset.docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-purple-600"
|
||||
>
|
||||
{preset.docsLabel ?? preset.docsUrl}
|
||||
</a>
|
||||
에서 발급할 수 있습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!draftKey.trim()}
|
||||
className="rounded-lg bg-purple-600 px-4 py-2 text-sm font-medium text-white hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
💾 저장
|
||||
</button>
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={
|
||||
testState.status === 'testing' ||
|
||||
(!draftKey.trim() && !storedKey)
|
||||
}
|
||||
disabled={!canTest}
|
||||
className="rounded-lg border border-purple-300 bg-purple-50 px-4 py-2 text-sm font-medium text-purple-700 hover:bg-purple-100 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{testState.status === 'testing' ? '테스트 중...' : '🧪 테스트 호출'}
|
||||
</button>
|
||||
{storedKey && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100 transition-colors"
|
||||
>
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100 transition-colors"
|
||||
>
|
||||
🗑️ 삭제
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{savedToast && (
|
||||
@@ -234,11 +352,10 @@ export default function SettingsPage() {
|
||||
{testState.status === 'success' && (
|
||||
<div className="mt-4 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
|
||||
<strong>✓ {testState.message}</strong>
|
||||
{testState.reply && (
|
||||
<p className="mt-1 font-mono text-xs text-green-600">
|
||||
Gemini 응답: {testState.reply}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 font-mono text-xs text-green-600">
|
||||
{testState.model ? `${testState.model} → ` : ''}
|
||||
{testState.reply}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -265,6 +382,9 @@ export default function SettingsPage() {
|
||||
<li className="text-amber-700">
|
||||
⚠️ <strong>회의 내용에 민감 정보가 포함된 경우</strong>, 실시간 녹음 기능은 음성을 Google 서버로 전송합니다 (Chrome Web Speech API 동작). 사내 컴플라이언스 정책 확인 후 사용해주세요.
|
||||
</li>
|
||||
<li className="text-amber-700">
|
||||
⚠️ <strong>중계 서비스를 고르면 회의 전문이 그 회사 서버를 거칩니다.</strong> OpenAI·Gemini 직접 호출은 해당 회사만, OrcaRouter 같은 라우터는 라우터 운영사 + 실제 모델 제공사 양쪽을 거칩니다. 외부 전송이 곤란하면 <strong>로컬 모델</strong>을 선택하세요.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -126,7 +126,7 @@ export function MinutesViewer({
|
||||
}
|
||||
}
|
||||
|
||||
const modeLabel = mode === 'gemini' ? 'Gemini AI 요약' : '단순 변환'
|
||||
const modeLabel = mode === 'gemini' ? 'AI 요약' : '단순 변환'
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useSpeechRecognition } from '@/hooks/useSpeechRecognition'
|
||||
import { useDualStreamCapture } from '@/hooks/useDualStreamCapture'
|
||||
import { useDiarization } from '@/hooks/useDiarization'
|
||||
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'
|
||||
|
||||
type SeparationMode = 'off' | 'remote' | 'in-person'
|
||||
|
||||
interface LiveRecorderProps {
|
||||
onTranscriptReady: (transcript: string) => void
|
||||
liveSummaryEnabled: boolean
|
||||
@@ -21,16 +36,63 @@ export function LiveRecorder({
|
||||
depth,
|
||||
customPrompt,
|
||||
}: 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 {
|
||||
isListening,
|
||||
isSupported,
|
||||
chunks,
|
||||
interimText,
|
||||
error: recognitionError,
|
||||
startListening,
|
||||
stopListening,
|
||||
resetChunks,
|
||||
} = useSpeechRecognition()
|
||||
} = local
|
||||
|
||||
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 {
|
||||
summary,
|
||||
@@ -78,14 +140,48 @@ export function LiveRecorder({
|
||||
)
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
stopListening()
|
||||
const transcript = formatTranscriptChunks(chunks)
|
||||
async function handleStart() {
|
||||
timeOriginRef.current = Date.now()
|
||||
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) {
|
||||
onTranscriptReady(transcript)
|
||||
}
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
local.resetChunks()
|
||||
remote.resetChunks()
|
||||
diarization.reset()
|
||||
setSegments(null)
|
||||
}
|
||||
|
||||
const lastUpdatedLabel = lastUpdatedAt
|
||||
? new Date(lastUpdatedAt).toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
@@ -136,7 +232,7 @@ export function LiveRecorder({
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={startListening}
|
||||
onClick={handleStart}
|
||||
className="flex items-center gap-2 rounded-full bg-blue-600 px-6 py-3 text-white font-medium shadow-lg shadow-blue-600/25 hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<span className="text-lg">🎤</span>
|
||||
@@ -146,7 +242,7 @@ export function LiveRecorder({
|
||||
|
||||
{chunks.length > 0 && !isListening && (
|
||||
<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"
|
||||
>
|
||||
초기화
|
||||
@@ -159,8 +255,149 @@ export function LiveRecorder({
|
||||
녹음 중 · {wordCount}단어 · {chunks.length}개 구간
|
||||
</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>
|
||||
|
||||
{!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>화면 공유 대화상자에서 “탭 오디오 공유”를 반드시 켜주세요.</strong>
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs text-amber-700">
|
||||
노트북 한 대를 두고 마주 앉은 대면 회의에서는 동작하지 않습니다.
|
||||
그 경우 “대면 회의”를 선택하세요.
|
||||
</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 && (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
<strong>음성 인식 오류:</strong> {recognitionError}
|
||||
@@ -204,7 +441,20 @@ export function LiveRecorder({
|
||||
{hasTranscript ? (
|
||||
<div className="space-y-1 text-sm font-mono leading-relaxed text-neutral-700">
|
||||
{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 && (
|
||||
<p className="text-neutral-400 italic">
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -4,13 +4,16 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { TranscriptChunk } from '@/lib/transcript-formatter'
|
||||
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
|
||||
import type { SummaryDepth, TemplateId } from '@/lib/templates'
|
||||
import { getStoredApiKey } from '@/lib/api-key-storage'
|
||||
import { getProviderRequestPayload } from '@/lib/api-key-storage'
|
||||
import { planLiveSummaryRequest } from '@/lib/live-summary'
|
||||
|
||||
interface UseLiveSummaryOptions {
|
||||
enabled: boolean
|
||||
pollIntervalMs?: number
|
||||
minWords?: number
|
||||
incrementWords?: number
|
||||
/** 증분 요약을 이 횟수만큼 반복하면 전사 전체로 한 번 다시 요약한다. 0이면 끈다. */
|
||||
fullRefreshEvery?: number
|
||||
template?: TemplateId
|
||||
depth?: SummaryDepth
|
||||
customPrompt?: string
|
||||
@@ -41,6 +44,7 @@ export function useLiveSummary(
|
||||
pollIntervalMs = 30_000,
|
||||
minWords = 25,
|
||||
incrementWords = 40,
|
||||
fullRefreshEvery = 20,
|
||||
template = 'meeting',
|
||||
depth,
|
||||
customPrompt,
|
||||
@@ -64,6 +68,11 @@ export function useLiveSummary(
|
||||
const cooldownUntilRef = useRef<number | null>(null)
|
||||
const consecutiveFailuresRef = useRef(0)
|
||||
|
||||
// 증분 요약 상태 — 성공했을 때만 전진시켜서 실패해도 발화를 잃지 않는다.
|
||||
const summaryRef = useRef('')
|
||||
const lastSummarizedIndexRef = useRef(0)
|
||||
const incrementsSinceFullRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
chunksRef.current = chunks
|
||||
}, [chunks])
|
||||
@@ -98,12 +107,31 @@ export function useLiveSummary(
|
||||
return
|
||||
}
|
||||
|
||||
const transcript = formatTranscriptChunks(chunksRef.current)
|
||||
const allChunks = chunksRef.current
|
||||
const transcript = formatTranscriptChunks(allChunks)
|
||||
const currentWordCount = countWords(transcript)
|
||||
|
||||
if (currentWordCount < minWords) return
|
||||
if (currentWordCount - lastWordCountRef.current < incrementWords) return
|
||||
|
||||
const plan = planLiveSummaryRequest({
|
||||
totalChunks: allChunks.length,
|
||||
lastSummarizedIndex: lastSummarizedIndexRef.current,
|
||||
incrementsSinceFull: incrementsSinceFullRef.current,
|
||||
fullRefreshEvery,
|
||||
hasPreviousSummary: summaryRef.current.trim().length > 0,
|
||||
})
|
||||
|
||||
// 요청을 보내는 시점의 길이를 고정해 둔다. 응답을 기다리는 동안
|
||||
// 새 청크가 쌓여도 그 부분은 다음 회차로 넘어간다.
|
||||
const chunkCountAtSend = allChunks.length
|
||||
const payloadTranscript =
|
||||
plan.mode === 'full'
|
||||
? transcript
|
||||
: formatTranscriptChunks(allChunks.slice(plan.startIndex))
|
||||
|
||||
if (payloadTranscript.trim().length === 0) return
|
||||
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
inFlightRef.current = true
|
||||
@@ -115,11 +143,13 @@ export function useLiveSummary(
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
transcript,
|
||||
transcript: payloadTranscript,
|
||||
previousSummary:
|
||||
plan.mode === 'incremental' ? summaryRef.current : undefined,
|
||||
template: configRef.current.template,
|
||||
depth: configRef.current.depth,
|
||||
customPrompt: configRef.current.customPrompt,
|
||||
apiKey: getStoredApiKey(),
|
||||
...getProviderRequestPayload(),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
@@ -142,8 +172,12 @@ export function useLiveSummary(
|
||||
|
||||
const data = await res.json()
|
||||
setSummary(data.markdown)
|
||||
summaryRef.current = data.markdown
|
||||
setLastUpdatedAt(Date.now())
|
||||
lastWordCountRef.current = currentWordCount
|
||||
lastSummarizedIndexRef.current = chunkCountAtSend
|
||||
incrementsSinceFullRef.current =
|
||||
plan.mode === 'full' ? 0 : incrementsSinceFullRef.current + 1
|
||||
consecutiveFailuresRef.current = 0
|
||||
cooldownUntilRef.current = null
|
||||
setCooldownUntil(null)
|
||||
@@ -164,7 +198,7 @@ export function useLiveSummary(
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [enabled, pollIntervalMs, minWords, incrementWords])
|
||||
}, [enabled, pollIntervalMs, minWords, incrementWords, fullRefreshEvery])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
|
||||
@@ -3,6 +3,22 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import type { TranscriptChunk } from '@/lib/transcript-formatter'
|
||||
|
||||
export interface UseSpeechRecognitionOptions {
|
||||
/**
|
||||
* 전사할 오디오 트랙. 지정하면 기본 마이크 대신 이 트랙을 인식한다.
|
||||
* 트랙마다 인식기를 붙이면 화자가 트랙으로 확정된다.
|
||||
*/
|
||||
audioTrack?: MediaStreamTrack | null
|
||||
/** 이 인식기가 만든 청크에 붙일 화자 식별자. */
|
||||
speaker?: string
|
||||
/**
|
||||
* 타임스탬프 기준 시각(ms). 여러 인식기를 병합하려면 같은 값을 넘겨야
|
||||
* 한 시간축 위에 놓인다. 생략하면 startListening 호출 시각을 쓴다.
|
||||
*/
|
||||
timeOrigin?: number
|
||||
lang?: string
|
||||
}
|
||||
|
||||
interface SpeechRecognitionHook {
|
||||
isListening: boolean
|
||||
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 [chunks, setChunks] = useState<TranscriptChunk[]>([])
|
||||
const [interimText, setInterimText] = useState('')
|
||||
@@ -40,6 +59,12 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||
const startTimeRef = 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
|
||||
|
||||
useEffect(() => {
|
||||
@@ -59,12 +84,15 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||
const SpeechRecognitionAPI =
|
||||
window.SpeechRecognition || window.webkitSpeechRecognition
|
||||
|
||||
const { audioTrack: track, speaker: speakerId, timeOrigin: origin, lang: language } =
|
||||
configRef.current
|
||||
|
||||
const recognition = new SpeechRecognitionAPI()
|
||||
recognition.lang = 'ko-KR'
|
||||
recognition.lang = language
|
||||
recognition.continuous = true
|
||||
recognition.interimResults = true
|
||||
|
||||
startTimeRef.current = Date.now()
|
||||
startTimeRef.current = origin ?? Date.now()
|
||||
networkRetryRef.current = 0
|
||||
setError(null)
|
||||
|
||||
@@ -88,6 +116,7 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||
startTime: Math.max(0, now - 2),
|
||||
endTime: now,
|
||||
isFinal: true,
|
||||
...(speakerId ? { speaker: speakerId } : {}),
|
||||
},
|
||||
])
|
||||
setInterimText('')
|
||||
@@ -103,8 +132,14 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||
const delay = networkRetryRef.current > 0 ? 1500 : 0
|
||||
setTimeout(() => {
|
||||
if (recognitionRef.current !== recognition) return
|
||||
// 트랙이 끝났으면(화면 공유 중단 등) 재시작하지 않는다.
|
||||
if (track && track.readyState !== 'live') {
|
||||
setIsListening(false)
|
||||
recognitionRef.current = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
recognition.start()
|
||||
recognition.start(track ?? undefined)
|
||||
} catch {
|
||||
setIsListening(false)
|
||||
recognitionRef.current = null
|
||||
@@ -141,7 +176,7 @@ export function useSpeechRecognition(): SpeechRecognitionHook {
|
||||
|
||||
recognitionRef.current = recognition
|
||||
try {
|
||||
recognition.start()
|
||||
recognition.start(track ?? undefined)
|
||||
setIsListening(true)
|
||||
} catch (err) {
|
||||
setError(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { findPreset, type ProviderId } from './providers'
|
||||
|
||||
const STORAGE_KEY = 'meeting-minutes:gemini-api-key'
|
||||
|
||||
export function getStoredApiKey(): string | null {
|
||||
@@ -34,3 +36,86 @@ export function maskApiKey(key: string): string {
|
||||
if (key.length <= 8) return '••••'
|
||||
return `${key.slice(0, 4)}••••${key.slice(-4)}`
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- *
|
||||
* 프로바이더 설정 (Gemini / OpenAI 호환 엔드포인트)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
const PROVIDER_STORAGE_KEY = 'meeting-minutes:llm-provider'
|
||||
|
||||
export interface StoredProviderConfig {
|
||||
presetId: string
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
model: string
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isProviderId, type ProviderId, type ProviderSettings } from './providers'
|
||||
|
||||
/**
|
||||
* Gemini API 키 해석 우선순위:
|
||||
* 1) 요청 body로 전달된 키 (브라우저 LocalStorage에서 옴)
|
||||
@@ -19,3 +21,70 @@ export function resolveGeminiApiKey(
|
||||
export function isEnvKeyConfigured(): boolean {
|
||||
return (process.env.GEMINI_API_KEY ?? '').trim().length > 0
|
||||
}
|
||||
|
||||
/** LLM_* 환경변수로 프로바이더가 지정되어 있는지 */
|
||||
export function isEnvProviderConfigured(): boolean {
|
||||
const provider = env('LLM_PROVIDER')
|
||||
if (provider === 'openai-compatible') {
|
||||
return env('LLM_BASE_URL').length > 0 && env('LLM_MODEL').length > 0
|
||||
}
|
||||
return isEnvKeyConfigured() || env('LLM_API_KEY').length > 0
|
||||
}
|
||||
|
||||
export interface ProviderRequestBody {
|
||||
provider?: unknown
|
||||
apiKey?: unknown
|
||||
baseUrl?: unknown
|
||||
model?: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 body + 환경변수로부터 프로바이더 설정을 만든다.
|
||||
* 필드별 우선순위는 기존 키 해석과 동일하게 "요청 → 환경변수" 순이다.
|
||||
*
|
||||
* 사용 가능한 설정이 없으면 null을 돌려주고, 호출부는 단순 변환으로 폴백한다.
|
||||
*/
|
||||
export function resolveProviderSettings(
|
||||
body: ProviderRequestBody | null | undefined,
|
||||
): ProviderSettings | null {
|
||||
const provider = resolveProvider(body?.provider)
|
||||
|
||||
if (provider === 'gemini') {
|
||||
const apiKey = resolveGeminiApiKey(str(body?.apiKey) || env('LLM_API_KEY'))
|
||||
if (!apiKey) return null
|
||||
return {
|
||||
provider,
|
||||
apiKey,
|
||||
model: str(body?.model) || env('LLM_MODEL') || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = str(body?.baseUrl) || env('LLM_BASE_URL')
|
||||
const model = str(body?.model) || env('LLM_MODEL')
|
||||
if (baseUrl.length === 0 || model.length === 0) return null
|
||||
|
||||
// 로컬 모델 서버는 키가 없어도 되므로 빈 문자열을 허용한다.
|
||||
return {
|
||||
provider,
|
||||
apiKey: str(body?.apiKey) || env('LLM_API_KEY'),
|
||||
baseUrl,
|
||||
model,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProvider(requested: unknown): ProviderId {
|
||||
if (isProviderId(requested)) return requested
|
||||
|
||||
const fromEnv = env('LLM_PROVIDER')
|
||||
if (isProviderId(fromEnv)) return fromEnv
|
||||
|
||||
return 'gemini'
|
||||
}
|
||||
|
||||
function str(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function env(name: string): string {
|
||||
return (process.env[name] ?? '').trim()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}` }
|
||||
}
|
||||
}
|
||||
+84
-46
@@ -1,3 +1,4 @@
|
||||
import { complete, toProviderSettings, type ProviderSettings } from './providers'
|
||||
import {
|
||||
buildPrompt,
|
||||
resolveDepth,
|
||||
@@ -10,7 +11,17 @@ type LiveSummaryResult =
|
||||
| { success: false; error: string; rateLimited?: boolean }
|
||||
|
||||
interface LiveSummaryOptions {
|
||||
apiKey: string
|
||||
/** 프로바이더 설정. 생략하면 apiKey로 Gemini를 호출한다. */
|
||||
provider?: ProviderSettings
|
||||
apiKey?: string
|
||||
/**
|
||||
* 직전 롤링 요약.
|
||||
*
|
||||
* 값이 있으면 증분 모드로 동작하며, 이때 `transcript` 인자는 전사 전체가 아니라
|
||||
* **직전 요약 이후 새로 추가된 발화**만 담아야 한다. 호출당 토큰이 회의 길이와
|
||||
* 무관하게 일정해진다.
|
||||
*/
|
||||
previousSummary?: string
|
||||
template?: TemplateId
|
||||
depth?: SummaryDepth
|
||||
customPrompt?: string
|
||||
@@ -21,67 +32,94 @@ export async function generateLiveSummary(
|
||||
transcript: string,
|
||||
options: LiveSummaryOptions,
|
||||
): Promise<LiveSummaryResult> {
|
||||
const { apiKey, fetchFn = fetch } = options
|
||||
|
||||
if (!apiKey || apiKey.trim().length === 0) {
|
||||
return { success: false, error: 'Gemini API 키가 필요합니다.' }
|
||||
}
|
||||
const { fetchFn } = options
|
||||
const settings = toProviderSettings(options.provider, options.apiKey)
|
||||
|
||||
const trimmed = transcript.trim()
|
||||
if (trimmed.length === 0) {
|
||||
return { success: false, error: '요약할 텍스트가 비어 있습니다.' }
|
||||
}
|
||||
|
||||
const previous = options.previousSummary?.trim() ?? ''
|
||||
const incremental = previous.length > 0
|
||||
|
||||
const templateId = options.template ?? 'meeting'
|
||||
const depth = resolveDepth(templateId, options.depth)
|
||||
const prompt = buildPrompt({
|
||||
templateId,
|
||||
depth,
|
||||
transcript: trimmed,
|
||||
transcript: incremental
|
||||
? `[지금까지의 요약]\n${previous}\n\n[새로 추가된 발화]\n${trimmed}`
|
||||
: trimmed,
|
||||
live: true,
|
||||
incremental,
|
||||
customPrompt: options.customPrompt,
|
||||
})
|
||||
|
||||
const url =
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
|
||||
const result = await complete(prompt, settings, { fetchFn })
|
||||
|
||||
try {
|
||||
const response = await fetchFn(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-goog-api-key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: prompt }] }],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Gemini 요청 한도 초과. 잠시 후 자동 재시도됩니다.',
|
||||
rateLimited: true,
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: `Gemini API 호출 실패: ${response.status}`,
|
||||
}
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
...(result.rateLimited ? { rateLimited: true } : {}),
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const markdown: string =
|
||||
data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''
|
||||
|
||||
if (markdown.trim().length === 0) {
|
||||
return { success: false, error: '요약 결과가 비어 있습니다.' }
|
||||
}
|
||||
|
||||
return { success: true, markdown }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
return { success: false, error: `실시간 요약 중 오류: ${message}` }
|
||||
}
|
||||
|
||||
if (result.text.trim().length === 0) {
|
||||
return { success: false, error: '요약 결과가 비어 있습니다.' }
|
||||
}
|
||||
|
||||
return { success: true, markdown: result.text }
|
||||
}
|
||||
|
||||
export interface LiveSummaryPlan {
|
||||
/** 'full'이면 전사 전체를 보내고 previousSummary를 쓰지 않는다. */
|
||||
mode: 'full' | 'incremental'
|
||||
/** 이번에 보낼 청크의 시작 인덱스. full이면 0. */
|
||||
startIndex: number
|
||||
}
|
||||
|
||||
export interface LiveSummaryPlanArgs {
|
||||
totalChunks: number
|
||||
lastSummarizedIndex: number
|
||||
incrementsSinceFull: number
|
||||
/** 0이면 주기적 전체 재요약을 하지 않는다. */
|
||||
fullRefreshEvery: number
|
||||
hasPreviousSummary: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 이번 롤링 요약 호출을 증분으로 보낼지 전체로 보낼지 결정한다.
|
||||
*
|
||||
* 증분 모드는 호출당 토큰을 회의 길이와 무관하게 유지하지만, 요약을 요약하는
|
||||
* 구조라 반복될수록 오차가 쌓인다. 그래서 일정 횟수마다 전사 전체로 한 번씩
|
||||
* 다시 요약해 오차를 끊는다.
|
||||
*/
|
||||
export function planLiveSummaryRequest(
|
||||
args: LiveSummaryPlanArgs,
|
||||
): LiveSummaryPlan {
|
||||
const {
|
||||
totalChunks,
|
||||
lastSummarizedIndex,
|
||||
incrementsSinceFull,
|
||||
fullRefreshEvery,
|
||||
hasPreviousSummary,
|
||||
} = args
|
||||
|
||||
// 전사가 초기화되어 인덱스가 범위를 벗어난 경우
|
||||
if (lastSummarizedIndex > totalChunks) {
|
||||
return { mode: 'full', startIndex: 0 }
|
||||
}
|
||||
|
||||
// 첫 호출이거나 갱신할 요약이 아직 없는 경우
|
||||
if (lastSummarizedIndex === 0 || !hasPreviousSummary) {
|
||||
return { mode: 'full', startIndex: 0 }
|
||||
}
|
||||
|
||||
if (fullRefreshEvery > 0 && incrementsSinceFull >= fullRefreshEvery) {
|
||||
return { mode: 'full', startIndex: 0 }
|
||||
}
|
||||
|
||||
return { mode: 'incremental', startIndex: lastSummarizedIndex }
|
||||
}
|
||||
@@ -1,3 +1,9 @@
|
||||
import {
|
||||
complete,
|
||||
describeModel,
|
||||
toProviderSettings,
|
||||
type ProviderSettings,
|
||||
} from './providers'
|
||||
import {
|
||||
buildPrompt,
|
||||
resolveDepth,
|
||||
@@ -14,12 +20,14 @@ export interface MinutesInput {
|
||||
customPrompt?: string
|
||||
}
|
||||
|
||||
type GeminiResult =
|
||||
type AiMinutesResult =
|
||||
| { success: true; markdown: string }
|
||||
| { success: false; error: string }
|
||||
| { success: false; error: string; rateLimited?: boolean }
|
||||
|
||||
interface GeminiOptions {
|
||||
apiKey: string
|
||||
interface AiMinutesOptions {
|
||||
/** 프로바이더 설정. 생략하면 apiKey로 Gemini를 호출한다. */
|
||||
provider?: ProviderSettings
|
||||
apiKey?: string
|
||||
fetchFn?: typeof fetch
|
||||
}
|
||||
|
||||
@@ -49,15 +57,12 @@ ${content}
|
||||
`
|
||||
}
|
||||
|
||||
export async function generateGeminiMinutes(
|
||||
export async function generateAiMinutes(
|
||||
input: MinutesInput,
|
||||
options: GeminiOptions,
|
||||
): Promise<GeminiResult> {
|
||||
const { apiKey, fetchFn = fetch } = options
|
||||
|
||||
if (!apiKey || apiKey.trim().length === 0) {
|
||||
return { success: false, error: 'Gemini API 키가 필요합니다.' }
|
||||
}
|
||||
options: AiMinutesOptions,
|
||||
): Promise<AiMinutesResult> {
|
||||
const { fetchFn } = options
|
||||
const settings = toProviderSettings(options.provider, options.apiKey)
|
||||
|
||||
const templateId = input.template ?? 'meeting'
|
||||
const depth = resolveDepth(templateId, input.depth)
|
||||
@@ -69,34 +74,23 @@ export async function generateGeminiMinutes(
|
||||
customPrompt: input.customPrompt,
|
||||
})
|
||||
|
||||
const url =
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
|
||||
const result = await complete(prompt, settings, { fetchFn })
|
||||
|
||||
try {
|
||||
const response = await fetchFn(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-goog-api-key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: prompt }] }],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Gemini API 호출 실패: ${response.status} ${response.statusText}`,
|
||||
}
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
...(result.rateLimited ? { rateLimited: true } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const generatedText =
|
||||
data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''
|
||||
const generatedText = result.text.trim()
|
||||
if (generatedText.length === 0) {
|
||||
return { success: false, error: '요약 결과가 비어 있습니다.' }
|
||||
}
|
||||
|
||||
const dateStr = formatDate(input.date)
|
||||
const markdown = `# ${input.title}
|
||||
const dateStr = formatDate(input.date)
|
||||
const markdown = `# ${input.title}
|
||||
|
||||
**날짜**: ${dateStr}
|
||||
|
||||
@@ -106,15 +100,13 @@ ${generatedText}
|
||||
|
||||
---
|
||||
|
||||
*이 회의록은 Gemini AI를 활용하여 자동 생성되었습니다.*
|
||||
*이 회의록은 AI(${describeModel(settings)})를 활용하여 자동 생성되었습니다.*
|
||||
`
|
||||
|
||||
return { success: true, markdown }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '알 수 없는 오류'
|
||||
return {
|
||||
success: false,
|
||||
error: `Gemini API 호출 중 오류 발생: ${message}`,
|
||||
}
|
||||
}
|
||||
return { success: true, markdown }
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated `generateAiMinutes`를 사용하세요. Gemini 전용 호출부 하위 호환용입니다.
|
||||
*/
|
||||
export const generateGeminiMinutes = generateAiMinutes
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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 ?? '' }
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -34,20 +34,24 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
|
||||
depthAdjustable: true,
|
||||
basePrompt: `당신은 회의록 작성 전문가입니다. 아래 발화 내용을 분석하여 마크다운 회의록을 작성하세요.
|
||||
|
||||
포함할 섹션:
|
||||
## 요약
|
||||
(3~5줄 핵심 내용)
|
||||
회의 전체를 3~5줄로. 무엇을 논의했고 무엇이 정해졌는지.
|
||||
|
||||
## 주요 논의 사항
|
||||
1. 순번 매기기
|
||||
이어지는 본문은 **섹션 제목을 직접 지어서** 구성하세요:
|
||||
- 실제로 논의된 주제를 스스로 파악해 \`##\` 제목을 붙여 나눕니다.
|
||||
- "주요 논의 사항", "논의 내용" 같은 일반적인 제목은 쓰지 마세요.
|
||||
이 회의가 무엇을 다뤘는지 제목만 봐도 알 수 있어야 합니다.
|
||||
(예: "단계별 개발 계획", "수익화 방안", "기술적 고려사항")
|
||||
- 주제 수는 내용에 따라 정하되 보통 3~6개입니다.
|
||||
|
||||
## 액션 아이템
|
||||
- [ ] 담당자와 기한이 명확한 항목만
|
||||
반드시 \`- [ ]\` 체크박스 형식으로 작성하세요.
|
||||
담당자가 파악되면 \`(담당자)\`를 앞에, 기한이 언급됐으면 뒤에 덧붙입니다.
|
||||
예: \`- [ ] (김지훈) 경쟁 사이트 리스트업 — 5/17까지\`
|
||||
담당자가 불분명해도 해야 할 일이 명확하면 포함하세요.
|
||||
|
||||
## 결정 사항
|
||||
- 합의 또는 확정된 것만
|
||||
|
||||
한국어로 작성하세요.`,
|
||||
- 합의되었거나 확정된 것만. 없으면 이 섹션을 생략하세요.`,
|
||||
},
|
||||
|
||||
lecture: {
|
||||
@@ -60,20 +64,18 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
|
||||
depthAdjustable: true,
|
||||
basePrompt: `당신은 학습용 노트를 작성하는 전문가입니다. 강의/세미나 녹취를 구조화된 학습 자료로 정리하세요.
|
||||
|
||||
포함할 섹션:
|
||||
## 주요 주제
|
||||
(한 줄 요약)
|
||||
|
||||
## 핵심 개념
|
||||
각 개념마다 ### 소제목 + 설명 + 구체 예시
|
||||
소제목은 강의에서 실제로 다룬 개념 이름을 그대로 쓰세요.
|
||||
|
||||
## 기억할 인용·예시
|
||||
> 중요한 문장 인용 형식으로
|
||||
|
||||
## 후속 질문
|
||||
- 스스로 탐구해볼 만한 질문
|
||||
|
||||
한국어로 작성하세요.`,
|
||||
- 스스로 탐구해볼 만한 질문`,
|
||||
},
|
||||
|
||||
one_on_one: {
|
||||
@@ -86,12 +88,11 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
|
||||
depthAdjustable: true,
|
||||
basePrompt: `당신은 1:1 미팅 정리 전문가입니다. 개인적이고 신뢰 기반의 대화임을 존중하며 정리하세요.
|
||||
|
||||
포함할 섹션:
|
||||
## 이번 세션 요지
|
||||
(2~3줄)
|
||||
|
||||
## 논의한 주제
|
||||
- 주요 대화 흐름
|
||||
이어서 대화에서 실제로 다룬 주제마다 **\`##\` 제목을 직접 지어** 나누세요.
|
||||
"논의한 주제" 같은 일반적인 제목 대신, 무엇에 대한 이야기였는지 드러내는 제목을 쓰세요.
|
||||
|
||||
## 고민·블로커
|
||||
- 공유된 어려움
|
||||
@@ -100,9 +101,7 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
|
||||
- 건설적 피드백 위주
|
||||
|
||||
## 다음 미팅까지 할 일
|
||||
- [ ] 합의된 후속 조치
|
||||
|
||||
한국어로 작성하세요.`,
|
||||
- [ ] 합의된 후속 조치. 담당자가 있으면 \`(담당자)\` 표기`,
|
||||
},
|
||||
|
||||
brainstorm: {
|
||||
@@ -115,12 +114,12 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
|
||||
depthAdjustable: true,
|
||||
basePrompt: `당신은 브레인스토밍 세션을 정리하는 전문가입니다. 나온 아이디어를 분류하고 실행 가능성 관점에서 정리하세요.
|
||||
|
||||
포함할 섹션:
|
||||
## 세션 개요
|
||||
(1~2줄 — 주제/목표)
|
||||
|
||||
## 아이디어 (카테고리별)
|
||||
### [카테고리 이름]
|
||||
카테고리는 미리 정해진 목록이 아니라, 나온 아이디어를 보고 직접 묶어서 이름 붙이세요.
|
||||
- 아이디어 요약
|
||||
|
||||
## 즉시 시도 가능
|
||||
@@ -130,9 +129,7 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
|
||||
- 사유 간단히
|
||||
|
||||
## 다음 스텝
|
||||
- 구체적인 후속 행동
|
||||
|
||||
한국어로 작성하세요.`,
|
||||
- [ ] 구체적인 후속 행동. 담당자가 있으면 \`(담당자)\` 표기`,
|
||||
},
|
||||
|
||||
interview: {
|
||||
@@ -145,7 +142,6 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
|
||||
depthAdjustable: true,
|
||||
basePrompt: `당신은 인터뷰 녹취를 Q&A 형식으로 정리하는 전문가입니다.
|
||||
|
||||
포함할 섹션:
|
||||
## 개요
|
||||
(인터뷰 대상/주제 1~2줄)
|
||||
|
||||
@@ -159,9 +155,7 @@ A: [답변 요약]
|
||||
> 직접 인용
|
||||
|
||||
## 종합 인상
|
||||
- 전반적 테마 / 놓치지 말 것
|
||||
|
||||
한국어로 작성하세요.`,
|
||||
- 전반적 테마 / 놓치지 말 것`,
|
||||
},
|
||||
|
||||
raw: {
|
||||
@@ -181,8 +175,7 @@ A: [답변 요약]
|
||||
엄격한 규칙:
|
||||
- 내용을 삭제하거나 축약하지 마세요
|
||||
- 구조적 제목(## 섹션)을 덧붙이지 마세요
|
||||
- 타임스탬프가 있으면 그대로 유지하세요
|
||||
- 한국어로 작성하세요.`,
|
||||
- 타임스탬프가 있으면 그대로 유지하세요`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -195,13 +188,41 @@ const DEPTH_MODIFIERS: Record<SummaryDepth, string> = {
|
||||
'작성 강도: **상세**. 원문의 주요 세부사항·수치·고유명사를 누락 없이 포함하세요. 구조만 바꾸고 내용은 최대한 보존합니다.',
|
||||
}
|
||||
|
||||
const LIVE_MODIFIER = `회의가 아직 진행 중입니다. 지금까지의 내용을 기반으로 **중간 정리**를 작성하세요. 확정되지 않은 결정은 "(논의 중)"으로 표시하세요.`
|
||||
const LIVE_MODIFIER = `회의가 아직 진행 중입니다. 지금까지의 내용을 기반으로 **중간 정리**를 작성하세요. 확정되지 않은 결정은 "(논의 중)"으로 표시하세요.
|
||||
섹션 제목은 지금까지 나온 주제를 기준으로 붙이되, 이후 갱신될 수 있으므로 간결하게 유지하세요.`
|
||||
|
||||
/**
|
||||
* 프리셋 템플릿 전체에 공통으로 덧붙는 규칙.
|
||||
*
|
||||
* custom 프롬프트에는 적용하지 않는다 — 사용자가 전적으로 제어하는 영역이기 때문.
|
||||
*/
|
||||
const COMMON_RULES = `공통 규칙:
|
||||
- 발화에 없는 내용을 지어내지 마세요. 불확실하면 쓰지 마세요.
|
||||
- 발화자 표기가 있다면 누가 무엇을 주장하고 약속했는지 반영하세요.
|
||||
- 영어 기술 용어·제품명은 번역하거나 음차하지 말고 원문 표기를 유지하세요 (예: latency, deployment).
|
||||
- 한국어로 작성하세요.`
|
||||
|
||||
/**
|
||||
* 증분 갱신 모드.
|
||||
*
|
||||
* 전사 전체를 매번 다시 보내는 대신 [지금까지의 요약] + [새로 추가된 발화]만 보낸다.
|
||||
* 호출당 토큰이 회의 길이와 무관하게 일정해져, 비용이 제곱이 아닌 선형으로 늘어난다.
|
||||
*/
|
||||
const INCREMENTAL_MODIFIER = `아래에는 [지금까지의 요약]과 [새로 추가된 발화]가 주어집니다.
|
||||
기존 요약을 처음부터 다시 쓰지 말고 **갱신**하세요:
|
||||
- 기존 요약의 내용과 구조를 유지한 채 새 발화를 반영합니다.
|
||||
- 기존 항목이 새 발화로 확정되거나 번복되었다면 그 항목을 고치세요.
|
||||
- 새 발화에 언급되지 않았다는 이유로 기존 내용을 삭제하지 마세요.
|
||||
- 출력은 항상 갱신된 회의록 **전체**입니다. 변경분만 출력하지 마세요.`
|
||||
|
||||
export interface BuildPromptArgs {
|
||||
templateId: TemplateId
|
||||
depth: SummaryDepth
|
||||
/** 증분 모드에서는 [지금까지의 요약] + [새로 추가된 발화]를 담은 블록이 들어온다. */
|
||||
transcript: string
|
||||
live?: boolean
|
||||
/** 직전 요약을 갱신하는 모드. live와 함께 쓴다. */
|
||||
incremental?: boolean
|
||||
customPrompt?: string
|
||||
}
|
||||
|
||||
@@ -222,14 +243,21 @@ export function resolveDepth(
|
||||
}
|
||||
|
||||
export function buildPrompt(args: BuildPromptArgs): string {
|
||||
const { templateId, depth, transcript, live = false, customPrompt } = args
|
||||
const {
|
||||
templateId,
|
||||
depth,
|
||||
transcript,
|
||||
live = false,
|
||||
incremental = false,
|
||||
customPrompt,
|
||||
} = args
|
||||
|
||||
let instruction: string
|
||||
|
||||
if (templateId === 'custom') {
|
||||
const custom = customPrompt?.trim()
|
||||
if (!custom) {
|
||||
instruction = TEMPLATES.meeting.basePrompt
|
||||
instruction = `${TEMPLATES.meeting.basePrompt}\n\n${COMMON_RULES}`
|
||||
} else {
|
||||
instruction = custom
|
||||
}
|
||||
@@ -243,12 +271,21 @@ export function buildPrompt(args: BuildPromptArgs): string {
|
||||
|
||||
if (live && template.liveSupported) {
|
||||
parts.push(LIVE_MODIFIER)
|
||||
|
||||
if (incremental) {
|
||||
parts.push(INCREMENTAL_MODIFIER)
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(COMMON_RULES)
|
||||
|
||||
instruction = parts.join('\n\n')
|
||||
}
|
||||
|
||||
return `${instruction}\n\n---\n음성 인식 텍스트:\n${transcript}`
|
||||
// 증분 모드의 transcript는 자체 라벨([지금까지의 요약] 등)을 이미 포함한다.
|
||||
const body = incremental ? transcript : `음성 인식 텍스트:\n${transcript}`
|
||||
|
||||
return `${instruction}\n\n---\n${body}`
|
||||
}
|
||||
|
||||
export function liveEnabledFor(templateId: TemplateId): boolean {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { resolveSpeakerName, type SpeakerNames } from './speakers'
|
||||
|
||||
export interface TranscriptChunk {
|
||||
text: string
|
||||
startTime: number
|
||||
endTime: number
|
||||
isFinal: boolean
|
||||
/** 화자 식별자. 단일 트랙 녹음에서는 없다. */
|
||||
speaker?: 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')}]`
|
||||
}
|
||||
|
||||
export function formatTranscriptChunks(chunks: readonly TranscriptChunk[]): string {
|
||||
export function formatTranscriptChunks(
|
||||
chunks: readonly TranscriptChunk[],
|
||||
names?: SpeakerNames,
|
||||
): string {
|
||||
if (chunks.length === 0) return ''
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 트랙에서 나온 청크를 하나의 시간축으로 합친다.
|
||||
*
|
||||
* 각 인식기가 독립적으로 동작하므로 도착 순서가 발화 순서와 다를 수 있다.
|
||||
* 모든 트랙이 같은 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(
|
||||
chunks: readonly TranscriptChunk[],
|
||||
gapThreshold: number,
|
||||
@@ -36,12 +68,19 @@ export function mergeAdjacentChunks(
|
||||
const current = chunks[i]
|
||||
const last = result[result.length - 1]
|
||||
|
||||
// 화자가 다르면 붙이지 않는다 — 서로 다른 사람의 발화가 한 덩어리가 되면 안 된다.
|
||||
if (current.speaker !== last.speaker) {
|
||||
result.push({ ...current })
|
||||
continue
|
||||
}
|
||||
|
||||
if (current.startTime - last.endTime <= gapThreshold) {
|
||||
result[result.length - 1] = {
|
||||
text: `${last.text} ${current.text}`,
|
||||
startTime: last.startTime,
|
||||
endTime: current.endTime,
|
||||
isFinal: current.isFinal,
|
||||
...(last.speaker ? { speaker: last.speaker } : {}),
|
||||
}
|
||||
} else {
|
||||
result.push({ ...current })
|
||||
@@ -50,3 +89,64 @@ export function mergeAdjacentChunks(
|
||||
|
||||
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) }
|
||||
})
|
||||
}
|
||||
Vendored
+36
@@ -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
|
||||
}
|
||||
}
|
||||
Vendored
+5
-1
@@ -5,7 +5,11 @@ interface SpeechRecognition extends EventTarget {
|
||||
onresult: ((event: SpeechRecognitionEvent) => void) | null
|
||||
onend: (() => void) | null
|
||||
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
|
||||
start(): void
|
||||
/**
|
||||
* audioTrack을 넘기면 기본 마이크 대신 그 트랙을 전사한다 (Chrome 한정).
|
||||
* 트랙별로 인식기를 붙이면 화자가 트랙 번호로 확정된다.
|
||||
*/
|
||||
start(audioTrack?: MediaStreamTrack): void
|
||||
stop(): void
|
||||
abort(): void
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user