3 Commits
Author SHA1 Message Date
9af00c3016 feat: 회의록 섹션을 내용 기반으로 생성하고 액션 아이템에 담당자 표기 (#13)
고정 제목(## 주요 논의 사항)을 강요하던 프롬프트를, 회의가 실제로 다룬
주제에서 섹션 제목을 도출하도록 바꿨다. 제목만 훑어도 내용이 파악된다.

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

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

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

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

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

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

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

테스트 141 → 151.

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

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

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

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

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

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

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

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

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

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

---------

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

No files matched your search

+22 -6
View File
@@ -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=
+60 -23
View File
@@ -28,7 +28,7 @@ docker compose up -d
- [⚡ 실시간 롤링 요약](#-실시간-롤링-요약)
- [💾 회의록 저장/조회/검색](#-회의록-저장조회검색)
- [✅ 액션 아이템 자동 추출](#-액션-아이템-자동-추출)
- [🔑 웹에서 API 키 설정](#-웹에서-api-키-설정)
- [🔑 AI 프로바이더 & API 키 설정](#-ai-프로바이더--api-키-설정)
- [📋 Google Docs 호환 복사](#-google-docs-호환-복사)
- [🚀 시작하기](#-시작하기)
- [Docker Compose (권장)](#docker-compose-권장)
@@ -69,14 +69,25 @@ docker compose up -d
| 템플릿 | 생성되는 구조 | 기본 강도 | 라이브 요약 |
|---|---|---|---|
| 🗂️ 회의록 | 요약 / 논의 / 액션 / 결정 | 표준 | ✅ |
| 🗂️ 회의록 | 요약 / **내용 기반 주제 섹션** / 액션 / 결정 | 표준 | ✅ |
| 🎓 강의·세미나 노트 | 핵심 개념 / 예시 / 인용 / 후속 질문 | 상세 | ✅ |
| 🤝 1:1 미팅 | 주제 / 고민 / 피드백 / 다음 액션 | 표준 | ✅ |
| 🤝 1:1 미팅 | 요지 / **내용 기반 주제 섹션** / 고민 / 피드백 / 다음 액션 | 표준 | ✅ |
| 💡 브레인스토밍 | 카테고리별 아이디어 / 즉시 시도 / 보류 | 상세 | ✅ |
| 🎤 인터뷰 | Q&A 포맷 / 인상적 발언 / 종합 | 표준 | ❌ |
| 📝 원문 정리 | 요약 없이 문단화·오탈자 정리만 | 상세 고정 | ❌ |
| ⚙️ 커스텀 | 자유 프롬프트 입력 | - | ✅ |
**내용 기반 주제 섹션**
회의록·1:1 템플릿은 `## 주요 논의 사항` 같은 고정 제목을 쓰지 않습니다. 그 회의가 실제로 무엇을
다뤘는지에 따라 AI가 섹션 제목을 직접 짓습니다 — 예를 들어 `## 단계별 개발 계획`,
`## 수익화 방안`, `## 기술적 고려사항` 처럼. 제목만 훑어도 회의 내용이 파악됩니다.
**액션 아이템 담당자**
발화에서 담당자가 파악되면 `- [ ] (김지훈) 경쟁 사이트 리스트업 — 5/17까지` 형태로 이름과 기한이 붙습니다.
담당자가 정해지지 않은 항목도 버리지 않고 그대로 수집합니다.
**강도 3단계**
- **간결** — 각 섹션 3줄 이내
- **표준** — 맥락이 이해될 정도
@@ -101,7 +112,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 +165,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 +188,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 +199,14 @@ docker compose up -d
**기능**
- 마스킹된 현재 키 표시 (`AIza••••XYZ12`)
- 보이기/숨기기 토글
- **🧪 테스트 호출** — 가벼운 Gemini 응답으로 즉시 키 검증
- 현재 키 소스 배지 (`브라우저` / `환경변수` / `없음`)
- **🧪 테스트 호출** — 가벼운 응답으로 즉시 설정 검증 (사용된 모델명 표시)
- 현재 소스 배지 (`브라우저` / `환경변수` / `없음`)
- 기존에 Gemini 키만 저장해둔 사용자는 **재입력 없이 그대로 동작** (자동 승계)
**보안**
- HTTPS 권장 (LocalStorage는 동일 출처 정책 의존)
- GET 응답에 절대 풀 키 노출 안 함
- base URL은 `http`/`https`만 허용 — 자세한 내용은 [SECURITY.md](SECURITY.md)
---
@@ -231,12 +257,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 +331,11 @@ npm run test:coverage # 커버리지 리포트
|------|------|
| 프레임워크 | Next.js 16 (App Router) + React 19 + TypeScript |
| STT (실시간) | Web Speech API — Chrome 내장, 무료 |
| AI 요약 | Gemini 2.5 Flash Lite (무료 등급 15 RPM / 1000 RPD) |
| AI 요약 | Gemini 3.5 Flash Lite (기본) · OpenAI 호환 엔드포인트 선택 가능 |
| DB | PostgreSQL 16 + Prisma 7 (driver adapter `@prisma/adapter-pg`) |
| Markdown | `marked` + `isomorphic-dompurify` |
| 스타일 | Tailwind CSS v4 |
| 테스트 | Vitest 4 (jsdom, 102 tests) |
| 테스트 | Vitest 4 (jsdom, 141 tests) |
| 배포 | Docker Compose + standalone Next.js 빌드 |
---
@@ -339,14 +366,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 +442,7 @@ prisma/
- 같은 머신을 다른 사람과 공유하지 않는 것을 가정
- 회의 transcript / 회의록은 평문으로 PostgreSQL에 저장됨 (디스크 암호화는 호스트 OS에 위임)
### Gemini API 키
### API 키
- LocalStorage 저장 (브라우저 동일 출처 정책으로 보호)
- 서버 DB에 저장되지 않음
- 요청 시점에만 body로 전송, 일회성 사용
@@ -417,10 +450,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`에서 **로컬 모델**을 선택하세요.
### 권장 배포 방식
+29 -4
View File
@@ -48,9 +48,9 @@
| 데이터 | 저장 위치 | 외부 송출 |
|---|---|---|
| 회의 transcript / 회의록 | PostgreSQL (`meetings.markdownMinutes`, `rawTranscript`) | Gemini AI 요약 활성화 시 transcript가 Google로 전송 |
| 회의 transcript / 회의록 | PostgreSQL (`meetings.markdownMinutes`, `rawTranscript`) | AI 요약 활성화 시 transcript가 **선택한 프로바이더**로 전송 |
| 액션 아이템 | PostgreSQL (`action_items`) | 외부 송출 없음 |
| Gemini API 키 | 브라우저 LocalStorage 또는 `.env` | 요청 시 Google에만 전송 |
| API 키 | 브라우저 LocalStorage 또는 `.env` | 요청 시 선택한 프로바이더에만 전송 |
| 음성 데이터 | 메모리 (실시간), `/app/uploads` (파일 업로드) | **Chrome Web Speech API → Google 서버** ⚠️ |
### ⚠️ 주의: Web Speech API의 음성 외부 전송
@@ -63,6 +63,30 @@ Chrome의 `SpeechRecognition` API는 **음성 데이터를 Google 서버로 전
이 경우 **파일 업로드 탭**도 같은 한계가 있으므로(현재 업로드 후 전사는 미구현, Phase 4에서 자체 STT 검토 예정), 이 도구의 사용을 보류하는 것을 권장합니다.
### ⚠️ 주의: AI 프로바이더 선택에 따른 전송 경로
`/settings`에서 고른 프로바이더에 따라 **회의 전문이 지나가는 회사가 달라집니다.**
| 선택 | transcript를 보게 되는 주체 |
|---|---|
| Google Gemini (기본) | Google |
| OpenAI | OpenAI |
| OrcaRouter 등 중계 라우터 | **라우터 운영사 + 라우터가 고른 실제 모델 제공사** (2단계) |
| 로컬 모델 (Ollama / LM Studio / vLLM) | **없음** — 요청이 내 머신 밖으로 나가지 않음 |
- 중계 라우터는 요청을 대신 전달하는 구조상 **평문 프롬프트를 볼 수 있는 주체가 한 곳 늘어납니다.** 로깅·보관 정책은 각 서비스 약관을 직접 확인하세요.
- 사내 컴플라이언스가 외부 전송을 제한한다면 **로컬 모델** 프리셋을 사용하세요.
### base URL은 서버가 대신 호출합니다 (SSRF 주의)
OpenAI 호환 프리셋의 base URL은 브라우저가 아니라 **Next.js Route Handler(서버)** 가 fetch 합니다.
- `http` / `https` 스킴만 허용합니다 (`normalizeBaseUrl`).
- 그 외 호스트 제한은 없습니다. 이 앱은 `127.0.0.1` 단일 사용자 실행을 전제로 하므로 의도된 설계이지만,
**앱을 LAN이나 인터넷에 노출하면 요청자가 서버 내부망 주소를 base URL로 넣어 스캔할 수 있습니다.**
- 노출 배포가 필요하다면 리버스 프록시에서 egress를 제한하거나, `LLM_BASE_URL`을 환경변수로 고정하고
요청 body의 `baseUrl`을 무시하도록 수정하세요.
---
## ✅ 구현된 보안 조치
@@ -91,9 +115,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에 넣지 않음** (로그/리퍼러 유출 방지)
---
+100
View File
@@ -4,6 +4,10 @@ import {
setStoredApiKey,
clearStoredApiKey,
maskApiKey,
getStoredProviderConfig,
setStoredProviderConfig,
clearStoredProviderConfig,
getProviderRequestPayload,
} from '@/lib/api-key-storage'
describe('LocalStorage api key helpers', () => {
@@ -48,3 +52,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')
})
})
+142 -1
View File
@@ -1,5 +1,10 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { resolveGeminiApiKey, isEnvKeyConfigured } from '@/lib/api-keys'
import {
resolveGeminiApiKey,
isEnvKeyConfigured,
isEnvProviderConfigured,
resolveProviderSettings,
} from '@/lib/api-keys'
describe('resolveGeminiApiKey', () => {
const originalEnv = process.env.GEMINI_API_KEY
@@ -70,3 +75,139 @@ describe('isEnvKeyConfigured', () => {
expect(isEnvKeyConfigured()).toBe(false)
})
})
describe('resolveProviderSettings', () => {
const ENV_KEYS = [
'GEMINI_API_KEY',
'LLM_PROVIDER',
'LLM_BASE_URL',
'LLM_API_KEY',
'LLM_MODEL',
] as const
const saved: Record<string, string | undefined> = {}
beforeEach(() => {
for (const key of ENV_KEYS) {
saved[key] = process.env[key]
delete process.env[key]
}
})
afterEach(() => {
for (const key of ENV_KEYS) {
if (saved[key] === undefined) delete process.env[key]
else process.env[key] = saved[key]
}
})
it('provider를 지정하지 않으면 gemini로 간주한다', () => {
expect(resolveProviderSettings({ apiKey: 'req-key' })).toEqual({
provider: 'gemini',
apiKey: 'req-key',
model: undefined,
})
})
it('gemini인데 키가 아무데도 없으면 null (단순 변환 폴백)', () => {
expect(resolveProviderSettings({})).toBeNull()
expect(resolveProviderSettings(null)).toBeNull()
})
it('gemini 키는 요청 → GEMINI_API_KEY 순으로 해석한다', () => {
process.env.GEMINI_API_KEY = 'env-key'
expect(resolveProviderSettings({ apiKey: 'req-key' })?.apiKey).toBe('req-key')
expect(resolveProviderSettings({})?.apiKey).toBe('env-key')
})
it('openai-compatible은 baseUrl과 model이 모두 있어야 한다', () => {
const base = { provider: 'openai-compatible', apiKey: 'sk-x' }
expect(resolveProviderSettings(base)).toBeNull()
expect(
resolveProviderSettings({ ...base, baseUrl: 'https://a.example/v1' }),
).toBeNull()
expect(
resolveProviderSettings({ ...base, model: 'gpt-4o-mini' }),
).toBeNull()
expect(
resolveProviderSettings({
...base,
baseUrl: 'https://a.example/v1',
model: 'gpt-4o-mini',
}),
).toEqual({
provider: 'openai-compatible',
apiKey: 'sk-x',
baseUrl: 'https://a.example/v1',
model: 'gpt-4o-mini',
})
})
it('openai-compatible은 키가 비어도 허용한다 (로컬 모델 서버)', () => {
const settings = resolveProviderSettings({
provider: 'openai-compatible',
apiKey: '',
baseUrl: 'http://localhost:11434/v1',
model: 'llama3.1',
})
expect(settings?.apiKey).toBe('')
})
it('LLM_* 환경변수만으로도 설정된다', () => {
process.env.LLM_PROVIDER = 'openai-compatible'
process.env.LLM_BASE_URL = 'https://api.orcarouter.ai/v1'
process.env.LLM_MODEL = 'google/gemini-2.5-flash-lite'
process.env.LLM_API_KEY = 'sk-env'
expect(resolveProviderSettings({})).toEqual({
provider: 'openai-compatible',
apiKey: 'sk-env',
baseUrl: 'https://api.orcarouter.ai/v1',
model: 'google/gemini-2.5-flash-lite',
})
})
it('알 수 없는 provider 값은 무시하고 기본값으로 떨어진다', () => {
process.env.GEMINI_API_KEY = 'env-key'
expect(resolveProviderSettings({ provider: 'anthropic' })?.provider).toBe(
'gemini',
)
})
})
describe('isEnvProviderConfigured', () => {
const ENV_KEYS = ['GEMINI_API_KEY', 'LLM_PROVIDER', 'LLM_BASE_URL', 'LLM_MODEL', 'LLM_API_KEY'] as const
const saved: Record<string, string | undefined> = {}
beforeEach(() => {
for (const key of ENV_KEYS) {
saved[key] = process.env[key]
delete process.env[key]
}
})
afterEach(() => {
for (const key of ENV_KEYS) {
if (saved[key] === undefined) delete process.env[key]
else process.env[key] = saved[key]
}
})
it('아무 것도 없으면 false', () => {
expect(isEnvProviderConfigured()).toBe(false)
})
it('GEMINI_API_KEY만 있어도 true', () => {
process.env.GEMINI_API_KEY = 'k'
expect(isEnvProviderConfigured()).toBe(true)
})
it('openai-compatible은 base URL과 모델까지 있어야 true', () => {
process.env.LLM_PROVIDER = 'openai-compatible'
process.env.LLM_API_KEY = 'k'
expect(isEnvProviderConfigured()).toBe(false)
process.env.LLM_BASE_URL = 'https://a.example/v1'
process.env.LLM_MODEL = 'm'
expect(isEnvProviderConfigured()).toBe(true)
})
})
+159 -1
View File
@@ -1,5 +1,8 @@
import { describe, it, expect, vi } from 'vitest'
import { generateLiveSummary } from '@/lib/live-summary'
import {
generateLiveSummary,
planLiveSummaryRequest,
} from '@/lib/live-summary'
describe('generateLiveSummary', () => {
it('Gemini API 응답을 받아 중간 요약 마크다운을 반환한다', async () => {
@@ -128,3 +131,158 @@ describe('generateLiveSummary', () => {
expect(init.headers['x-goog-api-key']).toBe('secret-key')
})
})
describe('generateLiveSummary — openai-compatible', () => {
it('OpenAI 호환 엔드포인트로 중간 요약을 만든다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: '## 요약\n진행 중' } }],
}),
})
const result = await generateLiveSummary('회의 내용', {
provider: {
provider: 'openai-compatible',
apiKey: 'sk-test',
baseUrl: 'http://localhost:11434/v1',
model: 'llama3.1',
},
fetchFn: mockFetch,
})
expect(result.success).toBe(true)
expect(mockFetch.mock.calls[0][0]).toBe(
'http://localhost:11434/v1/chat/completions',
)
})
})
describe('planLiveSummaryRequest', () => {
const base = {
totalChunks: 50,
lastSummarizedIndex: 30,
incrementsSinceFull: 3,
fullRefreshEvery: 20,
hasPreviousSummary: true,
}
it('평상시에는 직전 지점부터 증분으로 보낸다', () => {
expect(planLiveSummaryRequest(base)).toEqual({
mode: 'incremental',
startIndex: 30,
})
})
it('첫 호출은 전체를 보낸다', () => {
expect(
planLiveSummaryRequest({ ...base, lastSummarizedIndex: 0 }),
).toEqual({ mode: 'full', startIndex: 0 })
})
it('갱신할 직전 요약이 없으면 전체를 보낸다', () => {
expect(
planLiveSummaryRequest({ ...base, hasPreviousSummary: false }),
).toEqual({ mode: 'full', startIndex: 0 })
})
it('증분이 누적되면 전체 재요약으로 오차를 끊는다', () => {
expect(
planLiveSummaryRequest({ ...base, incrementsSinceFull: 20 }),
).toEqual({ mode: 'full', startIndex: 0 })
})
it('fullRefreshEvery=0이면 전체 재요약을 하지 않는다', () => {
expect(
planLiveSummaryRequest({
...base,
fullRefreshEvery: 0,
incrementsSinceFull: 999,
}).mode,
).toBe('incremental')
})
it('전사가 초기화되어 인덱스가 범위를 벗어나면 전체를 보낸다', () => {
expect(
planLiveSummaryRequest({ ...base, totalChunks: 5 }),
).toEqual({ mode: 'full', startIndex: 0 })
})
})
describe('generateLiveSummary — 증분 모드', () => {
function mockOk(text = '## 요약\n갱신됨') {
return vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
candidates: [{ content: { parts: [{ text }] } }],
}),
})
}
function sentPrompt(mockFetch: ReturnType<typeof vi.fn>): string {
const body = JSON.parse(mockFetch.mock.calls[0][1].body)
return body.contents[0].parts[0].text
}
it('previousSummary가 있으면 요약과 신규 발화를 나눠 전달한다', async () => {
const mockFetch = mockOk()
await generateLiveSummary('새로 나온 이야기', {
apiKey: 'k',
previousSummary: '## 요약\n이전까지의 내용',
fetchFn: mockFetch,
})
const prompt = sentPrompt(mockFetch)
expect(prompt).toContain('[지금까지의 요약]')
expect(prompt).toContain('이전까지의 내용')
expect(prompt).toContain('[새로 추가된 발화]')
expect(prompt).toContain('새로 나온 이야기')
expect(prompt).toContain('갱신')
})
it('previousSummary가 없으면 기존 전체 요약 형식을 유지한다', async () => {
const mockFetch = mockOk()
await generateLiveSummary('전체 전사', { apiKey: 'k', fetchFn: mockFetch })
const prompt = sentPrompt(mockFetch)
expect(prompt).toContain('음성 인식 텍스트:')
expect(prompt).not.toContain('[지금까지의 요약]')
})
it('빈 문자열 previousSummary는 증분으로 취급하지 않는다', async () => {
const mockFetch = mockOk()
await generateLiveSummary('전체 전사', {
apiKey: 'k',
previousSummary: ' ',
fetchFn: mockFetch,
})
expect(sentPrompt(mockFetch)).not.toContain('[지금까지의 요약]')
})
it('긴 회의에서 증분 프롬프트가 전체 프롬프트보다 짧다', async () => {
const longTranscript = '회의 발화 한 줄입니다.\n'.repeat(500)
const fullFetch = mockOk()
await generateLiveSummary(longTranscript, {
apiKey: 'k',
fetchFn: fullFetch,
})
const incFetch = mockOk()
await generateLiveSummary('마지막 30초에 나온 이야기', {
apiKey: 'k',
previousSummary: '## 요약\n지금까지의 요약 본문',
fetchFn: incFetch,
})
expect(sentPrompt(incFetch).length).toBeLessThan(
sentPrompt(fullFetch).length / 5,
)
})
})
+47
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest'
import {
generateSimpleMinutes,
generateAiMinutes,
generateGeminiMinutes,
type MinutesInput,
} from '@/lib/minutes-generator'
@@ -105,3 +106,49 @@ describe('generateGeminiMinutes', () => {
}
})
})
describe('generateAiMinutes — openai-compatible', () => {
const settings = {
provider: 'openai-compatible' as const,
apiKey: 'sk-test',
baseUrl: 'https://api.orcarouter.ai/v1',
model: 'google/gemini-2.5-flash-lite',
}
it('OpenAI 호환 엔드포인트 응답으로 회의록을 만든다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
choices: [{ message: { content: '## 요약\n- 리팩토링 완료' } }],
}),
})
const result = await generateAiMinutes(sampleInput, {
provider: settings,
fetchFn: mockFetch,
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.markdown).toContain('# 주간 스프린트 회의')
expect(result.markdown).toContain('리팩토링 완료')
// 하단 문구는 실제 사용한 모델을 표기한다
expect(result.markdown).toContain('google/gemini-2.5-flash-lite')
}
})
it('응답이 비어 있으면 실패로 처리해 단순 변환 폴백을 유도한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: ' ' } }] }),
})
const result = await generateAiMinutes(sampleInput, {
provider: settings,
fetchFn: mockFetch,
})
expect(result.success).toBe(false)
})
})
+222
View File
@@ -0,0 +1,222 @@
import { describe, it, expect, vi } from 'vitest'
import {
complete,
describeModel,
normalizeBaseUrl,
toProviderSettings,
DEFAULT_GEMINI_MODEL,
findPreset,
PROVIDER_PRESETS,
} from '@/lib/providers'
function okResponse(content: string) {
return {
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content } }] }),
}
}
const OPENAI_SETTINGS = {
provider: 'openai-compatible' as const,
apiKey: 'sk-test',
baseUrl: 'https://api.orcarouter.ai/v1',
model: 'google/gemini-2.5-flash-lite',
}
describe('normalizeBaseUrl', () => {
it('끝의 슬래시를 제거한다', () => {
expect(normalizeBaseUrl('https://api.example.com/v1/')).toBe(
'https://api.example.com/v1',
)
})
it('앞뒤 공백을 제거한다', () => {
expect(normalizeBaseUrl(' https://api.example.com/v1 ')).toBe(
'https://api.example.com/v1',
)
})
it('http/https 이외의 스킴은 거부한다', () => {
expect(normalizeBaseUrl('file:///etc/passwd')).toBeNull()
expect(normalizeBaseUrl('ftp://example.com')).toBeNull()
})
it('URL이 아니거나 비어 있으면 null', () => {
expect(normalizeBaseUrl('not-a-url')).toBeNull()
expect(normalizeBaseUrl(' ')).toBeNull()
})
})
describe('complete — openai-compatible', () => {
it('/chat/completions로 OpenAI 형식 요청을 보낸다', async () => {
const mockFetch = vi.fn().mockResolvedValue(okResponse('## 요약'))
const result = await complete('프롬프트', OPENAI_SETTINGS, {
fetchFn: mockFetch,
})
expect(result.success).toBe(true)
if (result.success) expect(result.text).toBe('## 요약')
const [url, init] = mockFetch.mock.calls[0]
expect(url).toBe('https://api.orcarouter.ai/v1/chat/completions')
expect(init.headers.Authorization).toBe('Bearer sk-test')
const body = JSON.parse(init.body)
expect(body.model).toBe('google/gemini-2.5-flash-lite')
expect(body.messages).toEqual([{ role: 'user', content: '프롬프트' }])
})
it('키가 없으면 Authorization 헤더를 붙이지 않는다 (로컬 모델 서버)', async () => {
const mockFetch = vi.fn().mockResolvedValue(okResponse('요약'))
await complete(
'프롬프트',
{ ...OPENAI_SETTINGS, apiKey: '', baseUrl: 'http://localhost:11434/v1' },
{ fetchFn: mockFetch },
)
const [, init] = mockFetch.mock.calls[0]
expect(init.headers.Authorization).toBeUndefined()
})
it('base URL이 없으면 호출하지 않고 에러를 반환한다', async () => {
const mockFetch = vi.fn()
const result = await complete(
'프롬프트',
{ ...OPENAI_SETTINGS, baseUrl: '' },
{ fetchFn: mockFetch },
)
expect(result.success).toBe(false)
expect(mockFetch).not.toHaveBeenCalled()
})
it('모델이 없으면 호출하지 않고 에러를 반환한다', async () => {
const mockFetch = vi.fn()
const result = await complete(
'프롬프트',
{ ...OPENAI_SETTINGS, model: ' ' },
{ fetchFn: mockFetch },
)
expect(result.success).toBe(false)
if (!result.success) expect(result.error).toContain('모델')
expect(mockFetch).not.toHaveBeenCalled()
})
it('429는 rateLimited 플래그를 세운다', async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 429 })
const result = await complete('프롬프트', OPENAI_SETTINGS, {
fetchFn: mockFetch,
})
expect(result.success).toBe(false)
if (!result.success) expect(result.rateLimited).toBe(true)
})
it('401은 인증 실패로 안내한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 401 })
const result = await complete('프롬프트', OPENAI_SETTINGS, {
fetchFn: mockFetch,
})
expect(result.success).toBe(false)
if (!result.success) expect(result.error).toContain('인증')
})
it('네트워크 예외를 결과 객체로 감싼다', async () => {
const mockFetch = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))
const result = await complete('프롬프트', OPENAI_SETTINGS, {
fetchFn: mockFetch,
})
expect(result.success).toBe(false)
if (!result.success) expect(result.error).toContain('ECONNREFUSED')
})
})
describe('complete — gemini 라우팅', () => {
it('provider가 gemini면 Google 엔드포인트를 호출한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
candidates: [{ content: { parts: [{ text: '요약' }] } }],
}),
})
await complete(
'프롬프트',
{ provider: 'gemini', apiKey: 'AIza-test' },
{ fetchFn: mockFetch },
)
const [url, init] = mockFetch.mock.calls[0]
expect(url).toContain('generativelanguage.googleapis.com')
expect(url).toContain(DEFAULT_GEMINI_MODEL)
expect(init.headers['x-goog-api-key']).toBe('AIza-test')
})
it('모델을 지정하면 해당 모델 엔드포인트를 호출한다', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
candidates: [{ content: { parts: [{ text: '요약' }] } }],
}),
})
await complete(
'프롬프트',
{ provider: 'gemini', apiKey: 'AIza-test', model: 'gemini-2.5-pro' },
{ fetchFn: mockFetch },
)
expect(mockFetch.mock.calls[0][0]).toContain('gemini-2.5-pro')
})
})
describe('toProviderSettings', () => {
it('provider가 없으면 기존 Gemini 호출부와 동일하게 동작한다', () => {
expect(toProviderSettings(undefined, 'legacy-key')).toEqual({
provider: 'gemini',
apiKey: 'legacy-key',
})
})
it('provider가 있으면 그대로 사용한다', () => {
expect(toProviderSettings(OPENAI_SETTINGS, 'ignored')).toBe(OPENAI_SETTINGS)
})
})
describe('describeModel', () => {
it('gemini는 기본 모델명을 돌려준다', () => {
expect(describeModel({ provider: 'gemini', apiKey: 'k' })).toBe(
DEFAULT_GEMINI_MODEL,
)
})
it('openai-compatible은 설정한 모델명을 돌려준다', () => {
expect(describeModel(OPENAI_SETTINGS)).toBe('google/gemini-2.5-flash-lite')
})
})
describe('프리셋', () => {
it('알 수 없는 id는 기본 프리셋으로 폴백한다', () => {
expect(findPreset('없는-프리셋').id).toBe('gemini')
expect(findPreset(null).id).toBe('gemini')
})
it('openai-compatible 프리셋은 base URL 또는 직접 입력 안내를 갖는다', () => {
for (const preset of PROVIDER_PRESETS) {
if (preset.provider !== 'openai-compatible') continue
expect(preset.id === 'custom' || preset.baseUrl.length > 0).toBe(true)
}
})
})
+116
View File
@@ -164,3 +164,119 @@ describe('liveEnabledFor / depthAdjustableFor', () => {
expect(depthAdjustableFor('custom')).toBe(false)
})
})
describe('내용 기반 섹션 구조', () => {
const transcript = '이번에는 가격 비교 사이트를 만들어 봅시다'
it('meeting은 섹션 제목을 직접 짓도록 지시한다', () => {
const prompt = buildPrompt({
templateId: 'meeting',
depth: 'standard',
transcript,
})
expect(prompt).toContain('섹션 제목을 직접 지어서')
// 고정 제목을 쓰지 말라는 지시가 함께 있어야 한다
expect(prompt).toContain('일반적인 제목은 쓰지 마세요')
})
it('one_on_one도 주제별 제목을 직접 짓도록 지시한다', () => {
const prompt = buildPrompt({
templateId: 'one_on_one',
depth: 'standard',
transcript,
})
expect(prompt).toContain('제목을 직접 지어')
})
it('raw는 구조적 제목을 덧붙이지 말라는 지시를 유지한다', () => {
const prompt = buildPrompt({
templateId: 'raw',
depth: 'detailed',
transcript,
})
expect(prompt).toContain('구조적 제목(## 섹션)을 덧붙이지 마세요')
})
})
describe('액션 아이템 담당자 표기', () => {
const transcript = '다음 주까지 정리해 주세요'
it('담당자 표기를 요구한다', () => {
const prompt = buildPrompt({
templateId: 'meeting',
depth: 'standard',
transcript,
})
expect(prompt).toContain('(담당자)')
})
it('담당자가 불분명해도 항목을 버리지 않도록 지시한다', () => {
const prompt = buildPrompt({
templateId: 'meeting',
depth: 'standard',
transcript,
})
expect(prompt).toContain('담당자가 불분명해도')
})
it('체크박스 형식을 유지해 액션 아이템 파서와 호환된다', () => {
for (const id of ['meeting', 'one_on_one', 'brainstorm'] as const) {
const prompt = buildPrompt({ templateId: id, depth: 'standard', transcript })
expect(prompt).toContain('- [ ]')
}
})
})
describe('공통 규칙', () => {
const transcript = '테스트 발화'
it('프리셋 템플릿 전체에 공통 규칙이 붙는다', () => {
for (const id of Object.keys(TEMPLATES) as (keyof typeof TEMPLATES)[]) {
const prompt = buildPrompt({ templateId: id, depth: 'standard', transcript })
expect(prompt).toContain('지어내지 마세요')
expect(prompt).toContain('한국어로 작성하세요')
}
})
it('발화자 표기가 있으면 활용하도록 지시한다', () => {
const prompt = buildPrompt({
templateId: 'meeting',
depth: 'standard',
transcript,
})
expect(prompt).toContain('발화자 표기가 있다면')
})
it('영어 기술 용어는 원문 표기를 유지하도록 지시한다', () => {
const prompt = buildPrompt({
templateId: 'meeting',
depth: 'standard',
transcript,
})
expect(prompt).toContain('원문 표기를 유지하세요')
})
it('custom 프롬프트에는 공통 규칙을 덧붙이지 않는다', () => {
const prompt = buildPrompt({
templateId: 'custom',
depth: 'standard',
transcript,
customPrompt: '한 줄로만 요약해줘',
})
expect(prompt).toContain('한 줄로만 요약해줘')
expect(prompt).not.toContain('지어내지 마세요')
})
it('custom 프롬프트가 비어 있으면 meeting 템플릿 + 공통 규칙으로 대체한다', () => {
const prompt = buildPrompt({
templateId: 'custom',
depth: 'standard',
transcript,
customPrompt: ' ',
})
expect(prompt).toContain('회의록 작성 전문가')
expect(prompt).toContain('지어내지 마세요')
// 기존 동작 유지 — custom 경로에는 강도/라이브 모디파이어를 적용하지 않는다
expect(prompt).not.toContain('작성 강도')
})
})
+2 -1
View File
@@ -1,9 +1,10 @@
import { isEnvKeyConfigured } from '@/lib/api-keys'
import { isEnvKeyConfigured, isEnvProviderConfigured } from '@/lib/api-keys'
export const dynamic = 'force-dynamic'
export async function GET() {
return Response.json({
envConfigured: isEnvKeyConfigured(),
envProviderConfigured: isEnvProviderConfigured(),
})
}
+11 -38
View File
@@ -1,5 +1,6 @@
import { NextRequest } from 'next/server'
import { resolveGeminiApiKey } from '@/lib/api-keys'
import { resolveProviderSettings } from '@/lib/api-keys'
import { complete, describeModel } from '@/lib/providers'
export const dynamic = 'force-dynamic'
@@ -8,60 +9,32 @@ const TEST_PROMPT = '"OK"라고만 한 단어로 답하세요.'
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}))
const apiKey = resolveGeminiApiKey(body.apiKey)
const settings = resolveProviderSettings(body)
if (!apiKey) {
if (!settings) {
return Response.json(
{
ok: false,
error: '키가 비어 있습니다.',
error: '설정이 비어 있습니다. 키(또는 base URL과 모델)를 확인해주세요.',
},
{ status: 400 },
)
}
const url =
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
const result = await complete(TEST_PROMPT, settings)
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify({
contents: [{ parts: [{ text: TEST_PROMPT }] }],
}),
})
if (!response.ok) {
const errorText = await response.text().catch(() => '')
const message =
response.status === 400
? '잘못된 API 키 형식입니다.'
: response.status === 403
? '권한이 거부되었습니다. 키를 확인해주세요.'
: response.status === 429
? '요청 한도를 초과했지만 키 자체는 유효해 보입니다.'
: `Gemini API 오류: ${response.status}`
if (!result.success) {
return Response.json(
{
ok: false,
error: message,
detail: errorText.slice(0, 200),
},
{ ok: false, error: result.error },
{ status: 200 },
)
}
const data = await response.json()
const reply: string =
data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? ''
return Response.json({
ok: true,
message: '키가 정상적으로 동작합니다.',
reply: reply.slice(0, 50),
message: '설정이 정상적으로 동작합니다.',
model: describeModel(settings),
reply: result.text.trim().slice(0, 50),
})
} catch (err) {
const message = err instanceof Error ? err.message : '알 수 없는 오류'
+14 -6
View File
@@ -1,14 +1,15 @@
import { NextRequest } from 'next/server'
import { generateLiveSummary } from '@/lib/live-summary'
import { resolveGeminiApiKey } from '@/lib/api-keys'
import { resolveProviderSettings } from '@/lib/api-keys'
import type { SummaryDepth, TemplateId } from '@/lib/templates'
const MAX_TRANSCRIPT_CHARS = 40_000
const MAX_PREVIOUS_SUMMARY_CHARS = 8_000
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { transcript, template, depth, customPrompt, apiKey } = body
const { transcript, template, depth, customPrompt, previousSummary } = body
if (!transcript || typeof transcript !== 'string') {
return Response.json(
@@ -17,12 +18,12 @@ export async function POST(request: NextRequest) {
)
}
const resolvedKey = resolveGeminiApiKey(apiKey)
if (!resolvedKey) {
const settings = resolveProviderSettings(body)
if (!settings) {
return Response.json(
{
error:
'Gemini API 키가 설정되지 않았습니다. /settings에서 키를 입력해주세요.',
'AI 프로바이더가 설정되지 않았습니다. /settings에서 키를 입력해주세요.',
},
{ status: 503 },
)
@@ -33,8 +34,15 @@ export async function POST(request: NextRequest) {
? transcript.slice(-MAX_TRANSCRIPT_CHARS)
: transcript
// 증분 모드: 직전 요약 + 새 발화만 보내므로 호출당 토큰이 일정하다.
const previous =
typeof previousSummary === 'string'
? previousSummary.slice(-MAX_PREVIOUS_SUMMARY_CHARS)
: undefined
const result = await generateLiveSummary(truncated, {
apiKey: resolvedKey,
provider: settings,
previousSummary: previous,
template: (template as TemplateId | undefined) ?? 'meeting',
depth: depth as SummaryDepth | undefined,
customPrompt:
+10 -18
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -126,7 +126,7 @@ export function MinutesViewer({
}
}
const modeLabel = mode === 'gemini' ? 'Gemini AI 요약' : '단순 변환'
const modeLabel = mode === 'gemini' ? 'AI 요약' : '단순 변환'
return (
<div className="space-y-4">
+39 -5
View File
@@ -4,13 +4,16 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import type { TranscriptChunk } from '@/lib/transcript-formatter'
import { formatTranscriptChunks } from '@/lib/transcript-formatter'
import type { SummaryDepth, TemplateId } from '@/lib/templates'
import { getStoredApiKey } from '@/lib/api-key-storage'
import { getProviderRequestPayload } from '@/lib/api-key-storage'
import { planLiveSummaryRequest } from '@/lib/live-summary'
interface UseLiveSummaryOptions {
enabled: boolean
pollIntervalMs?: number
minWords?: number
incrementWords?: number
/** 증분 요약을 이 횟수만큼 반복하면 전사 전체로 한 번 다시 요약한다. 0이면 끈다. */
fullRefreshEvery?: number
template?: TemplateId
depth?: SummaryDepth
customPrompt?: string
@@ -41,6 +44,7 @@ export function useLiveSummary(
pollIntervalMs = 30_000,
minWords = 25,
incrementWords = 40,
fullRefreshEvery = 20,
template = 'meeting',
depth,
customPrompt,
@@ -64,6 +68,11 @@ export function useLiveSummary(
const cooldownUntilRef = useRef<number | null>(null)
const consecutiveFailuresRef = useRef(0)
// 증분 요약 상태 — 성공했을 때만 전진시켜서 실패해도 발화를 잃지 않는다.
const summaryRef = useRef('')
const lastSummarizedIndexRef = useRef(0)
const incrementsSinceFullRef = useRef(0)
useEffect(() => {
chunksRef.current = chunks
}, [chunks])
@@ -98,12 +107,31 @@ export function useLiveSummary(
return
}
const transcript = formatTranscriptChunks(chunksRef.current)
const allChunks = chunksRef.current
const transcript = formatTranscriptChunks(allChunks)
const currentWordCount = countWords(transcript)
if (currentWordCount < minWords) return
if (currentWordCount - lastWordCountRef.current < incrementWords) return
const plan = planLiveSummaryRequest({
totalChunks: allChunks.length,
lastSummarizedIndex: lastSummarizedIndexRef.current,
incrementsSinceFull: incrementsSinceFullRef.current,
fullRefreshEvery,
hasPreviousSummary: summaryRef.current.trim().length > 0,
})
// 요청을 보내는 시점의 길이를 고정해 둔다. 응답을 기다리는 동안
// 새 청크가 쌓여도 그 부분은 다음 회차로 넘어간다.
const chunkCountAtSend = allChunks.length
const payloadTranscript =
plan.mode === 'full'
? transcript
: formatTranscriptChunks(allChunks.slice(plan.startIndex))
if (payloadTranscript.trim().length === 0) return
const controller = new AbortController()
abortRef.current = controller
inFlightRef.current = true
@@ -115,11 +143,13 @@ export function useLiveSummary(
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transcript,
transcript: payloadTranscript,
previousSummary:
plan.mode === 'incremental' ? summaryRef.current : undefined,
template: configRef.current.template,
depth: configRef.current.depth,
customPrompt: configRef.current.customPrompt,
apiKey: getStoredApiKey(),
...getProviderRequestPayload(),
}),
signal: controller.signal,
})
@@ -142,8 +172,12 @@ export function useLiveSummary(
const data = await res.json()
setSummary(data.markdown)
summaryRef.current = data.markdown
setLastUpdatedAt(Date.now())
lastWordCountRef.current = currentWordCount
lastSummarizedIndexRef.current = chunkCountAtSend
incrementsSinceFullRef.current =
plan.mode === 'full' ? 0 : incrementsSinceFullRef.current + 1
consecutiveFailuresRef.current = 0
cooldownUntilRef.current = null
setCooldownUntil(null)
@@ -164,7 +198,7 @@ export function useLiveSummary(
return () => {
clearInterval(interval)
}
}, [enabled, pollIntervalMs, minWords, incrementWords])
}, [enabled, pollIntervalMs, minWords, incrementWords, fullRefreshEvery])
useEffect(() => {
return () => {
+85
View File
@@ -1,5 +1,7 @@
'use client'
import { findPreset, type ProviderId } from './providers'
const STORAGE_KEY = 'meeting-minutes:gemini-api-key'
export function getStoredApiKey(): string | null {
@@ -34,3 +36,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,
}
}
+69
View File
@@ -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()
}
+84 -46
View File
@@ -1,3 +1,4 @@
import { complete, toProviderSettings, type ProviderSettings } from './providers'
import {
buildPrompt,
resolveDepth,
@@ -10,7 +11,17 @@ type LiveSummaryResult =
| { success: false; error: string; rateLimited?: boolean }
interface LiveSummaryOptions {
apiKey: string
/** 프로바이더 설정. 생략하면 apiKey로 Gemini를 호출한다. */
provider?: ProviderSettings
apiKey?: string
/**
* 직전 롤링 요약.
*
* 값이 있으면 증분 모드로 동작하며, 이때 `transcript` 인자는 전사 전체가 아니라
* **직전 요약 이후 새로 추가된 발화**만 담아야 한다. 호출당 토큰이 회의 길이와
* 무관하게 일정해진다.
*/
previousSummary?: string
template?: TemplateId
depth?: SummaryDepth
customPrompt?: string
@@ -21,67 +32,94 @@ export async function generateLiveSummary(
transcript: string,
options: LiveSummaryOptions,
): Promise<LiveSummaryResult> {
const { apiKey, fetchFn = fetch } = options
if (!apiKey || apiKey.trim().length === 0) {
return { success: false, error: 'Gemini API 키가 필요합니다.' }
}
const { fetchFn } = options
const settings = toProviderSettings(options.provider, options.apiKey)
const trimmed = transcript.trim()
if (trimmed.length === 0) {
return { success: false, error: '요약할 텍스트가 비어 있습니다.' }
}
const previous = options.previousSummary?.trim() ?? ''
const incremental = previous.length > 0
const templateId = options.template ?? 'meeting'
const depth = resolveDepth(templateId, options.depth)
const prompt = buildPrompt({
templateId,
depth,
transcript: trimmed,
transcript: incremental
? `[지금까지의 요약]\n${previous}\n\n[새로 추가된 발화]\n${trimmed}`
: trimmed,
live: true,
incremental,
customPrompt: options.customPrompt,
})
const url =
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-lite:generateContent'
const result = await complete(prompt, settings, { fetchFn })
try {
const response = await fetchFn(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
}),
})
if (!response.ok) {
if (response.status === 429) {
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 }
}
+37 -45
View File
@@ -1,3 +1,9 @@
import {
complete,
describeModel,
toProviderSettings,
type ProviderSettings,
} from './providers'
import {
buildPrompt,
resolveDepth,
@@ -14,12 +20,14 @@ export interface MinutesInput {
customPrompt?: string
}
type GeminiResult =
type AiMinutesResult =
| { success: true; markdown: string }
| { success: false; error: string }
| { success: false; error: string; rateLimited?: boolean }
interface GeminiOptions {
apiKey: string
interface AiMinutesOptions {
/** 프로바이더 설정. 생략하면 apiKey로 Gemini를 호출한다. */
provider?: ProviderSettings
apiKey?: string
fetchFn?: typeof fetch
}
@@ -49,15 +57,12 @@ ${content}
`
}
export async function generateGeminiMinutes(
export async function generateAiMinutes(
input: MinutesInput,
options: GeminiOptions,
): Promise<GeminiResult> {
const { apiKey, fetchFn = fetch } = options
if (!apiKey || apiKey.trim().length === 0) {
return { success: false, error: 'Gemini API 키가 필요합니다.' }
}
options: AiMinutesOptions,
): Promise<AiMinutesResult> {
const { fetchFn } = options
const settings = toProviderSettings(options.provider, options.apiKey)
const templateId = input.template ?? 'meeting'
const depth = resolveDepth(templateId, input.depth)
@@ -69,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
+76
View File
@@ -0,0 +1,76 @@
import type {
CompletionOptions,
CompletionResult,
ProviderSettings,
} from './types'
export const DEFAULT_GEMINI_MODEL = 'gemini-3.5-flash-lite'
const API_ROOT = 'https://generativelanguage.googleapis.com/v1beta/models'
export function resolveGeminiModel(model?: string): string {
const trimmed = model?.trim() ?? ''
return trimmed.length > 0 ? trimmed : DEFAULT_GEMINI_MODEL
}
/**
* Google Generative Language API 직접 호출.
* 키는 URL이 아닌 `x-goog-api-key` 헤더로 보낸다 (로그/리퍼러 유출 방지).
*/
export async function completeWithGemini(
prompt: string,
settings: ProviderSettings,
options: CompletionOptions = {},
): Promise<CompletionResult> {
const { fetchFn = fetch } = options
const apiKey = settings.apiKey.trim()
if (apiKey.length === 0) {
return { success: false, error: 'Gemini API 키가 필요합니다.' }
}
const model = resolveGeminiModel(settings.model)
const url = `${API_ROOT}/${encodeURIComponent(model)}:generateContent`
try {
const response = await fetchFn(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': apiKey,
},
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }],
}),
})
if (!response.ok) {
if (response.status === 429) {
return {
success: false,
error: 'Gemini API 요청 한도 초과. 잠시 후 자동 재시도됩니다.',
rateLimited: true,
}
}
return { success: false, error: describeHttpError(response.status) }
}
const data = await response.json()
const text: string = data?.candidates?.[0]?.content?.parts?.[0]?.text ?? ''
return { success: true, text }
} catch (err) {
const message = err instanceof Error ? err.message : '알 수 없는 오류'
return { success: false, error: `Gemini API 호출 중 오류: ${message}` }
}
}
function describeHttpError(status: number): string {
if (status === 400) {
return 'Gemini API 오류: 잘못된 요청 또는 키 형식입니다 (400).'
}
if (status === 401 || status === 403) {
return `Gemini API 인증 실패 (${status}). 키를 확인해주세요.`
}
return `Gemini API 호출 실패: ${status}`
}
+43
View File
@@ -0,0 +1,43 @@
import { completeWithGemini, resolveGeminiModel } from './gemini'
import { completeWithOpenAICompatible } from './openai-compatible'
import type {
CompletionOptions,
CompletionResult,
ProviderSettings,
} from './types'
export * from './types'
export * from './presets'
export { DEFAULT_GEMINI_MODEL, resolveGeminiModel } from './gemini'
export { normalizeBaseUrl } from './openai-compatible'
/**
* 설정된 프로바이더로 프롬프트 1회 호출.
* 실패는 예외 대신 `{ success: false }`로 돌려주므로 호출부에서 폴백하기 쉽다.
*/
export async function complete(
prompt: string,
settings: ProviderSettings,
options: CompletionOptions = {},
): Promise<CompletionResult> {
if (settings.provider === 'openai-compatible') {
return completeWithOpenAICompatible(prompt, settings, options)
}
return completeWithGemini(prompt, settings, options)
}
/** 회의록 하단 문구 등에 쓸 모델 표기. */
export function describeModel(settings: ProviderSettings): string {
if (settings.provider === 'openai-compatible') {
return settings.model?.trim() || '알 수 없는 모델'
}
return resolveGeminiModel(settings.model)
}
/** provider 설정이 없으면 기존 Gemini 전용 호출부와 동일하게 동작시킨다. */
export function toProviderSettings(
settings: ProviderSettings | undefined,
apiKey: string | undefined,
): ProviderSettings {
return settings ?? { provider: 'gemini', apiKey: apiKey ?? '' }
}
+112
View File
@@ -0,0 +1,112 @@
import type {
CompletionOptions,
CompletionResult,
ProviderSettings,
} from './types'
/**
* base URL 검증.
*
* 이 값은 사용자가 설정 화면에서 입력하고 서버(Route Handler)가 그대로 fetch 하므로,
* http/https 이외의 스킴은 거부한다. 앱을 localhost 밖으로 노출한다면
* SECURITY.md의 "외부 엔드포인트" 항목을 먼저 확인할 것.
*/
export function normalizeBaseUrl(baseUrl: string): string | null {
const trimmed = baseUrl.trim().replace(/\/+$/, '')
if (trimmed.length === 0) return null
let parsed: URL
try {
parsed = new URL(trimmed)
} catch {
return null
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null
return trimmed
}
/**
* OpenAI Chat Completions 호환 엔드포인트 호출.
* OrcaRouter · OpenAI · Ollama · LM Studio · vLLM 등이 모두 이 형식을 따른다.
*/
export async function completeWithOpenAICompatible(
prompt: string,
settings: ProviderSettings,
options: CompletionOptions = {},
): Promise<CompletionResult> {
const { fetchFn = fetch } = options
const baseUrl = normalizeBaseUrl(settings.baseUrl ?? '')
if (!baseUrl) {
return {
success: false,
error: 'API 주소(base URL)가 올바르지 않습니다. http:// 또는 https:// 로 시작해야 합니다.',
}
}
const model = settings.model?.trim() ?? ''
if (model.length === 0) {
return { success: false, error: '모델 이름이 필요합니다.' }
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
// 로컬 모델 서버(Ollama/LM Studio)는 키 없이 동작하므로 키가 없어도 호출한다.
//
// NOTE: 일부 라우터는 "이 요청이 어느 앱에서 왔는지" 식별하는 헤더를 받는다
// (OpenRouter의 HTTP-Referer / X-Title 등). 특정 서비스와 제휴해 트래픽을
// 귀속시키려면 그 서비스가 공식 문서로 밝힌 헤더를 여기에 추가하면 된다.
// 문서화되지 않은 헤더를 추측해서 보내지는 않는다.
const apiKey = settings.apiKey.trim()
if (apiKey.length > 0) {
headers.Authorization = `Bearer ${apiKey}`
}
try {
const response = await fetchFn(`${baseUrl}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify({
model,
messages: [{ role: 'user', content: prompt }],
stream: false,
}),
})
if (!response.ok) {
if (response.status === 429) {
return {
success: false,
error: 'API 요청 한도 초과. 잠시 후 자동 재시도됩니다.',
rateLimited: true,
}
}
return { success: false, error: describeHttpError(response.status) }
}
const data = await response.json()
const text: string = data?.choices?.[0]?.message?.content ?? ''
return { success: true, text }
} catch (err) {
const message = err instanceof Error ? err.message : '알 수 없는 오류'
return { success: false, error: `API 호출 중 오류: ${message}` }
}
}
function describeHttpError(status: number): string {
if (status === 401 || status === 403) {
return `인증에 실패했습니다 (${status}). API 키를 확인해주세요.`
}
if (status === 404) {
return `엔드포인트를 찾을 수 없습니다 (404). base URL과 모델 이름을 확인해주세요.`
}
if (status === 402) {
return '크레딧이 부족합니다 (402). 프로바이더 잔액을 확인해주세요.'
}
return `API 호출 실패: ${status}`
}
+98
View File
@@ -0,0 +1,98 @@
import { DEFAULT_GEMINI_MODEL } from './gemini'
import type { ProviderId } from './types'
export interface ProviderPreset {
id: string
label: string
provider: ProviderId
/** openai-compatible 프리셋의 기본 base URL. 사용자가 수정할 수 있다. */
baseUrl: string
defaultModel: string
description: string
apiKeyLabel: string
apiKeyPlaceholder: string
/** 키 없이도 동작하는 엔드포인트(로컬 모델 서버)인지 여부 */
apiKeyOptional?: boolean
docsUrl?: string
docsLabel?: string
modelHint?: string
}
export const PROVIDER_PRESETS: ProviderPreset[] = [
{
id: 'gemini',
label: 'Google Gemini',
provider: 'gemini',
baseUrl: '',
defaultModel: DEFAULT_GEMINI_MODEL,
description: 'Google에 직접 호출합니다. 무료 티어가 있어 가장 간단합니다.',
apiKeyLabel: 'Gemini API 키',
apiKeyPlaceholder: 'AIzaSy...',
docsUrl: 'https://aistudio.google.com/apikey',
docsLabel: 'Google AI Studio',
modelHint:
'예: gemini-3.5-flash-lite(저렴·빠름), gemini-3.6-flash, gemini-2.5-pro',
},
{
id: 'openai',
label: 'OpenAI',
provider: 'openai-compatible',
baseUrl: 'https://api.openai.com/v1',
defaultModel: 'gpt-4o-mini',
description: 'OpenAI Chat Completions API를 사용합니다.',
apiKeyLabel: 'OpenAI API 키',
apiKeyPlaceholder: 'sk-...',
docsUrl: 'https://platform.openai.com/api-keys',
docsLabel: 'OpenAI 대시보드',
modelHint: '예: gpt-4o-mini, gpt-4o',
},
{
id: 'orcarouter',
label: 'OrcaRouter',
provider: 'openai-compatible',
baseUrl: 'https://api.orcarouter.ai/v1',
defaultModel: 'google/gemini-3.5-flash-lite',
description:
'하나의 키로 여러 제공사 모델을 사용합니다. 별도 가입과 크레딧 충전(또는 BYOK 등록)이 필요합니다.',
apiKeyLabel: 'OrcaRouter API 키',
apiKeyPlaceholder: 'sk-...',
docsUrl: 'https://www.orcarouter.ai/',
docsLabel: 'OrcaRouter',
modelHint:
'예: google/gemini-3.5-flash-lite, openai/gpt-4o-mini, orcarouter/auto',
},
{
id: 'local',
label: '로컬 모델',
provider: 'openai-compatible',
baseUrl: 'http://localhost:11434/v1',
defaultModel: 'llama3.1',
description:
'Ollama · LM Studio · vLLM 등 내 PC에서 도는 모델. 회의 내용이 외부로 나가지 않습니다.',
apiKeyLabel: 'API 키 (보통 불필요)',
apiKeyPlaceholder: '비워두세요',
apiKeyOptional: true,
modelHint: 'Ollama 기본 포트는 11434, LM Studio는 1234입니다.',
},
{
id: 'custom',
label: '직접 입력',
provider: 'openai-compatible',
baseUrl: '',
defaultModel: '',
description: 'OpenAI 호환 엔드포인트라면 무엇이든 연결할 수 있습니다.',
apiKeyLabel: 'API 키',
apiKeyPlaceholder: 'sk-...',
apiKeyOptional: true,
modelHint: '엔드포인트가 제공하는 모델 ID를 그대로 입력하세요.',
},
]
export const DEFAULT_PRESET_ID = 'gemini'
export function findPreset(presetId: string | null | undefined): ProviderPreset {
return (
PROVIDER_PRESETS.find((preset) => preset.id === presetId) ??
PROVIDER_PRESETS[0]
)
}
+32
View File
@@ -0,0 +1,32 @@
/**
* LLM 프로바이더 공통 타입.
*
* - `gemini`: Google Generative Language API를 직접 호출 (기본값, 기존 동작)
* - `openai-compatible`: OpenAI Chat Completions 형식을 따르는 모든 엔드포인트
* (OrcaRouter, OpenAI, Ollama, LM Studio, vLLM 등)
*/
export type ProviderId = 'gemini' | 'openai-compatible'
export const PROVIDER_IDS: readonly ProviderId[] = ['gemini', 'openai-compatible']
export function isProviderId(value: unknown): value is ProviderId {
return typeof value === 'string' && (PROVIDER_IDS as readonly string[]).includes(value)
}
export interface ProviderSettings {
provider: ProviderId
/** 로컬 모델 서버처럼 인증이 필요 없는 엔드포인트에서는 빈 문자열일 수 있다. */
apiKey: string
/** openai-compatible 전용. 예: https://api.orcarouter.ai/v1 */
baseUrl?: string
/** 비워두면 프로바이더별 기본 모델을 사용한다. */
model?: string
}
export type CompletionResult =
| { success: true; text: string }
| { success: false; error: string; rateLimited?: boolean }
export interface CompletionOptions {
fetchFn?: typeof fetch
}
+69 -32
View File
@@ -34,20 +34,24 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
depthAdjustable: true,
basePrompt: `당신은 회의록 작성 전문가입니다. 아래 발화 내용을 분석하여 마크다운 회의록을 작성하세요.
포함할 섹션:
## 요약
(3~5줄 핵심 내용)
회의 전체를 3~5줄로. 무엇을 논의했고 무엇이 정해졌는지.
## 주요 논의 사항
1. 순번 매기기
이어지는 본문은 **섹션 제목을 직접 지어서** 구성하세요:
- 실제로 논의된 주제를 스스로 파악해 \`##\` 제목을 붙여 나눕니다.
- "주요 논의 사항", "논의 내용" 같은 일반적인 제목은 쓰지 마세요.
이 회의가 무엇을 다뤘는지 제목만 봐도 알 수 있어야 합니다.
(예: "단계별 개발 계획", "수익화 방안", "기술적 고려사항")
- 주제 수는 내용에 따라 정하되 보통 3~6개입니다.
## 액션 아이템
- [ ] 담당자와 기한이 명확한 항목만
반드시 \`- [ ]\` 체크박스 형식으로 작성하세요.
담당자가 파악되면 \`(담당자)\`를 앞에, 기한이 언급됐으면 뒤에 덧붙입니다.
예: \`- [ ] (김지훈) 경쟁 사이트 리스트업 — 5/17까지\`
담당자가 불분명해도 해야 할 일이 명확하면 포함하세요.
## 결정 사항
- 합의 또는 확정된 것만
한국어로 작성하세요.`,
- 합의되었거나 확정된 것만. 없으면 이 섹션을 생략하세요.`,
},
lecture: {
@@ -60,20 +64,18 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
depthAdjustable: true,
basePrompt: `당신은 학습용 노트를 작성하는 전문가입니다. 강의/세미나 녹취를 구조화된 학습 자료로 정리하세요.
포함할 섹션:
## 주요 주제
(한 줄 요약)
## 핵심 개념
각 개념마다 ### 소제목 + 설명 + 구체 예시
소제목은 강의에서 실제로 다룬 개념 이름을 그대로 쓰세요.
## 기억할 인용·예시
> 중요한 문장 인용 형식으로
## 후속 질문
- 스스로 탐구해볼 만한 질문
한국어로 작성하세요.`,
- 스스로 탐구해볼 만한 질문`,
},
one_on_one: {
@@ -86,12 +88,11 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
depthAdjustable: true,
basePrompt: `당신은 1:1 미팅 정리 전문가입니다. 개인적이고 신뢰 기반의 대화임을 존중하며 정리하세요.
포함할 섹션:
## 이번 세션 요지
(2~3줄)
## 논의한 주제
- 주요 대화 흐름
이어서 대화에서 실제로 다룬 주제마다 **\`##\` 제목을 직접 지어** 나누세요.
"논의한 주제" 같은 일반적인 제목 대신, 무엇에 대한 이야기였는지 드러내는 제목을 쓰세요.
## 고민·블로커
- 공유된 어려움
@@ -100,9 +101,7 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
- 건설적 피드백 위주
## 다음 미팅까지 할 일
- [ ] 합의된 후속 조치
한국어로 작성하세요.`,
- [ ] 합의된 후속 조치. 담당자가 있으면 \`(담당자)\` 표기`,
},
brainstorm: {
@@ -115,12 +114,12 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
depthAdjustable: true,
basePrompt: `당신은 브레인스토밍 세션을 정리하는 전문가입니다. 나온 아이디어를 분류하고 실행 가능성 관점에서 정리하세요.
포함할 섹션:
## 세션 개요
(1~2줄 — 주제/목표)
## 아이디어 (카테고리별)
### [카테고리 이름]
카테고리는 미리 정해진 목록이 아니라, 나온 아이디어를 보고 직접 묶어서 이름 붙이세요.
- 아이디어 요약
## 즉시 시도 가능
@@ -130,9 +129,7 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
- 사유 간단히
## 다음 스텝
- 구체적인 후속 행동
한국어로 작성하세요.`,
- [ ] 구체적인 후속 행동. 담당자가 있으면 \`(담당자)\` 표기`,
},
interview: {
@@ -145,7 +142,6 @@ export const TEMPLATES: Record<Exclude<TemplateId, 'custom'>, Template> = {
depthAdjustable: true,
basePrompt: `당신은 인터뷰 녹취를 Q&A 형식으로 정리하는 전문가입니다.
포함할 섹션:
## 개요
(인터뷰 대상/주제 1~2줄)
@@ -159,9 +155,7 @@ A: [답변 요약]
> 직접 인용
## 종합 인상
- 전반적 테마 / 놓치지 말 것
한국어로 작성하세요.`,
- 전반적 테마 / 놓치지 말 것`,
},
raw: {
@@ -181,8 +175,7 @@ A: [답변 요약]
엄격한 규칙:
- 내용을 삭제하거나 축약하지 마세요
- 구조적 제목(## 섹션)을 덧붙이지 마세요
- 타임스탬프가 있으면 그대로 유지하세요
- 한국어로 작성하세요.`,
- 타임스탬프가 있으면 그대로 유지하세요`,
},
}
@@ -195,13 +188,41 @@ const DEPTH_MODIFIERS: Record<SummaryDepth, string> = {
'작성 강도: **상세**. 원문의 주요 세부사항·수치·고유명사를 누락 없이 포함하세요. 구조만 바꾸고 내용은 최대한 보존합니다.',
}
const LIVE_MODIFIER = `회의가 아직 진행 중입니다. 지금까지의 내용을 기반으로 **중간 정리**를 작성하세요. 확정되지 않은 결정은 "(논의 중)"으로 표시하세요.`
const LIVE_MODIFIER = `회의가 아직 진행 중입니다. 지금까지의 내용을 기반으로 **중간 정리**를 작성하세요. 확정되지 않은 결정은 "(논의 중)"으로 표시하세요.
섹션 제목은 지금까지 나온 주제를 기준으로 붙이되, 이후 갱신될 수 있으므로 간결하게 유지하세요.`
/**
* 프리셋 템플릿 전체에 공통으로 덧붙는 규칙.
*
* custom 프롬프트에는 적용하지 않는다 — 사용자가 전적으로 제어하는 영역이기 때문.
*/
const COMMON_RULES = `공통 규칙:
- 발화에 없는 내용을 지어내지 마세요. 불확실하면 쓰지 마세요.
- 발화자 표기가 있다면 누가 무엇을 주장하고 약속했는지 반영하세요.
- 영어 기술 용어·제품명은 번역하거나 음차하지 말고 원문 표기를 유지하세요 (예: latency, deployment).
- 한국어로 작성하세요.`
/**
* 증분 갱신 모드.
*
* 전사 전체를 매번 다시 보내는 대신 [지금까지의 요약] + [새로 추가된 발화]만 보낸다.
* 호출당 토큰이 회의 길이와 무관하게 일정해져, 비용이 제곱이 아닌 선형으로 늘어난다.
*/
const INCREMENTAL_MODIFIER = `아래에는 [지금까지의 요약]과 [새로 추가된 발화]가 주어집니다.
기존 요약을 처음부터 다시 쓰지 말고 **갱신**하세요:
- 기존 요약의 내용과 구조를 유지한 채 새 발화를 반영합니다.
- 기존 항목이 새 발화로 확정되거나 번복되었다면 그 항목을 고치세요.
- 새 발화에 언급되지 않았다는 이유로 기존 내용을 삭제하지 마세요.
- 출력은 항상 갱신된 회의록 **전체**입니다. 변경분만 출력하지 마세요.`
export interface BuildPromptArgs {
templateId: TemplateId
depth: SummaryDepth
/** 증분 모드에서는 [지금까지의 요약] + [새로 추가된 발화]를 담은 블록이 들어온다. */
transcript: string
live?: boolean
/** 직전 요약을 갱신하는 모드. live와 함께 쓴다. */
incremental?: boolean
customPrompt?: string
}
@@ -222,14 +243,21 @@ export function resolveDepth(
}
export function buildPrompt(args: BuildPromptArgs): string {
const { templateId, depth, transcript, live = false, customPrompt } = args
const {
templateId,
depth,
transcript,
live = false,
incremental = false,
customPrompt,
} = args
let instruction: string
if (templateId === 'custom') {
const custom = customPrompt?.trim()
if (!custom) {
instruction = TEMPLATES.meeting.basePrompt
instruction = `${TEMPLATES.meeting.basePrompt}\n\n${COMMON_RULES}`
} else {
instruction = custom
}
@@ -243,12 +271,21 @@ export function buildPrompt(args: BuildPromptArgs): string {
if (live && template.liveSupported) {
parts.push(LIVE_MODIFIER)
if (incremental) {
parts.push(INCREMENTAL_MODIFIER)
}
}
parts.push(COMMON_RULES)
instruction = parts.join('\n\n')
}
return `${instruction}\n\n---\n음성 인식 텍스트:\n${transcript}`
// 증분 모드의 transcript는 자체 라벨([지금까지의 요약] 등)을 이미 포함한다.
const body = incremental ? transcript : `음성 인식 텍스트:\n${transcript}`
return `${instruction}\n\n---\n${body}`
}
export function liveEnabledFor(templateId: TemplateId): boolean {