From 2b17bf47b75daec1585316394478de75d60f3367 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Wed, 8 Apr 2026 10:18:42 +0900 Subject: [PATCH] =?UTF-8?q?Phase=2015=20=EA=B5=AC=ED=98=84:=20Auto=20Polis?= =?UTF-8?q?h=20+=20AI=20=EC=B1=84=ED=8C=85=20+=20=EB=A7=88=EC=9D=B8?= =?UTF-8?q?=EB=93=9C=EB=A7=B5=20+=20=EA=B3=B5=EC=9C=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auto Polish: LLM으로 전사 다듬기 (필러 제거, 문장 교정) - AI 채팅: 회의 전사 기반 Q&A (LocalLLMService.chatStream 활용, 스트리밍) - 마인드맵: 빌트인 템플릿 추가 (마크다운 계층 트리) - 공유: 클립보드 복사 메뉴 추가 - MeetingChatPanel UI (접기/펼치기, 스트리밍 인디케이터) - TranscriptTab에 AI 다듬기 버튼 추가 - 12개 locale i18n 키 추가 --- CLAUDE.md | 3 +- docs/phases/phase-15-meeting-pro.md | 205 +++++++++++ src/main/db/schema.ts | 2 +- src/main/ipc/meeting-mode-handlers.ts | 47 +++ .../services/MeetingDocTemplateService.ts | 26 ++ src/main/services/MeetingModeService.ts | 97 ++++++ src/preload/index.ts | 22 ++ .../components/meeting/ExportMenu.tsx | 19 +- .../components/meeting/MeetingChatPanel.tsx | 325 ++++++++++++++++++ .../components/meeting/MeetingDetailTabs.tsx | 4 + .../components/meeting/TranscriptTab.tsx | 54 ++- src/renderer/i18n/de.json | 14 +- src/renderer/i18n/en.json | 14 +- src/renderer/i18n/es.json | 14 +- src/renderer/i18n/fr.json | 14 +- src/renderer/i18n/ja.json | 14 +- src/renderer/i18n/ko.json | 14 +- src/renderer/i18n/pt.json | 14 +- src/renderer/i18n/ru.json | 14 +- src/renderer/i18n/th.json | 14 +- src/renderer/i18n/vi.json | 14 +- src/renderer/i18n/zh-TW.json | 14 +- src/renderer/i18n/zh.json | 14 +- src/shared/errors.ts | 2 + src/shared/ipc-channels.ts | 11 + src/shared/types.ts | 21 +- 26 files changed, 980 insertions(+), 26 deletions(-) create mode 100644 docs/phases/phase-15-meeting-pro.md create mode 100644 src/renderer/components/meeting/MeetingChatPanel.tsx diff --git a/CLAUDE.md b/CLAUDE.md index db56339..ab16b80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ npm run typecheck # tsc --noEmit - 녹음 UI: 9개 웨이브 바, cos 분포, 100ms ## 현재 상태 -Phase 1~14 전체 완료 + 랜딩 페이지(site/). +Phase 1~14.5 전체 완료 + 랜딩 페이지(site/). 차단 이슈: @nut-tree-fork/nut-js 포크 사용 ## 설계 문서 (구현 시 Read 도구로 참조) @@ -50,6 +50,7 @@ Phase10+: MemoService, VoiceCommandService, ScreenContextService, ChainService, Phase12+: FileTranscriptionService, MeetingSummaryService, DictationTemplateService Phase13+: VoiceConversationService, RAGService, VoiceActionService Phase14: MeetingModeService +Phase14.5: MeetingDocTemplateService + 다중 문서 생성/편집/내보내기 ## DS 컴포넌트 (src/renderer/components/ds/) CrtDisplay, InstrumentPanel, Led, PhysicalButton, MetalCard, diff --git a/docs/phases/phase-15-meeting-pro.md b/docs/phases/phase-15-meeting-pro.md new file mode 100644 index 0000000..eb8bf20 --- /dev/null +++ b/docs/phases/phase-15-meeting-pro.md @@ -0,0 +1,205 @@ +# Phase 15: 회의 모드 Pro — Auto Polish + AI 채팅 + 마인드맵 + 공유 + +> Phase 14.5 기반 확장: AI 전사 다듬기, 회의록 기반 AI 채팅, 마인드맵 생성, 공유 링크 + +--- + +## 1. 개요 + +### 1.1 기능 4종 + +| 기능 | 설명 | 난이도 | +|------|------|--------| +| **Auto Polish** | 전사 텍스트 AI 다듬기 (필러 제거, 가독성 향상) | 소 | +| **마인드맵** | 전사 내용에서 AI 마인드맵 생성 (마크다운 트리) | 소 | +| **AI 채팅** | 회의 전사 기반 Q&A 채팅 | 중 | +| **공유** | 마크다운 → 클립보드 복사 + 파일 공유 | 소 | + +**화자 구분**: sidecar에 pyannote-audio 추가 필요 → TODO로 유지 + +--- + +## 2. Auto Polish + +### 2.1 동작 방식 +1. Transcript 탭에서 [AI 다듬기] 버튼 클릭 +2. rawTranscript를 LLM에 전달 (refine 프롬프트) +3. 결과를 editedTranscript에 저장 +4. "원본/AI 다듬기본" 토글 가능 + +### 2.2 IPC +``` +MEETING_MODE.POLISH_TRANSCRIPT: 'meetingMode:polishTranscript' +``` + +### 2.3 서비스 메서드 +```typescript +async polishTranscript(sessionId: string): Promise { + // rawTranscript → LLM refine → editedTranscript 저장 → 반환 +} +``` + +### 2.4 UI +TranscriptTab에 [AI 다듬기] PhysicalButton 추가. 처리 중 LinearProgress. + +--- + +## 3. 마인드맵 + +### 3.1 동작 방식 +MeetingDocTemplateType에 'mindmap' 추가. 빌트인 템플릿으로 등록. +LLM이 마크다운 트리 구조로 마인드맵 생성. DocumentTab에서 렌더링. + +### 3.2 프롬프트 +``` +전사록을 분석하여 마인드맵을 작성하세요. +마크다운 계층 구조를 사용하세요: +# 중심 주제 +## 주요 주제 1 + - 세부 항목 A + - 세부 항목 B +## 주요 주제 2 + - 세부 항목 C +``` + +### 3.3 DB 변경 +`meetingDocuments.templateType` enum에 'mindmap' 추가. +`meetingSessions.templateType` (schema.ts) enum에 'mindmap' 추가. + +--- + +## 4. AI 채팅 + +### 4.1 동작 방식 +1. 상세 페이지 하단에 채팅 바 (고정, 탭과 별개) +2. 사용자가 질문 입력 → 전사+문서를 컨텍스트로 LLM 질문 +3. 스트리밍 응답 표시 +4. 대화 히스토리는 메모리에만 유지 (DB 저장 안 함) + +### 4.2 IPC +``` +MEETING_CHAT: { + SEND: 'meetingChat:send', + CANCEL: 'meetingChat:cancel', + CLEAR: 'meetingChat:clear', + // Main → Renderer + DELTA: 'meetingChat:delta', + MESSAGE: 'meetingChat:message', + ERROR: 'meetingChat:error', +} +``` + +### 4.3 서비스 +MeetingModeService에 채팅 메서드 추가 (별도 서비스 불필요): +```typescript +async chatWithMeeting(sessionId: string, userMessage: string): Promise +cancelMeetingChat(): void +clearMeetingChatHistory(): void +``` + +LocalLLMService.chatStream() 활용. 시스템 프롬프트에 전사 내용 포함. + +### 4.4 UI +MeetingDetailTabs 하단에 고정 채팅 바: +``` +┌──────────────────────────────────────┐ +│ [탭 콘텐츠...] │ +├──────────────────────────────────────┤ +│ 🤖 AI: 예산은 5000만원으로 확정... │ +│ 👤 다음 회의 일정은? │ +│ 🤖 AI: 다음 주 수요일... │ +│ ┌──────────────────────────┐ [Send] │ +│ │ 질문을 입력하세요... │ │ +│ └──────────────────────────┘ │ +└──────────────────────────────────────┘ +``` + +### 4.5 타입 +```typescript +interface MeetingChatMessage { + role: 'user' | 'assistant' + content: string + timestamp: number +} + +interface MeetingChatSendParams { + sessionId: string + message: string +} + +interface MeetingChatDelta { + token: string +} +``` + +--- + +## 5. 공유 + +### 5.1 동작 방식 +1. 클립보드 복사: 문서/전사 마크다운을 클립보드에 복사 +2. 파일 공유: 기존 내보내기(MD/PDF/TXT/DOCX)로 파일 저장 +3. 향후: 웹 서비스 연동 (TODO) + +### 5.2 UI +ExportMenu에 "클립보드 복사" 항목 추가. + +--- + +## 6. 에러 코드 +```typescript +MeetingPolishFailed = 893, +MeetingChatFailed = 894, +``` + +--- + +## 7. i18n 키 +```json +"meeting.polish": "AI 다듬기", +"meeting.polishing": "AI가 전사를 다듬고 있습니다...", +"meeting.polished": "AI 다듬기 완료", +"meeting.mindmapTemplate": "마인드맵", +"meeting.chat": "AI 채팅", +"meeting.chatPlaceholder": "회의 내용에 대해 질문하세요...", +"meeting.chatSend": "전송", +"meeting.chatClear": "대화 초기화", +"meeting.copyToClipboard": "클립보드 복사", +"meeting.copied": "복사됨" +``` + +--- + +## 8. 구현 순서 + +### Step A: 기반 (타입 + IPC + 에러) +### Step B: Auto Polish (서비스 + IPC + UI) +### Step C: 마인드맵 (템플릿 추가 + DB enum) +### Step D: AI 채팅 (서비스 + IPC + UI) +### Step E: 공유 (클립보드 복사) +### Step F: i18n + 검증 + +--- + +## 파일 목록 + +### 신규 +| 파일 | 설명 | +|------|------| +| `src/renderer/components/meeting/MeetingChatPanel.tsx` | AI 채팅 UI | + +### 수정 +| 파일 | 변경 | +|------|------| +| `src/shared/types.ts` | MeetingChatMessage, MeetingChatSendParams, MeetingChatDelta, MeetingDocTemplateType에 mindmap 추가 | +| `src/shared/ipc-channels.ts` | MEETING_MODE.POLISH_TRANSCRIPT + MEETING_CHAT 그룹 | +| `src/shared/errors.ts` | 893-894 | +| `src/main/db/schema.ts` | meetingDocuments templateType enum에 mindmap | +| `src/main/services/MeetingModeService.ts` | polishTranscript, chatWithMeeting, cancelMeetingChat, clearMeetingChatHistory | +| `src/main/services/MeetingDocTemplateService.ts` | 빌트인 마인드맵 템플릿 | +| `src/main/ipc/meeting-mode-handlers.ts` | 4개 핸들러 추가 | +| `src/preload/index.ts` | meetingChat 그룹 + polishTranscript | +| `src/renderer/components/meeting/TranscriptTab.tsx` | AI 다듬기 버튼 | +| `src/renderer/components/meeting/MeetingDetailTabs.tsx` | 하단 채팅 패널 | +| `src/renderer/components/meeting/ExportMenu.tsx` | 클립보드 복사 | +| `src/renderer/i18n/*.json` | 12개 locale | diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index 750d498..586b629 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -210,7 +210,7 @@ export const meetingDocuments = sqliteTable( id: text('id').primaryKey(), sessionId: text('session_id').notNull(), templateType: text('template_type', { - enum: ['minutes', 'report', 'idea-note', 'custom'], + enum: ['minutes', 'report', 'idea-note', 'custom', 'mindmap'], }).notNull(), title: text('title').notNull(), content: text('content').notNull().default(''), diff --git a/src/main/ipc/meeting-mode-handlers.ts b/src/main/ipc/meeting-mode-handlers.ts index 3c2bc34..0639271 100644 --- a/src/main/ipc/meeting-mode-handlers.ts +++ b/src/main/ipc/meeting-mode-handlers.ts @@ -14,6 +14,7 @@ import type { MeetingExportParams, MeetingGenerateDocParams, MeetingExportFormat, + MeetingChatSendParams, } from '@shared/types' const logger = getLogger('meeting-mode-handlers') @@ -198,5 +199,51 @@ export function registerMeetingModeHandlers(): void { } }) + // ── Phase 15 핸들러 ── + + ipcMain.handle(ch.POLISH_TRANSCRIPT, async (_event, params: { sessionId: string }) => { + try { + const result = await getMeetingModeService().polishTranscript(params.sessionId) + return ipcSuccess(result) + } catch (err) { + logger.error(`polishTranscript 실패: ${err instanceof Error ? err.message : String(err)}`) + return ipcError( + ErrorCode.MeetingPolishFailed, + err instanceof Error ? err.message : String(err), + ) + } + }) + + ipcMain.handle(IPC_CHANNELS.MEETING_CHAT.SEND, async (_event, params: MeetingChatSendParams) => { + try { + await getMeetingModeService().chatWithMeeting(params.sessionId, params.message) + return ipcSuccess(undefined) + } catch (err) { + logger.error(`meetingChat:send 실패: ${err instanceof Error ? err.message : String(err)}`) + return ipcError( + ErrorCode.MeetingChatFailed, + err instanceof Error ? err.message : String(err), + ) + } + }) + + ipcMain.handle(IPC_CHANNELS.MEETING_CHAT.CANCEL, () => { + try { + getMeetingModeService().cancelMeetingChat() + return ipcSuccess(undefined) + } catch (err) { + return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err)) + } + }) + + ipcMain.handle(IPC_CHANNELS.MEETING_CHAT.CLEAR, () => { + try { + getMeetingModeService().clearMeetingChatHistory() + return ipcSuccess(undefined) + } catch (err) { + return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err)) + } + }) + logger.info('Meeting Mode IPC handlers registered') } diff --git a/src/main/services/MeetingDocTemplateService.ts b/src/main/services/MeetingDocTemplateService.ts index 46d24a1..51204df 100644 --- a/src/main/services/MeetingDocTemplateService.ts +++ b/src/main/services/MeetingDocTemplateService.ts @@ -118,6 +118,32 @@ const BUILTIN_TEMPLATES: MeetingDocTemplate[] = [ createdAt: Date.now(), updatedAt: Date.now(), }, + { + id: 'builtin-mindmap', + name: '마인드맵', + description: '전사 내용의 핵심 구조를 시각적 마인드맵으로 정리', + templateType: 'mindmap' as MeetingDocTemplateType, + systemPrompt: `전사록을 분석하여 마인드맵을 마크다운 계층 구조로 작성하세요. + +# 중심 주제 (회의의 핵심 주제) +## 주요 주제 1 + - 세부 항목 A + - 세부 항목 B + - 하위 항목 +## 주요 주제 2 + - 세부 항목 C +## 결론 및 다음 단계 + - 액션 아이템 + +규칙: +- 계층은 최대 4단계까지 +- 각 항목은 간결하게 (한 줄) +- 전사록에 없는 내용 추가 금지 +- 한국어로 작성`, + isBuiltin: true, + createdAt: Date.now(), + updatedAt: Date.now(), + }, ] interface MeetingDocTemplateStoreSchema { diff --git a/src/main/services/MeetingModeService.ts b/src/main/services/MeetingModeService.ts index 6f16797..b57391f 100644 --- a/src/main/services/MeetingModeService.ts +++ b/src/main/services/MeetingModeService.ts @@ -65,6 +65,10 @@ class MeetingModeService extends EventEmitter { /** CaptionService session-saved 방지 플래그 */ private _meetingModeActive = false + // Phase 15: Meeting AI Chat + private _chatHistory: Array<{ role: 'user' | 'assistant'; content: string }> = [] + private _chatAbortController: AbortController | null = null + // ── 공개 접근자 ── getState(): MeetingModeState { @@ -823,6 +827,99 @@ class MeetingModeService extends EventEmitter { } } + // ── Phase 15: Auto Polish ── + + async polishTranscript(sessionId: string): Promise { + const db = getDatabase() + const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() + if (!row) throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`) + + const transcript = row.rawTranscript + if (!transcript) throw new D3ROError(ErrorCode.MeetingPolishFailed, '전사 텍스트가 없습니다') + + const { getLocalLLMService } = await import('./LocalLLMService') + const llm = getLocalLLMService() + const result = await llm.generate(transcript, { + systemPrompt: '다음 음성 전사 텍스트를 다듬어주세요. 필러 단어(음, 어, 그, 아 등)를 제거하고, 문장 구조를 자연스럽게 교정하되, 원래 의미와 내용은 절대 변경하지 마세요. 타임스탬프 형식 [MM:SS]은 그대로 유지하세요.', + temperature: 0.3, + }) + + db.update(meetingSessions).set({ + editedTranscript: result.text, + updatedAt: Date.now(), + }).where(eq(meetingSessions.id, sessionId)).run() + + logger.info(`Auto Polish 완료: sessionId=${sessionId}`) + return result.text + } + + // ── Phase 15: Meeting AI Chat ── + + async chatWithMeeting(sessionId: string, userMessage: string): Promise { + const db = getDatabase() + const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get() + if (!row) throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`) + + const transcript = row.editedTranscript ?? row.rawTranscript ?? '' + + const systemPrompt = `당신은 회의 내용을 분석하는 AI 어시스턴트입니다. +아래 회의 전사 내용을 참고하여 사용자의 질문에 정확하게 답변하세요. +전사 내용에 없는 것은 "전사 내용에서 확인되지 않습니다"라고 답하세요. + +## 회의 전사 +${transcript}` + + this._chatHistory.push({ role: 'user', content: userMessage }) + + const { getLocalLLMService } = await import('./LocalLLMService') + const llm = getLocalLLMService() + + const messages = [ + { role: 'system' as const, content: systemPrompt }, + ...this._chatHistory, + ] + + let fullResponse = '' + this._chatAbortController = new AbortController() + const signal = this._chatAbortController.signal + + try { + const generator = llm.chatStream(messages, { temperature: 0.5 }) + + for await (const token of generator) { + if (signal.aborted) break + fullResponse += token + this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.DELTA, { token }) + } + + this._chatHistory.push({ role: 'assistant', content: fullResponse }) + this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.MESSAGE, { + role: 'assistant', + content: fullResponse, + timestamp: Date.now(), + }) + } catch (err) { + if ((err as Error).name !== 'AbortError') { + this._sendToRenderer(IPC_CHANNELS.MEETING_CHAT.ERROR, { + message: err instanceof Error ? err.message : String(err), + }) + } + } finally { + this._chatAbortController = null + } + } + + cancelMeetingChat(): void { + if (this._chatAbortController) { + this._chatAbortController.abort() + this._chatAbortController = null + } + } + + clearMeetingChatHistory(): void { + this._chatHistory = [] + } + private _buildPdfHtml(session: MeetingSessionDetail): string { const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0 const minutesHtml = session.minutesMarkdown diff --git a/src/preload/index.ts b/src/preload/index.ts index e3e84b7..efea7ab 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -162,6 +162,9 @@ import type { CreateMeetingDocTemplateParams, UpdateMeetingDocTemplateParams, DeleteMeetingDocTemplateParams, + MeetingChatMessage, + MeetingChatSendParams, + MeetingChatDelta, } from '@shared/types' import { Feature } from '@shared/types' import type { IPCResult } from '@shared/errors' @@ -635,6 +638,25 @@ const electronAPI = { invoke(IPC_CHANNELS.MEETING_MODE.EDIT_SEGMENT, params), onDocGeneratingProgress: (cb: (e: MeetingDocGeneratingProgress) => void): Unsubscribe => on(IPC_CHANNELS.MEETING_MODE.DOC_GENERATING_PROGRESS, cb), + // Phase 15 + polishTranscript: (params: { sessionId: string }) => + invoke(IPC_CHANNELS.MEETING_MODE.POLISH_TRANSCRIPT, params), + }, + + // ── Meeting Chat (Phase 15) ─────────────────────────── + meetingChat: { + send: (params: MeetingChatSendParams) => + invoke(IPC_CHANNELS.MEETING_CHAT.SEND, params), + cancel: () => + invoke(IPC_CHANNELS.MEETING_CHAT.CANCEL), + clear: () => + invoke(IPC_CHANNELS.MEETING_CHAT.CLEAR), + onDelta: (cb: (e: MeetingChatDelta) => void): Unsubscribe => + on(IPC_CHANNELS.MEETING_CHAT.DELTA, cb), + onMessage: (cb: (e: MeetingChatMessage) => void): Unsubscribe => + on(IPC_CHANNELS.MEETING_CHAT.MESSAGE, cb), + onError: (cb: (e: { message: string }) => void): Unsubscribe => + on(IPC_CHANNELS.MEETING_CHAT.ERROR, cb), }, // ── Meeting Doc Templates (Phase 14.5) ─────────────── diff --git a/src/renderer/components/meeting/ExportMenu.tsx b/src/renderer/components/meeting/ExportMenu.tsx index af24598..c95bbda 100644 --- a/src/renderer/components/meeting/ExportMenu.tsx +++ b/src/renderer/components/meeting/ExportMenu.tsx @@ -11,6 +11,7 @@ import type { MeetingExportFormat } from '@shared/types' interface ExportMenuProps { onExport: (format: MeetingExportFormat) => void + onCopyToClipboard?: () => void } interface FormatItem { @@ -18,7 +19,7 @@ interface FormatItem { label: string } -export function ExportMenu({ onExport }: ExportMenuProps): React.ReactElement { +export function ExportMenu({ onExport, onCopyToClipboard }: ExportMenuProps): React.ReactElement { const { t } = useI18n() const [anchorEl, setAnchorEl] = useState(null) const open = Boolean(anchorEl) @@ -90,6 +91,22 @@ export function ExportMenu({ onExport }: ExportMenuProps): React.ReactElement { {label} ))} + {onCopyToClipboard && ( + { onCopyToClipboard(); handleClose() }} + sx={{ + fontFamily: d3roFontMono, + fontSize: d3roTypo.compact.size, + color: d3roPalette.text.primary, + '&:hover': { + bgcolor: d3roPalette.bg.cardHover, + color: d3roPalette.accent.amber, + }, + }} + > + {t('meeting.copyToClipboard')} + + )} ) diff --git a/src/renderer/components/meeting/MeetingChatPanel.tsx b/src/renderer/components/meeting/MeetingChatPanel.tsx new file mode 100644 index 0000000..aa078d7 --- /dev/null +++ b/src/renderer/components/meeting/MeetingChatPanel.tsx @@ -0,0 +1,325 @@ +// src/renderer/components/meeting/MeetingChatPanel.tsx +// Phase 15: 회의 상세 페이지 하단 AI 채팅 패널 + +import { useState, useCallback, useEffect, useRef } from 'react' +import { Box, TextField, IconButton, LinearProgress, Tooltip } from '@mui/material' +import SendIcon from '@mui/icons-material/Send' +import DeleteSweepIcon from '@mui/icons-material/DeleteSweep' +import ExpandLessIcon from '@mui/icons-material/ExpandLess' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import { PhosphorText } from '../ds/PhosphorText' +import { PhysicalButton } from '../ds/PhysicalButton' +import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow } from '../../theme' +import { useI18n } from '../../i18n' +import type { MeetingChatMessage } from '@shared/types' + +interface MeetingChatPanelProps { + sessionId: string +} + +const COLLAPSED_HEIGHT = 40 +const EXPANDED_HEIGHT = 240 + +// 타이핑 인디케이터 점 3개 애니메이션 +function TypingIndicator(): React.ReactElement { + return ( + + {[0, 1, 2].map((i) => ( + + ))} + + ) +} + +export function MeetingChatPanel({ sessionId }: MeetingChatPanelProps): React.ReactElement { + const { t } = useI18n() + const [messages, setMessages] = useState([]) + const [inputValue, setInputValue] = useState('') + const [streaming, setStreaming] = useState(false) + const [streamingContent, setStreamingContent] = useState('') + const [collapsed, setCollapsed] = useState(false) + const scrollRef = useRef(null) + const inputRef = useRef(null) + + // 스크롤 하단 유지 + const scrollToBottom = useCallback(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, []) + + useEffect(() => { + scrollToBottom() + }, [messages, streamingContent, scrollToBottom]) + + // 이벤트 구독 + useEffect(() => { + const unsubDelta = window.electronAPI.meetingChat.onDelta((data) => { + setStreaming(true) + setStreamingContent((prev) => prev + data.token) + }) + + const unsubMessage = window.electronAPI.meetingChat.onMessage((msg) => { + setMessages((prev) => [...prev, msg]) + setStreaming(false) + setStreamingContent('') + }) + + const unsubError = window.electronAPI.meetingChat.onError((_err) => { + setStreaming(false) + setStreamingContent('') + }) + + return () => { + unsubDelta() + unsubMessage() + unsubError() + } + }, []) + + const handleSend = useCallback(() => { + const text = inputValue.trim() + if (!text || streaming) return + + const userMsg: MeetingChatMessage = { + role: 'user', + content: text, + timestamp: Date.now(), + } + setMessages((prev) => [...prev, userMsg]) + setInputValue('') + window.electronAPI.meetingChat.send({ sessionId, message: text }) + inputRef.current?.focus() + }, [inputValue, streaming, sessionId]) + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSend() + } + }, + [handleSend], + ) + + const handleClear = useCallback(() => { + setMessages([]) + setStreamingContent('') + setStreaming(false) + window.electronAPI.meetingChat.clear({ sessionId }) + }, [sessionId]) + + const handleToggleCollapse = useCallback(() => { + setCollapsed((prev) => !prev) + }, []) + + return ( + + {/* 헤더 */} + + + {t('meeting.chat')} + + + + + + + + + + + {collapsed ? ( + + ) : ( + + )} + + + + + {!collapsed && ( + <> + {/* 메시지 히스토리 */} + + {messages.map((msg, idx) => ( + + + {msg.content} + + + ))} + + {/* 스트리밍 중 어시스턴트 메시지 */} + {streaming && ( + + + {streamingContent || } + + + )} + + + {/* 스트리밍 진행 표시 */} + {streaming && ( + + )} + + {/* 입력 영역 */} + + setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={t('meeting.chatPlaceholder')} + disabled={streaming} + sx={{ + '& .MuiInputBase-root': { + fontFamily: d3roFontMono, + fontSize: d3roTypo.compact.size, + bgcolor: d3roPalette.bg.input, + borderRadius: '4px', + }, + '& .MuiOutlinedInput-notchedOutline': { + borderColor: d3roPalette.border.default, + }, + '&:hover .MuiOutlinedInput-notchedOutline': { + borderColor: d3roPalette.border.strong, + }, + '& .Mui-focused .MuiOutlinedInput-notchedOutline': { + borderColor: d3roPalette.accent.amber, + }, + }} + /> + + + + + + )} + + ) +} diff --git a/src/renderer/components/meeting/MeetingDetailTabs.tsx b/src/renderer/components/meeting/MeetingDetailTabs.tsx index b932916..5f16fcb 100644 --- a/src/renderer/components/meeting/MeetingDetailTabs.tsx +++ b/src/renderer/components/meeting/MeetingDetailTabs.tsx @@ -17,6 +17,7 @@ import CheckIcon from '@mui/icons-material/Check' import { TranscriptTab } from './TranscriptTab' import { DocumentTab } from './DocumentTab' import { AddDocumentDialog } from './AddDocumentDialog' +import { MeetingChatPanel } from './MeetingChatPanel' import { PhosphorText } from '../ds/PhosphorText' import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme' import { useI18n } from '../../i18n' @@ -323,6 +324,9 @@ export function MeetingDetailTabs({ )} + {/* AI 채팅 패널 */} + + {/* 문서 생성 다이얼로그 */} { + setPolishing(true) + const resp = await window.electronAPI.meetingMode.polishTranscript({ sessionId }) + if (resp.success) { + onSaveTranscript(resp.data) + } + setPolishing(false) + }, [sessionId, onSaveTranscript]) const handleDownloadTxt = useCallback(() => { const content = showEdited && editedTranscript ? editedTranscript : (rawTranscript ?? '') @@ -91,16 +102,39 @@ export function TranscriptTab({ } /> - - - {t('meeting.downloadTranscript')} - + + + + {polishing ? t('meeting.polishing') : t('meeting.polish')} + + + + {t('meeting.downloadTranscript')} + + + {/* 폴리싱 진행 표시 */} + {polishing && ( + + )} + {/* 타임라인 스크롤 영역 */}